diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index a5bec8f..4ed1683 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -2,6 +2,8 @@ import { createServer } from "http"; import app from "./app.js"; import { attachWebSocketServer } from "./routes/events.js"; import { rootLogger } from "./lib/logger.js"; +import { timmyIdentityService } from "./lib/timmy-identity.js"; +import { startEngagementEngine } from "./lib/engagement.js"; const rawPort = process.env["PORT"]; @@ -20,10 +22,12 @@ attachWebSocketServer(server); server.listen(port, () => { rootLogger.info("server started", { port }); + rootLogger.info("timmy identity", { npub: timmyIdentityService.npub }); const domain = process.env["REPLIT_DEV_DOMAIN"]; if (domain) { rootLogger.info("public url", { url: `https://${domain}/api/ui` }); rootLogger.info("tower url", { url: `https://${domain}/tower` }); rootLogger.info("ws url", { url: `wss://${domain}/api/ws` }); } + startEngagementEngine(); }); diff --git a/artifacts/api-server/src/lib/engagement.ts b/artifacts/api-server/src/lib/engagement.ts new file mode 100644 index 0000000..dd6166f --- /dev/null +++ b/artifacts/api-server/src/lib/engagement.ts @@ -0,0 +1,140 @@ +/** + * engagement.ts — Proactive engagement engine for Timmy. + * + * When enabled (ENGAGEMENT_INTERVAL_DAYS > 0), runs on a configurable + * schedule and sends a Nostr DM (NIP-04 encrypted, kind 4) to trusted + * partners who have been absent for ENGAGEMENT_ABSENT_DAYS days. + * + * Events are signed with Timmy's key and logged to timmy_nostr_events. + * Broadcasting to a Nostr relay requires NOSTR_RELAY_URL to be set. + * + * Configuration (env vars): + * ENGAGEMENT_INTERVAL_DAYS — how often to run the check (default 0 = disabled) + * ENGAGEMENT_ABSENT_DAYS — absence threshold to trigger a DM (default 7) + * NOSTR_RELAY_URL — relay URL; if set, events are POSTed there + */ + +import { randomUUID } from "crypto"; +import { db, nostrIdentities, timmyNostrEvents } from "@workspace/db"; +import { and, gte, lt } from "drizzle-orm"; +import { timmyIdentityService } from "./timmy-identity.js"; +import { agentService } from "./agent.js"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("engagement"); + +function envInt(name: string, fallback: number): number { + const v = parseInt(process.env[name] ?? "", 10); + return Number.isFinite(v) && v >= 0 ? v : fallback; +} + +const ENGAGEMENT_INTERVAL_DAYS = envInt("ENGAGEMENT_INTERVAL_DAYS", 0); +const ENGAGEMENT_ABSENT_DAYS = envInt("ENGAGEMENT_ABSENT_DAYS", 7); +const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? ""; +const MIN_TIER_SCORES = { trusted: 50, elite: 200 } as const; + +async function sendDm(recipientPubkey: string, plaintext: string): Promise { + const ciphertext = await timmyIdentityService.encryptDm(recipientPubkey, plaintext); + + const event = timmyIdentityService.sign({ + kind: 4, + created_at: Math.floor(Date.now() / 1000), + tags: [["p", recipientPubkey]], + content: ciphertext, + }); + + await db.insert(timmyNostrEvents).values({ + id: randomUUID(), + kind: 4, + recipientPubkey, + eventJson: JSON.stringify(event), + amountSats: null, + relayUrl: RELAY_URL || null, + }); + + if (RELAY_URL) { + try { + const r = await fetch(RELAY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(["EVENT", event]), + signal: AbortSignal.timeout(10_000), + }); + if (!r.ok) { + logger.warn("relay rejected DM event", { status: r.status }); + } else { + logger.info("DM event published to relay", { relay: RELAY_URL }); + } + } catch (err) { + logger.warn("relay publish failed", { error: err instanceof Error ? err.message : String(err) }); + } + } + + return event.id; +} + +async function runEngagementCycle(): Promise { + logger.info("engagement cycle started", { absentDays: ENGAGEMENT_ABSENT_DAYS }); + + const threshold = new Date(Date.now() - ENGAGEMENT_ABSENT_DAYS * 24 * 60 * 60 * 1000); + + const absentPartners = await db + .select({ pubkey: nostrIdentities.pubkey, trustScore: nostrIdentities.trustScore }) + .from(nostrIdentities) + .where( + and( + gte(nostrIdentities.trustScore, MIN_TIER_SCORES.trusted), + lt(nostrIdentities.lastSeen, threshold), + ), + ) + .limit(20); + + logger.info("absent trusted partners found", { count: absentPartners.length }); + + for (const partner of absentPartners) { + try { + const prompt = + `Write a short, friendly Nostr DM from Timmy (a wizard AI agent) to a trusted partner ` + + `who hasn't visited the workshop in ${ENGAGEMENT_ABSENT_DAYS}+ days. ` + + `Keep it under 120 characters, warm but not pushy. No emoji overload.`; + + const message = await agentService.chatReply(prompt); + + const eventId = await sendDm(partner.pubkey, message); + logger.info("proactive DM sent", { + to: partner.pubkey.slice(0, 8), + eventId, + }); + } catch (err) { + logger.warn("proactive DM failed for partner", { + pubkey: partner.pubkey.slice(0, 8), + error: err instanceof Error ? err.message : String(err), + }); + } + } + + logger.info("engagement cycle complete", { processed: absentPartners.length }); +} + +export function startEngagementEngine(): void { + if (ENGAGEMENT_INTERVAL_DAYS <= 0) { + logger.info("proactive engagement disabled (set ENGAGEMENT_INTERVAL_DAYS > 0 to enable)"); + return; + } + + const intervalMs = ENGAGEMENT_INTERVAL_DAYS * 24 * 60 * 60 * 1000; + logger.info("proactive engagement engine started", { + intervalDays: ENGAGEMENT_INTERVAL_DAYS, + absentDays: ENGAGEMENT_ABSENT_DAYS, + relay: RELAY_URL || "none", + }); + + const run = () => { + runEngagementCycle().catch(err => { + logger.error("engagement cycle error", { error: err instanceof Error ? err.message : String(err) }); + }); + }; + + setTimeout(run, 60_000); + setInterval(run, intervalMs); +} diff --git a/artifacts/api-server/src/lib/timmy-identity.ts b/artifacts/api-server/src/lib/timmy-identity.ts new file mode 100644 index 0000000..e8c88df --- /dev/null +++ b/artifacts/api-server/src/lib/timmy-identity.ts @@ -0,0 +1,77 @@ +/** + * timmy-identity.ts — Timmy's persistent Nostr identity. + * + * Loads a secp256k1 keypair from TIMMY_NOSTR_NSEC env var at boot. + * If the env var is absent, generates an ephemeral keypair and logs + * a warning instructing the operator to persist it. + * + * Exports `timmyIdentityService` singleton with: + * .npub — bech32 public key (npub1...) + * .nsec — bech32 private key (nsec1...) — never log this + * .pubkeyHex — raw 64-char hex pubkey + * .sign(template) — finalises and signs a Nostr event template + * .encryptDm(recipientPubkeyHex, plaintext) — NIP-04 encrypt + */ + +import { generateSecretKey, getPublicKey, finalizeEvent, nip19, nip04 } from "nostr-tools"; +import type { EventTemplate, NostrEvent } from "nostr-tools"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("timmy-identity"); + +function loadOrGenerate(): { sk: Uint8Array; nsec: string; npub: string; pubkeyHex: string } { + const raw = process.env["TIMMY_NOSTR_NSEC"]; + if (raw && raw.startsWith("nsec1")) { + try { + const decoded = nip19.decode(raw); + if (decoded.type !== "nsec") throw new Error("not nsec type"); + const sk = decoded.data as Uint8Array; + const pubkeyHex = getPublicKey(sk); + const npub = nip19.npubEncode(pubkeyHex); + logger.info("timmy identity loaded from env", { npub }); + return { sk, nsec: raw, npub, pubkeyHex }; + } catch (err) { + logger.warn("TIMMY_NOSTR_NSEC is set but could not be decoded — generating ephemeral key", { + error: err instanceof Error ? err.message : String(err), + }); + } + } + + const sk = generateSecretKey(); + const pubkeyHex = getPublicKey(sk); + const nsec = nip19.nsecEncode(sk); + const npub = nip19.npubEncode(pubkeyHex); + + logger.warn( + "TIMMY_NOSTR_NSEC not set — generated ephemeral Nostr identity. " + + "Set this env var permanently to preserve Timmy's identity across restarts.", + { npub }, + ); + + return { sk, nsec, npub, pubkeyHex }; +} + +class TimmyIdentityService { + readonly npub: string; + readonly nsec: string; + readonly pubkeyHex: string; + private readonly _sk: Uint8Array; + + constructor() { + const identity = loadOrGenerate(); + this.npub = identity.npub; + this.nsec = identity.nsec; + this.pubkeyHex = identity.pubkeyHex; + this._sk = identity.sk; + } + + sign(template: EventTemplate): NostrEvent { + return finalizeEvent(template, this._sk); + } + + async encryptDm(recipientPubkeyHex: string, plaintext: string): Promise { + return nip04.encrypt(this._sk, recipientPubkeyHex, plaintext); + } +} + +export const timmyIdentityService = new TimmyIdentityService(); diff --git a/artifacts/api-server/src/lib/zap.ts b/artifacts/api-server/src/lib/zap.ts new file mode 100644 index 0000000..6cf3f4f --- /dev/null +++ b/artifacts/api-server/src/lib/zap.ts @@ -0,0 +1,149 @@ +/** + * zap.ts — NIP-57 zap-out capability for Timmy. + * + * ZapService constructs and signs a NIP-57 zap request event with Timmy's key, + * optionally pays a BOLT11 invoice via LNbits, and logs the outbound event to + * the `timmy_nostr_events` audit table. + * + * Configuration (env vars): + * ZAP_PCT_DEFAULT — percentage of work fee to zap back (default 0 = disabled) + * ZAP_MIN_SATS — minimum sats to trigger a zap (default 10) + * NOSTR_RELAY_URL — relay URL embedded in the zap request tags (optional) + */ + +import { randomUUID } from "crypto"; +import { db, timmyNostrEvents } from "@workspace/db"; +import { timmyIdentityService } from "./timmy-identity.js"; +import { lnbitsService } from "./lnbits.js"; +import { makeLogger } from "./logger.js"; + +const logger = makeLogger("zap"); + +function envInt(name: string, fallback: number): number { + const v = parseInt(process.env[name] ?? "", 10); + return Number.isFinite(v) && v >= 0 ? v : fallback; +} + +const ZAP_PCT_DEFAULT = envInt("ZAP_PCT_DEFAULT", 0); +const ZAP_MIN_SATS = envInt("ZAP_MIN_SATS", 10); +const RELAY_URL = process.env["NOSTR_RELAY_URL"] ?? ""; + +export interface ZapRequest { + recipientPubkey: string; + amountSats: number; + bolt11?: string; + message?: string; +} + +export interface ZapResult { + skipped: boolean; + reason?: string; + eventId?: string; + paymentHash?: string; +} + +class ZapService { + /** Calculate the zap amount for a given work fee, returns 0 if disabled. */ + zapAmountSats(workFeeSats: number, pct: number = ZAP_PCT_DEFAULT): number { + if (pct <= 0) return 0; + return Math.floor(workFeeSats * pct / 100); + } + + /** + * Send a zap from Timmy to a trusted partner. + * + * If bolt11 is provided, pays the invoice via LNbits. + * Event is always logged to timmy_nostr_events for auditing. + */ + async sendZap(req: ZapRequest): Promise { + const { recipientPubkey, amountSats, bolt11, message } = req; + + if (amountSats < ZAP_MIN_SATS) { + return { skipped: true, reason: `amount ${amountSats} < ZAP_MIN_SATS ${ZAP_MIN_SATS}` }; + } + + const amountMsats = amountSats * 1000; + + const tags: string[][] = [ + ["p", recipientPubkey], + ["amount", amountMsats.toString()], + ]; + + if (RELAY_URL) { + tags.push(["relays", RELAY_URL]); + } + + const zapRequestEvent = timmyIdentityService.sign({ + kind: 9734, + created_at: Math.floor(Date.now() / 1000), + tags, + content: message ?? "⚡ zap from Timmy", + }); + + let paymentHash: string | undefined; + + if (bolt11 && !lnbitsService.stubMode) { + try { + paymentHash = await lnbitsService.payInvoice(bolt11); + logger.info("zap payment sent", { amountSats, paymentHash, recipientPubkey: recipientPubkey.slice(0, 8) }); + } catch (err) { + logger.warn("zap payment failed — event logged anyway", { + error: err instanceof Error ? err.message : String(err), + }); + } + } else if (lnbitsService.stubMode) { + paymentHash = "stub_" + zapRequestEvent.id.slice(0, 16); + logger.info("zap stub payment simulated", { amountSats, paymentHash }); + } + + await db.insert(timmyNostrEvents).values({ + id: randomUUID(), + kind: 9734, + recipientPubkey, + eventJson: JSON.stringify(zapRequestEvent), + amountSats, + relayUrl: RELAY_URL || null, + }); + + logger.info("zap event logged", { + eventId: zapRequestEvent.id, + recipientPubkey: recipientPubkey.slice(0, 8), + amountSats, + }); + + return { skipped: false, eventId: zapRequestEvent.id, paymentHash }; + } + + /** + * Called from the job completion path. + * Only fires if trust tier warrants it and ZAP_PCT_DEFAULT > 0. + */ + async maybeZapOnJobComplete(opts: { + pubkey: string | null; + workFeeSats: number; + tier: string; + }): Promise { + const { pubkey, workFeeSats, tier } = opts; + if (!pubkey) return; + if (ZAP_PCT_DEFAULT <= 0) return; + if (tier !== "trusted" && tier !== "elite") return; + + const amount = this.zapAmountSats(workFeeSats); + if (amount < ZAP_MIN_SATS) return; + + const result = await this.sendZap({ + recipientPubkey: pubkey, + amountSats: amount, + message: "Thanks for being a trusted partner! ⚡", + }).catch(err => { + logger.warn("zapOnJobComplete failed silently", { error: err instanceof Error ? err.message : String(err) }); + return { skipped: true, reason: "error" } as ZapResult; + }); + + if (!result.skipped) { + logger.info("zap sent on job complete", { pubkey: pubkey.slice(0, 8), amount, tier }); + } + } +} + +export const zapService = new ZapService(); diff --git a/artifacts/api-server/src/routes/identity.ts b/artifacts/api-server/src/routes/identity.ts index 371361d..f5e0e63 100644 --- a/artifacts/api-server/src/routes/identity.ts +++ b/artifacts/api-server/src/routes/identity.ts @@ -1,7 +1,10 @@ import { Router, type Request, type Response } from "express"; -import { randomBytes } from "crypto"; +import { randomBytes, randomUUID } from "crypto"; import { verifyEvent, validateEvent } from "nostr-tools"; +import { db, nostrTrustVouches, nostrIdentities, timmyNostrEvents } from "@workspace/db"; +import { eq, count } from "drizzle-orm"; import { trustService } from "../lib/trust.js"; +import { timmyIdentityService } from "../lib/timmy-identity.js"; import { makeLogger } from "../lib/logger.js"; const logger = makeLogger("identity"); @@ -153,6 +156,145 @@ router.post("/identity/verify", async (req: Request, res: Response) => { } }); +// ── GET /identity/timmy ─────────────────────────────────────────────────────── +// Returns Timmy's own Nostr identity (npub) and outbound zap statistics. +// Public endpoint — no authentication required. + +router.get("/identity/timmy", async (_req: Request, res: Response) => { + try { + const zapRows = await db + .select({ count: count() }) + .from(timmyNostrEvents) + .where(eq(timmyNostrEvents.kind, 9734)); + + const zapCount = zapRows[0]?.count ?? 0; + + res.json({ + npub: timmyIdentityService.npub, + pubkeyHex: timmyIdentityService.pubkeyHex, + zapCount, + }); + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : "Failed to fetch Timmy identity" }); + } +}); + +// ── POST /identity/vouch ────────────────────────────────────────────────────── +// Allows an elite-tier identity to co-sign a new pubkey, granting it a trust +// boost. The voucher's Nostr event is recorded in nostr_trust_vouches. +// +// Requires: X-Nostr-Token with elite tier. +// Body: { voucheePubkey: string, event: NostrEvent (optional — voucher's signed event) } + +const VOUCH_TRUST_BOOST = 20; + +router.post("/identity/vouch", async (req: Request, res: Response) => { + const raw = req.headers["x-nostr-token"]; + const token = typeof raw === "string" ? raw.trim() : null; + + if (!token) { + res.status(401).json({ error: "X-Nostr-Token header required" }); + return; + } + + const parsed = trustService.verifyToken(token); + if (!parsed) { + res.status(401).json({ error: "Invalid or expired nostr_token" }); + return; + } + + const tier = await trustService.getTier(parsed.pubkey); + if (tier !== "elite") { + res.status(403).json({ error: "Vouching requires elite trust tier" }); + return; + } + + const { voucheePubkey, event } = req.body as { + voucheePubkey?: unknown; + event?: unknown; + }; + + if (typeof voucheePubkey !== "string" || !/^[0-9a-f]{64}$/.test(voucheePubkey)) { + res.status(400).json({ error: "voucheePubkey must be a 64-char hex pubkey" }); + return; + } + + if (voucheePubkey === parsed.pubkey) { + res.status(400).json({ error: "Cannot vouch for yourself" }); + return; + } + + // Verify the voucher event if provided + if (event !== undefined) { + if (!event || typeof event !== "object") { + res.status(400).json({ error: "event must be a Nostr signed event object" }); + return; + } + if (!validateEvent(event as Parameters[0])) { + res.status(400).json({ error: "Invalid Nostr event structure" }); + return; + } + let valid = false; + try { valid = verifyEvent(event as Parameters[0]); } catch { valid = false; } + if (!valid) { + res.status(401).json({ error: "Nostr signature verification failed" }); + return; + } + const ev = event as Record; + if (ev["pubkey"] !== parsed.pubkey) { + res.status(400).json({ error: "event.pubkey does not match X-Nostr-Token identity" }); + return; + } + } + + try { + // Upsert vouchee identity so it exists before applying boost + await trustService.getOrCreate(voucheePubkey); + + const vouchEventJson = event ? JSON.stringify(event) : JSON.stringify({ voucher: parsed.pubkey, vouchee: voucheePubkey, ts: Date.now() }); + + await db.insert(nostrTrustVouches).values({ + id: randomUUID(), + voucherPubkey: parsed.pubkey, + voucheePubkey, + vouchEventJson, + trustBoost: VOUCH_TRUST_BOOST, + }); + + // Apply trust boost: read current score then write boosted value + const currentRows = await db + .select({ score: nostrIdentities.trustScore }) + .from(nostrIdentities) + .where(eq(nostrIdentities.pubkey, voucheePubkey)); + const currentScore = currentRows[0]?.score ?? 0; + + await db + .update(nostrIdentities) + .set({ trustScore: currentScore + VOUCH_TRUST_BOOST, updatedAt: new Date() }) + .where(eq(nostrIdentities.pubkey, voucheePubkey)); + + logger.info("vouch recorded", { + voucher: parsed.pubkey.slice(0, 8), + vouchee: voucheePubkey.slice(0, 8), + boost: VOUCH_TRUST_BOOST, + }); + + const voucheeIdentity = await trustService.getIdentityWithDecay(voucheePubkey); + + res.json({ + ok: true, + voucheePubkey, + trustBoost: VOUCH_TRUST_BOOST, + newScore: voucheeIdentity?.trustScore, + newTier: voucheeIdentity?.tier, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to record vouch"; + logger.error("vouch failed", { error: message }); + res.status(500).json({ error: message }); + } +}); + // ── GET /identity/me ────────────────────────────────────────────────────────── // Look up the trust profile for a verified nostr_token. diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 9b81de3..76aaba4 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -13,6 +13,7 @@ import { makeLogger } from "../lib/logger.js"; import { latencyHistogram } from "../lib/histogram.js"; import { trustService } from "../lib/trust.js"; import { freeTierService } from "../lib/free-tier.js"; +import { zapService } from "../lib/zap.js"; const logger = makeLogger("jobs"); @@ -275,6 +276,9 @@ async function runWorkInBackground( const pubkeyForTrust = nostrPubkey ?? (await getJobById(jobId))?.nostrPubkey ?? null; if (pubkeyForTrust) { void trustService.recordSuccess(pubkeyForTrust, actualAmountSats); + // Zap back to trusted partners if configured (ZAP_PCT_DEFAULT > 0) + const tier = await trustService.getTier(pubkeyForTrust); + void zapService.maybeZapOnJobComplete({ pubkey: pubkeyForTrust, workFeeSats: actualAmountSats, tier }); } } catch (err) { const message = err instanceof Error ? err.message : "Execution error"; diff --git a/lib/db/src/schema/index.ts b/lib/db/src/schema/index.ts index e893452..144436d 100644 --- a/lib/db/src/schema/index.ts +++ b/lib/db/src/schema/index.ts @@ -8,3 +8,5 @@ export * from "./sessions"; export * from "./nostr-identities"; export * from "./timmy-config"; export * from "./free-tier-grants"; +export * from "./timmy-nostr-events"; +export * from "./nostr-trust-vouches"; diff --git a/lib/db/src/schema/nostr-trust-vouches.ts b/lib/db/src/schema/nostr-trust-vouches.ts new file mode 100644 index 0000000..47acd69 --- /dev/null +++ b/lib/db/src/schema/nostr-trust-vouches.ts @@ -0,0 +1,20 @@ +import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core"; +import { nostrIdentities } from "./nostr-identities"; + +export const nostrTrustVouches = pgTable("nostr_trust_vouches", { + id: text("id").primaryKey(), + + voucherPubkey: text("voucher_pubkey") + .notNull() + .references(() => nostrIdentities.pubkey), + + voucheePubkey: text("vouchee_pubkey").notNull(), + + vouchEventJson: text("vouch_event_json").notNull(), + + trustBoost: integer("trust_boost").notNull().default(20), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type NostrTrustVouch = typeof nostrTrustVouches.$inferSelect; diff --git a/lib/db/src/schema/timmy-nostr-events.ts b/lib/db/src/schema/timmy-nostr-events.ts new file mode 100644 index 0000000..59bd9d5 --- /dev/null +++ b/lib/db/src/schema/timmy-nostr-events.ts @@ -0,0 +1,19 @@ +import { pgTable, text, integer, timestamp } from "drizzle-orm/pg-core"; + +export const timmyNostrEvents = pgTable("timmy_nostr_events", { + id: text("id").primaryKey(), + + kind: integer("kind").notNull(), + + recipientPubkey: text("recipient_pubkey"), + + eventJson: text("event_json").notNull(), + + amountSats: integer("amount_sats"), + + relayUrl: text("relay_url"), + + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type TimmyNostrEvent = typeof timmyNostrEvents.$inferSelect; diff --git a/the-matrix/index.html b/the-matrix/index.html index cde1892..d670edb 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -383,6 +383,25 @@ 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } } + + /* ── Timmy identity card ──────────────────────────────────────────── */ + #timmy-id-card { + position: fixed; bottom: 80px; right: 16px; + font-size: 10px; color: #334466; + pointer-events: all; z-index: 10; + text-align: right; line-height: 1.8; + } + #timmy-id-card .id-label { + letter-spacing: 2px; color: #223355; + text-transform: uppercase; font-size: 9px; + } + #timmy-id-card .id-npub { + color: #4466aa; cursor: pointer; + text-decoration: underline dotted; + font-size: 10px; letter-spacing: 0.5px; + } + #timmy-id-card .id-npub:hover { color: #88aadd; } + #timmy-id-card .id-zaps { color: #556688; font-size: 9px; } @@ -399,6 +418,13 @@
OFFLINE
+ +
+
TIMMY IDENTITY
+
+
⚡ 0 zaps sent
+
+
diff --git a/the-matrix/js/main.js b/the-matrix/js/main.js index 9f6e25b..aa9d515 100644 --- a/the-matrix/js/main.js +++ b/the-matrix/js/main.js @@ -13,6 +13,7 @@ import { initSessionPanel } from './session.js'; import { initNostrIdentity } from './nostr-identity.js'; import { warmup as warmupEdgeWorker, onReady as onEdgeWorkerReady } from './edge-worker-client.js'; import { setEdgeWorkerReady } from './ui.js'; +import { initTimmyId } from './timmy-id.js'; let running = false; let canvas = null; @@ -39,6 +40,8 @@ function buildWorld(firstInit, stateSnapshot) { // Warm up edge-worker models in the background; show ready badge when done warmupEdgeWorker(); onEdgeWorkerReady(() => setEdgeWorkerReady()); + // Fetch Timmy's Nostr identity and populate identity card + void initTimmyId(); } const ac = new AbortController(); diff --git a/the-matrix/js/timmy-id.js b/the-matrix/js/timmy-id.js new file mode 100644 index 0000000..f313f71 --- /dev/null +++ b/the-matrix/js/timmy-id.js @@ -0,0 +1,62 @@ +/** + * timmy-id.js — Timmy's identity card. + * + * Fetches Timmy's Nostr npub from GET /api/identity/timmy and populates + * the #timmy-id-card widget. Npub is displayed shortened and is copyable. + * Zap count refreshes every 60 seconds. + */ + +const REFRESH_INTERVAL_MS = 60_000; + +function shortenNpub(npub) { + if (!npub || npub.length < 16) return npub; + return npub.slice(0, 10) + '…' + npub.slice(-6); +} + +async function fetchTimmyIdentity() { + try { + const res = await fetch('/api/identity/timmy'); + if (!res.ok) return; + return await res.json(); + } catch { + return null; + } +} + +function renderCard(data) { + if (!data) return; + + const $npub = document.getElementById('timmy-npub'); + const $zapCount = document.getElementById('timmy-zap-count'); + + if ($npub && data.npub) { + $npub.textContent = shortenNpub(data.npub); + $npub.title = data.npub + '\n(click to copy)'; + + if (!$npub._clickBound) { + $npub._clickBound = true; + $npub.addEventListener('click', () => { + navigator.clipboard.writeText(data.npub).then(() => { + const orig = $npub.textContent; + $npub.textContent = 'copied!'; + setTimeout(() => { $npub.textContent = orig; }, 1500); + }).catch(() => {}); + }); + } + } + + if ($zapCount) { + const n = typeof data.zapCount === 'number' ? data.zapCount : 0; + $zapCount.textContent = `⚡ ${n} zap${n === 1 ? '' : 's'} sent`; + } +} + +export async function initTimmyId() { + const data = await fetchTimmyIdentity(); + renderCard(data); + + setInterval(async () => { + const refreshed = await fetchTimmyIdentity(); + renderCard(refreshed); + }, REFRESH_INTERVAL_MS); +}