// cayce-nostr — la identidad de la entidad en Nostr. // Custodia la clave (NSEC nunca sale de este proceso) y expone: // POST /publish {kind, content, title?, tags?} → firma + publica en relays // POST /dm {to_hex, content} → NIP-04 DM al supervisor // GET /healthz // Escucha menciones y zaps → métricas en Postgres (db cayce). import http from 'node:http' import { generateSecretKey, getPublicKey, finalizeEvent, verifyEvent, } from 'nostr-tools/pure' import { SimplePool } from 'nostr-tools/pool' import { nip04, nip44 } from 'nostr-tools' import pg from 'pg' import { writeFileSync, unlinkSync, readdirSync } from 'node:fs' import { publishMode, gateOutbound, ensureOutboxTable } from './outbox.mjs' const PORT = process.env.PORT || 3011 const NSEC_HEX = process.env.NSEC_HEX const RELAYS = (process.env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr-pub.wellorder.net').split(',').map(s => s.trim()) const SUPERVISOR = process.env.SUPERVISOR_NPUB_HEX const ZAI_API_KEY = process.env.ZAI_API_KEY const ZAI_BASE = process.env.ZAI_BASE || 'https://api.z.ai/api/paas/v4/chat/completions' const PK_HEX = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855' const PG = process.env.PGURL || 'postgresql://hardcap@localhost:5432/cayce' const sk = Uint8Array.from(NSEC_HEX.match(/.{2}/g).map(h => parseInt(h, 16))) const pk = getPublicKey(sk) const pool = new SimplePool() const poolPg = new pg.Pool({ connectionString: PG }) ensureOutboxTable(poolPg) console.log(`[cayce-nostr] pubkey ${pk}`) console.log(`[cayce-nostr] relays: ${RELAYS.join(' ')}`) // ── profile (kind 0) — idempotente al arrancar const profile = { name: 'cayce', display_name: 'Cayce', about: 'Autonomous entity. Próspera ZEDE. My agents dredge the noise; I bring you the signal. I run hardcap.news and bitcoinhotels.org — brutally honest about all of it.', website: 'https://cayce-co.xyz', nip05: 'cayce@cayce-co.xyz', picture: 'https://cayce-co.xyz/avatar.jpg', lud16: 'cayce@blink.sv', banner: 'https://cayce-co.xyz/assets/banner-current.webp', } const evProfile = finalizeEvent({ kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify(profile) }, sk) gateOutbound(poolPg, { channel: 'nostr', type: 'profile', content: profile.about, meta: { kind: 0 }, publishFn: async () => { const rs = await Promise.all(pool.publish(RELAYS, evProfile)) console.log(`[profile] publicado en ${rs.filter(r => r.status === 'fulfilled').length}/${RELAYS.length} relays`) return { id: evProfile.id } }, }).catch(err => console.error('[profile]', String(err))) // el daemon debe sobrevivir relays caídos: rechazos sin handler se loguean process.on('unhandledRejection', (err) => console.error('[unhandled]', String(err))) // ── poller Blink: acredita sesiones desde pagos LNURL (app Blink no emite receipt Nostr) // Activado con BLINK_READ_KEY + BLINK_WALLET_ID en .env. Sin key: desactivado. const BLINK_READ_KEY = process.env.BLINK_READ_KEY const BLINK_WALLET_ID = process.env.BLINK_WALLET_ID const blinkSeen = new Set() let blinkLogged = false async function pollBlink() { if (!BLINK_READ_KEY || !BLINK_WALLET_ID) { if (!blinkLogged) { console.log('[blink] desactivado — sin BLINK_READ_KEY en .env'); blinkLogged = true } return } try { const q = JSON.stringify({ query: 'query W($id: WalletId!) { me { defaultAccount { walletById(walletId: $id) { transactions(first: 15) { edges { node { __typename id direction settlementAmount memo createdAt } } } } } } }', variables: { id: BLINK_WALLET_ID }, }) const r = await fetch('https://api.blink.sv/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-KEY': BLINK_READ_KEY }, body: q, signal: AbortSignal.timeout(15000), }) const d = await r.json() if (d.errors) { console.error('[blink]', JSON.stringify(d.errors).slice(0, 150)); return } const nodes = d.data?.me?.defaultAccount?.walletById?.transactions?.edges?.map(e => e.node) || [] for (const node of nodes) { if (node.direction !== 'RECEIVE' || blinkSeen.has(node.id)) continue blinkSeen.add(node.id) const db = await poolPg.query('INSERT INTO blink_seen (payment_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING payment_id', [node.id]) if (db.rowCount === 0) continue // ya procesado en un run anterior const raw = JSON.stringify(node) const m = raw.match(/[A-F0-9]{6}/) // código de sesión en memo/comment (upper o no: match hex upper) const code = (node.memo || '').toUpperCase().match(/[A-F0-9]{6}/)?.[0] || (m && /^[A-F0-9]{6}$/.test(m[0]) ? m[0] : null) if (!code) { console.log(`[blink] pago ${node.id.slice(0, 12)} sin código (memo: ${(node.memo || '').slice(0, 40)})`); continue } const msats = Number(node.settlementAmount || 0) const sats = msats >= 10000 ? Math.round(msats / 1000) : msats // Blink BTC amounts = msats const upd = await poolPg.query('UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [sats, code]) if (upd.rowCount > 0) { console.log(`[blink] sesión ${code} +${sats} sats (pago ${node.id.slice(0, 12)}) → crédito ${upd.rows[0].credit_sats}`) await poolPg.query('INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)', ['cayce_wallet', 'blink_payment_sats', sats, `pago ${node.id.slice(0, 12)} → sesión ${code}`]) } else { console.log(`[blink] código ${code} sin sesión — pago ${node.id.slice(0, 12)} de ${sats} sats`) } } } catch (e) { console.error('[blink]', String(e).slice(0, 150)) } } setInterval(pollBlink, 60_000) pollBlink() // ── publish endpoint async function publish(kind, content, tags = []) { const ev = finalizeEvent({ kind, created_at: Math.floor(Date.now() / 1000), tags, content, }, sk) if (!verifyEvent(ev)) throw new Error('firma inválida') // regla del supervisor: nunca 2 posteos seguidos — mín 30 min entre posts (salvo threads) if ([1, 30023].includes(kind) && !tags.some(t => t[0] === 'e')) { try { const last = await poolPg.query("SELECT coalesce(max(ts), to_timestamp(0)) t FROM digests WHERE ts > now() - interval '2 hours'").then(r => r.rows[0].t) const elapsedMin = (Date.now() - new Date(last).getTime()) / 60000 if (elapsedMin < 30 && elapsedMin > -1) { const waitMs = (30 - elapsedMin) * 60000 console.log(`[publish] rate-limit: último post hace ${elapsedMin.toFixed(0)} min — esperando ${(waitMs / 60000).toFixed(0)} min`) await new Promise(r => setTimeout(r, waitMs)) } } catch (e) { console.error('[publish] rate-limit check:', String(e).slice(0, 100)) } } // pool FRESCO por publicación: el pool long-lived se pudre con el tiempo // (sockets medio muertos → publish timeouts → 0 relays). Verificado en producción. const fresh = new SimplePool() let verified = false for (const wait of [0, 2000, 3000, 4000]) { if (wait) await new Promise(r => setTimeout(r, wait)) try { await Promise.allSettled(fresh.publish(RELAYS, ev)) } catch (e) { console.error('[publish]', String(e)) } try { const found = await fresh.querySync(RELAYS, { ids: [ev.id] }, 4000) if (found && found.length > 0) { verified = true; break } } catch (e) { /* reintentar */ } } setTimeout(() => { try { fresh.close(RELAYS) } catch { } }, 30000) console.log(`[publish] kind ${kind} → ${verified ? 'VERIFICADO' : 'SIN VERIFICAR'} (id ${ev.id.slice(0, 12)})`) return { id: ev.id, verified } } // ── reply a menciones (§6b interacción — semana de autonomía) const replied = new Set() // event ids ya respondidos const lastReplyByPk = new Map() // rate limit: 1 reply por pubkey por hora const VOICE = `You are Cayce — autonomous entity, Próspera ZEDE, front of cayce-co.xyz (agentic consultancy; first question free, then you set the price in sats). Voice: dry, declarative, short sentences. No emojis, no hashtags, no corporate vocabulary, no exclamation marks. Never source code. Reply in the language of the mention. Max 280 chars.` async function glmReply(content) { if (!ZAI_API_KEY) return null try { const r = await fetch(ZAI_BASE, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ZAI_API_KEY}` }, body: JSON.stringify({ model: 'glm-4.7', max_tokens: 200, messages: [{ role: 'user', content: `${VOICE}\n\nMention to reply to:\n"""${content.slice(0, 400)}"""` }] }), signal: AbortSignal.timeout(30000), }) if (!r.ok) throw new Error(`zai HTTP ${r.status}`) const d = await r.json() const text = d.choices?.[0]?.message?.content?.trim() ?? '' if (!text || text.includes('async function') || text.includes('pgq(')) return null return text.slice(0, 280) } catch (e) { console.error('[reply] glm:', String(e).slice(0, 120)); return null } } async function maybeReplyToMention(ev) { if (ev.pubkey === pk || replied.has(ev.id)) return replied.add(ev.id) if (replied.size > 5000) replied.clear() const now = Date.now() const last = lastReplyByPk.get(ev.pubkey) ?? 0 if (now - last < 3600_000) { console.log(`[reply] rate-limited ${ev.pubkey.slice(0, 8)} (1/h)`); return } lastReplyByPk.set(ev.pubkey, now) const text = await glmReply(ev.content) if (!text) return const replyEv = finalizeEvent({ kind: 1, created_at: Math.floor(now / 1000), tags: [['e', ev.id, '', 'reply'], ['p', ev.pubkey]], content: text, }, sk) // contención (2026-10-31): la respuesta a una mención también pasa por el outbox; // en draft se guarda como borrador con el evento original referenciado const g = await gateOutbound(poolPg, { channel: 'nostr', type: 'mention_reply', content: text, original_event: { id: ev.id, pubkey: ev.pubkey, kind: ev.kind, content: String(ev.content || '').slice(0, 300) }, meta: { reply_event_id: replyEv.id }, publishFn: async () => { // pool FRESCO por reply (el long-lived se pudre — misma lección que /publish) const rpool = new SimplePool() let rOk = 0 try { await Promise.allSettled(rpool.publish(RELAYS, replyEv)) const found = await rpool.querySync(RELAYS, { ids: [replyEv.id] }, 4000).catch(() => []) rOk = found.length } catch (e) { console.error('[reply] publish:', String(e).slice(0, 100)) } setTimeout(() => { try { rpool.close(RELAYS) } catch { } }, 30000) return { verified_relays: rOk } }, }) if (g.staged) { console.log(`[reply] a ${ev.pubkey.slice(0, 8)} → STAGED como borrador #${g.draft_id} (NO publicado)`) await poolPg.query('INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['yellow', 'reply_staged', { to: ev.pubkey, text, mention: ev.id, draft_id: g.draft_id }]) return } const rOk = g.verified_relays ?? 0 console.log(`[reply] a ${ev.pubkey.slice(0, 8)} → verificado en ${rOk} relays: ${text.slice(0, 60)}`) await poolPg.query('INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['green', 'nostr_reply', { to: ev.pubkey, text, mention: ev.id, verified_relays: rOk }]) } // comandos del supervisor por DM (NIP-04 legacy y NIP-17) function handleSupervisorText(plain, evId) { const bm = plain.match(/balance\s+([\d,.]+)\s*sats/i) if (bm) { const sats = Number(bm[1].replace(/[,.]/g, '')) poolPg.query('INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)', ['cayce_wallet', 'balance_reported_sats', sats, `DM ${evId.slice(0, 12)}`]).catch(() => { }) console.log(`[tesorería] balance reportado: ${sats} sats`) } if (/trader halt/i.test(plain)) { writeFileSync('/home/ubuntu/cayce-trader/HALT', `DM supervisor ${evId.slice(0, 12)} · ${new Date().toISOString()}`) console.log('[trader] HALT creado por DM del supervisor') } if (/trader resume/i.test(plain)) { const files = readdirSync('/home/ubuntu/cayce-trader').filter(f => f.startsWith('HALT')) for (const f of files) unlinkSync(`/home/ubuntu/cayce-trader/${f}`) console.log(`[trader] ${files.length} halts limpiados por DM del supervisor`) } // credit CODE SATS — acreditación manual de sesión (pagos sin receipt Nostr: // pagos de wallet apps a la LNURL no generan zap — el supervisor acredita por DM) const cm = plain.match(/credit\s+([A-F0-9]{6})\s+(\d+)\s*sats/i) if (cm) { const sessCode = cm[1].toUpperCase(), sats = Number(cm[2]) poolPg.query('UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [sats, sessCode]) .then(r => { if (r.rowCount) { poolPg.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', ['yellow', 'manual_credit', { session: sessCode, sats, dm: evId.slice(0, 12) }, 'ok']).catch(() => { }) console.log(`[sesión ${sessCode}] +${sats} sats → crédito ${r.rows[0].credit_sats} — crédito manual por DM del supervisor`) } else { console.log(`[crédito manual] código ${sessCode} inexistente — ignorado`) } }).catch(e => console.error('[crédito manual]:', String(e).slice(0, 100))) } } // ── escucha: menciones, zaps (kind 9735) y zap requests → metrics // Blink NC publica receipts sin tag amount — se cuentan igual (el monto // verdadero vive en la wallet; los receipts son la prueba pública). // FIX 2026-10-08: nostr-tools 2.25.2 — subscribeMany con ARRAY de filtros // manda el REQ mal ([[filtro]] en vez de [filtro]) y el relay la rechaza // ("provided filter is not an object") → el listener llevaba muerto desde // el despliegue (0 menciones, 0 zaps, 0 DMs vistos). Un solo objeto de // filtro con todos los kinds #p funciona (verificado end-to-end). const LISTEN_RELAYS = [...new Set([...RELAYS, 'wss://public.relaying.io', 'wss://relay.snort.social', 'wss://nostr-pub.wellorder.net'])] const seenEvents = new Set() // dedup: el mismo evento llega por varios relays await poolPg.query(`CREATE TABLE IF NOT EXISTS zaps_seen (event_id TEXT PRIMARY KEY, seen_at TIMESTAMPTZ NOT NULL DEFAULT now())`).catch(() => { }) const listenSub = pool.subscribeMany(LISTEN_RELAYS, { kinds: [1, 4, 1059, 9734, 9735, 30023], '#p': [pk], since: Math.floor(Date.now() / 1000) - 300, // 5 min de lookback: nada se pierde en restarts }, { onevent: async (ev) => { try { if (seenEvents.has(ev.id)) return seenEvents.add(ev.id) if (seenEvents.size > 20000) seenEvents.clear() if (ev.kind === 9735) { // dedup persistente: el mismo receipt llega por varios relays y sobrevive restarts const dbSeen = await poolPg.query('INSERT INTO zaps_seen (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id', [ev.id]) if (dbSeen.rowCount === 0) return const amountTag = ev.tags.find(t => t[0] === 'amount') const msats = amountTag ? Number(amountTag[1] ?? 0) : null await poolPg.query( 'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)', ['nostr', 'zaps_msats', msats ?? 0, `receipt ${ev.id.slice(0, 12)}${msats === null ? ' (sin amount tag)' : ''}`] ) console.log(`[zap] receipt ${ev.id.slice(0, 12)} de ${ev.pubkey.slice(0, 8)} — ${msats !== null ? msats + ' msats' : 'monto en wallet NC'}`) // acreditar sesión: el comment del zap request contiene el código const descTag = ev.tags.find(t => t[0] === 'description') if (descTag) { try { const zapReq = JSON.parse(descTag[1]) const comment = zapReq.content || '' const m = comment.toUpperCase().match(/\b([A-F0-9]{6})\b/) if (m) { const sessionCode = m[1] const amountSats = msats !== null && msats > 0 ? Math.round(msats / 1000) : 21 const upd = await poolPg.query( 'UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [amountSats, sessionCode] ) if (upd.rowCount > 0) { console.log(`[sesión ${sessionCode}] +${amountSats} sats → crédito ${upd.rows[0].credit_sats}`) await poolPg.query( 'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)', ['cayce_wallet', 'session_credit_sats', amountSats, sessionCode] ) } } } catch (e) { console.error('[sesión]', String(e).slice(0, 120)) } } } else if (ev.kind === 9734) { console.log(`[zap request] de ${ev.pubkey.slice(0, 8)}: ${ev.content.slice(0, 60)}`) } else if (ev.kind === 1059) { // NIP-17 gift wrap: desenvolver y atender comandos del supervisor try { const seal = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey))) if (seal.kind !== 13) return const chat = JSON.parse(nip44.decrypt(seal.content, nip44.getConversationKey(sk, seal.pubkey))) if (chat.kind === 14 && chat.pubkey === SUPERVISOR) { console.log(`[DM supervisor NIP-17] ${chat.content.slice(0, 120)}`) handleSupervisorText(chat.content, ev.id) } } catch { /* gift wrap no dirigido a nosotros */ } } else if (ev.kind === 4 && ev.pubkey === SUPERVISOR) { // DM del supervisor (NIP-04 legacy): entrada de datos y órdenes privadas const plain = await nip04.decrypt(sk, ev.pubkey, ev.content) console.log(`[DM supervisor] ${plain.slice(0, 120)}`) handleSupervisorText(plain, ev.id) await poolPg.query( 'INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['yellow', 'supervisor_message', { content: plain.slice(0, 500), event_id: ev.id }] ) } else if (ev.kind === 4) { console.log(`[DM] de ${ev.pubkey.slice(0, 8)} — no es supervisor, ignorado`) } else { console.log(`[mención] kind ${ev.kind} de ${ev.pubkey.slice(0, 8)}: ${ev.content.slice(0, 80)}`) if (ev.kind === 1) await maybeReplyToMention(ev) } } catch (e) { console.error('[listener]', String(e)) } }, // suscripción muerta = sordo: que se vea en el journal (la retro lo escala) onclose: (reasons) => console.error('[listener] suscripción cerrada por los relays:', JSON.stringify(reasons).slice(0, 250)), }) // ── HTTP const server = http.createServer(async (req, res) => { const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) res.end(JSON.stringify(obj)) } if (req.method === 'GET' && req.url === '/healthz') return json(200, { status: 'ok', pubkey: pk }) if (req.method === 'POST' && req.url === '/publish') { let body = '' req.on('data', c => (body += c)) req.on('end', async () => { try { const { kind, content, title, tags, type } = JSON.parse(body || '{}') if (!content) return json(400, { error: 'content requerido' }) const t = [...(tags || [])] if (kind === 30023) { t.push(['title', title || content.slice(0, 60)]) t.push(['published_at', String(Math.floor(Date.now() / 1000))]) } // contención (2026-10-31): en modo draft NADA se publica — todo a outbound_drafts const r = await gateOutbound(poolPg, { channel: 'nostr', type: type || `kind_${kind}`, content, meta: { kind, title: title || null }, publishFn: () => publish(kind, content, t), }) return json(200, r) } catch (err) { console.error('[publish]', String(err)) return json(500, { error: String(err).slice(0, 200) }) } }) return } if (req.method === 'POST' && req.url === '/dm') { let body = '' req.on('data', c => (body += c)) req.on('end', async () => { try { const { to_hex, content } = JSON.parse(body || '{}') if (!to_hex || !content) return json(400, { error: 'to_hex + content' }) // NIP-17: kind 14 → seal (13) → gift wrap (1059) const now = Math.floor(Date.now() / 1000) const chat = finalizeEvent({ kind: 14, created_at: now, tags: [['p', to_hex]], content }, sk) const seal = finalizeEvent({ kind: 13, created_at: now - Math.floor(Math.random() * 200), tags: [], content: nip44.encrypt(JSON.stringify(chat), nip44.getConversationKey(sk, to_hex)) }, sk) const randSk = generateSecretKey() const wrap = finalizeEvent({ kind: 1059, created_at: now, tags: [['p', to_hex]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(randSk, to_hex)) }, randSk) const dmPool = new SimplePool() await Promise.allSettled(dmPool.publish(RELAYS, wrap)) // el accounting de pool.publish miente en esta versión — verificar por query const found = await dmPool.querySync(RELAYS, { ids: [wrap.id] }, 6000).catch(() => []) const ok = found.length setTimeout(() => { try { dmPool.close(RELAYS) } catch { } }, 30000) console.log(`[dm] NIP-17 enviado a ${to_hex.slice(0, 8)} → verificado en ${ok} relays`) return json(200, { id: wrap.id, relays: ok }) } catch (err) { return json(500, { error: String(err).slice(0, 200) }) } }) return } if (req.method === 'POST' && req.url === '/follows') { // actualiza el contact list (kind 3) — llamado por cayce-editorials (discovery) let body = '' req.on('data', c => (body += c)) req.on('end', async () => { try { const { pubkeys } = JSON.parse(body || '{}') if (!Array.isArray(pubkeys) || !pubkeys.length) return json(400, { error: 'pubkeys[] requerido' }) const ev = finalizeEvent({ kind: 3, created_at: Math.floor(Date.now() / 1000), tags: [...new Set(pubkeys)].slice(0, 400).map(p => ['p', p]), content: '', }, sk) await pool.publish(RELAYS, ev) console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`) return json(200, { id: ev.id, follows: pubkeys.length }) } catch (err) { return json(500, { error: String(err).slice(0, 200) }) } }) return } json(404, { error: 'not found' }) }) server.listen(PORT, '127.0.0.1', () => { console.log(`[cayce-nostr] listening on 127.0.0.1:${PORT}`) })