ci / lint-test-leaks (pull_request) Canceled after 0s
Problem: Cayce publishes straight to relays with no approval gate — cayce-initiative publish_note, core.mjs digest/opinion/reflection/events, mention auto-replies, social promo (Facebook/Instagram via sfeed), and mail — while the operator believed approvals existed (they never did; ADR-0005 Telegram queue was never implemented). Mechanism (outbox.mjs, deployed to all runtimes): - CAYCE_PUBLISH_MODE=draft (default): gateOutbound() stages EVERY outbound artifact into outbound_drafts (entity DB) with identity_path + identity_hash (sha256 of prompts/cayce-identity.md) before anything else happens. Fail-closed: if staging fails, the event is discarded, never published. - CAYCE_PUBLISH_MODE=live: original behavior, unchanged (reversible with one line per service .env). - Chokepoint: the nostr daemon /publish endpoint gates ALL callers (initiative publish_note, core.mjs ×4, banner kind 0); the daemon own mention-reply path gates too, storing original_event for context; startup profile publish gates as well. DM-to-supervisor (/dm) is internal and stays live. Identity (point 8b): - prompts/cayce-identity.md: the 5 hardcoded voice blocks extracted VERBATIM (verified byte-identical by runtime test) with block markers. - core.mjs / editorials.mjs now load their voice blocks from this file (loadIdentityBlock); identity hash recorded on every draft. - runtime/ holds the versioned copies of the previously-unversioned runtime scripts + deploy.sh installer. No timers enabled or disabled. No production data modified.
441 lines
22 KiB
JavaScript
441 lines
22 KiB
JavaScript
// 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}`)
|
|
})
|