Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef733e9ecc | ||
|
|
179ecf47ab | ||
|
|
9b44863e00 | ||
|
|
25f6efb1c1 | ||
|
|
93fa1a07e7 | ||
|
|
d30fdc7fd4 | ||
|
|
6a16c64f45 | ||
|
|
6dec3192bd |
@@ -9,6 +9,7 @@ jobs:
|
||||
lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: set -o pipefail # r19: sin esto, | tail se come los exit codes y un test roto da verde
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Ronda 16 — Verificación (OpenCode/GLM en m1, operador patternn)
|
||||
|
||||
## Hallazgos
|
||||
- EXPOSICION previa: secretos en juego (nombres, nunca valores): core .env → ZAI_API_KEY, PGURL; mail .env → MAIL_PASS, GITEA_TOKEN. Rotación PENDIENTE (decisión del operador). Token de Gitea también visible en `git remote get-url origin` de cayce-ops (remote con credencial embebida).
|
||||
- VOZ: main y despliegue = ff2cd822 (v2 vieja). PR #23 (voz v3) OPEN, sin merge. identity_path real verificado en outbound_drafts.
|
||||
- PUBLICADO 48h: 4 piezas nostr con event id (2 verificables en relays: 99cc75f4, 449a39bf; 8a81c688 y 09eb4e9a NO están en relays configurados) + hardcap article_note/backlog. La opinion 04:01 (BTCPay) tenia fuente de WPR (planta de gas Wisconsin): mala atribución real.
|
||||
- actions_log de core.mjs --posts registra "ok" aunque el guard rechace (falso ok). Bug abierto.
|
||||
|
||||
## Cambios (desplegados y con tests 27/27)
|
||||
- llm.mjs (core + copia cayce-nostr): SOLO Gemini, fuera zai/glm-4.6/4.7. La copia del daemon apuntaba a /srv/hardcap-backend/.env inexistente (sin fallback). Daemon cayce-nostr reiniciado (guard/llm nuevos activos).
|
||||
- guard.mjs R9 reescrito: terminos ESPECIFICOS de la fuente (slug del titular + sourceTitle: nombres propios, cifras ≥3 dígitos, keywords ≥5 chars con stopwords), ids opacos excluidos. Fuera vocabulario DOMAIN genérico. guardCheck acepta sourceTitle; outbox lo pasa desde meta.
|
||||
- Tests: R16a (caso real BTCPay/Wisconsin → rechazado), R16b (id opaco no bloquea), R16c (sourceTitle verifica). 27/27 verde.
|
||||
- Prueba en vivo: pieza generada (BTCPay) con fuente theblock.co/etf-binance → RECHAZADA por R9 en tiempo real.
|
||||
|
||||
## Timing ciclo posts (Gemini-only)
|
||||
- selectSource 61ms · selectBatch 2ms · fetch artículos 333ms · gemini 10.0s · guard 4ms = 10.4s/post (instrumentado).
|
||||
- Run systemd real: opinion 50.3s (start 04:08:27.257 → spacing 04:09:17.528), rechazado por tope 3/3 + aforismo.
|
||||
- Ciclo --posts completo = 3 posts + 2 esperas de 45 MIN por diseño (regla supervisor) ≈ 92 min. El objetivo <5 min aplica al procesamiento (✓ 10-50s/post), no al ciclo con espaciado.
|
||||
|
||||
## Pendientes
|
||||
- Rotar ZAI_API_KEY, PGURL, MAIL_PASS, GITEA_TOKEN (y token embebido en remote de cayce-ops).
|
||||
- Merges Gitea: #23 (voz v3), #25.
|
||||
- Decidir qué hacer con /srv/cayce-entity (copia muerta: sin servicio, guard sin R9, mtime 21:08 Nov1).
|
||||
- Bug falso-ok en core.mjs --posts (no chequea resultado del gate).
|
||||
- cayce-promo y cayce-cycle-monitor en failed.
|
||||
|
||||
## Incidente kill switch (resuelto en la sesión)
|
||||
- Cada restart del daemon publica kind 0; si el publish agota timeout tras aceptación parcial en relays, no se registraba aprobación → watcher disparaba PUBLISH_OFF (ocurrió 00:39 y 04:50 Nov-2).
|
||||
- Fix desplegado en /srv/cayce-core/outbox.mjs: en live, recordPublish también en el catch del publishFn (meta.publish_error). Validado: restart 04:53:54 → fila profile registrada (draft #183).
|
||||
- Backfill de las 2 filas kind 0 de los restarts (drafts #181/#182), PUBLISH_OFF eliminado, watcher manual en verde, publishMode efectivo = live.
|
||||
@@ -0,0 +1,46 @@
|
||||
# Ronda 17 — Rotaciones, borrado, fixes y fuente única (OpenCode/GLM, operador patternn)
|
||||
|
||||
## 1. Rotaciones
|
||||
- PG rol hardcap (cayce DB): ALTER ROLE + 4 .env actualizados (core, nostr, front, hardcap-news CAYCE_PGURL). Verificado psql + restart servicios. Valor jamás impreso.
|
||||
- GITEA_TOKEN: nuevo por CLI (r17-rotation-1102, scopes repo+user) en mail y social; VIEJO REVOCADO (automation3, id 3 — era el del remote). Quedan: auto-merge (id 10, decisión del operador) y el nuevo.
|
||||
- Remote de cayce-ops sin token embebido; credential helper store con fichero 600 (~/.git-credentials-cayce). Fetch verificado.
|
||||
- ZAI_API_KEY eliminada de core/nostr/front/entity .env. Servicios reiniciados. (cayce-front proxy Z.AI queda sin upstream: sin saldo desde oct-9, sin impacto práctico.)
|
||||
- MAIL_PASS: lo cambia el operador → /home/ubuntu/cayce-mail/.env, var MAIL_PASS, luego `sudo systemctl restart cayce-mail`.
|
||||
|
||||
## 2. Borrado
|
||||
- kind 5 para 99cc75f4 (opinión BTCPay/fuente Wisconsin): publicado vía daemon gateado, event 404b476c…, aprobación registrada (draft #186, kind5_id). Visible 1/5 relays al instante.
|
||||
- Recheck +1h programado (systemd-run r17-recheck-99cc, 06:32 UTC) → /var/log/r17-recheck-99cc.log.
|
||||
|
||||
## 3. Monitor y promo
|
||||
- Monitor: fallaba por (a) alerta por exit 15 (SIGTERM de parada externa), (b) exit 1 al alertar, (c) journalctl --utc inexistente, (d) date mal citado. Corregido; heartbeat /srv/cayce-core/.monitor-heartbeat.
|
||||
- WATCHER vigila al monitor: heartbeat >45 min → DM + guard_rejections(monitor_stalled). Test negativo real pasado (alerta + fila).
|
||||
- Promo: faltaba pg (instalado), convert duplicado y comillas literales en color (fix), sub no-array (fix), doble publicación en live (fix), y PUBLICABA pese a rechazo del guard (fix crítico). GRANT posts_log a hardcap. Resultado: 12/12 de campaña procesadas, TODAS rechazadas por el guard (hashtag/regla R2 en captions IG/FB) — nada publicado a terceros; decisión de contenido pendiente del operador.
|
||||
|
||||
## 4. Falsos ok
|
||||
- core.mjs: assertPublished() en los 4 helpers; REJECTED → actions_log result=rejected con reasons (coincide con guard_rejections). En PR #2.
|
||||
|
||||
## 5. Fuente única
|
||||
- Backups forensics: r17-single-source-20261102-0540.tar.gz + r17-cayce-ops-runtime-… + manifest sha256.
|
||||
- /srv/cayce-core AHORA es checkout de cayce/entity (rama r16-r9-gemini, commit ddb2d2e). Tests 27/27 en el checkout. Tree limpio.
|
||||
- Eliminados: /srv/cayce-entity (copia muerta) y runtime/ de cayce-ops. check-clean-trees cubre /srv/cayce-core ✓.
|
||||
- Artefactos runtime ignorados en .gitignore del repo entity.
|
||||
|
||||
## 6. PR
|
||||
- entity PR #2 (r16-r9-gemini → main): R9, Gemini-only, fixes r16+r17.
|
||||
- Regla de urgencia r16: NO se cumplió (cambio directo sin PUBLISH_OFF; rama a cayce-ops, no entity). En r17 SÍ: PUBLISH_OFF activo durante el swap y PR #2 en la misma ventana.
|
||||
|
||||
## Otros
|
||||
- cayce-front: crash latente al reiniciar (leía /srv/hardcap-backend/.env muerto) — corregido (fallback resiliente). Claves Blink ya no están en el backend nuevo: paywall de invoices sin key hasta que el operador las cargue.
|
||||
- cayce-scout: crash-loop desde la contención (buscaba cayce-trader/scout.mjs, código en cuarentena). Deshabilitado — decisión del operador si se recupera del archivo forense. No estaba en la lista del monitor.
|
||||
- PR #23 (voz v3) sigue OPEN: al mergearse, en cayce-ops `git checkout main && git pull` actualiza el despliegue (identity ff2cd822 → v3).
|
||||
|
||||
## Pendientes operador
|
||||
- MAIL_PASS en proveedor + .env + restart.
|
||||
- Merge entity PR #2 y cayce-ops PRs (#23, r16-r9-gemini de cayce-ops si procede); luego /srv/cayce-core → main.
|
||||
- Decidir token auto-merge (id 10) y destino de scout.
|
||||
- Contenido de campaña promo (hashtags prohibidos por R2) o excepción de política.
|
||||
|
||||
## Incidencias de la propia ronda (resueltas)
|
||||
- La rotacion del rol hardcap rompio DATABASE_URL del backend hardcap (mismo rol, variable con otro nombre): fix + sweep de TODOS los .env con URL postgres (todos conectan; tambien se actualizo el snapshot pre-git-20261101 por coherencia). hardcap-backend restart OK.
|
||||
- logPost clasificaba "Error: REJECTED..." como error (prefijo de Node): fix includes() → result=rejected. Validado sinteticamente; la opinion 05:48 quedo error=REJECTED (pre-fix) y la 05:50 publico OK (event f9f976eb…) desde el checkout nuevo.
|
||||
- cayce-scout deshabilitado (crash-loop: buscaba cayce-trader/scout.mjs, codigo en cuarentena).
|
||||
@@ -1,45 +0,0 @@
|
||||
// check-relay-silence — verifica que Cayce NO publique a relays desde <T>.
|
||||
// Uso: node check-relay-silence.mjs <epoch-segundos-desde> [horas-ventana]
|
||||
// Eventos permitidos: kind 10050 (inbox relays, autorizado), kind 4/1059
|
||||
// (DMs AL SUPERVISOR — canal interno autorizado). Todo lo demás firmado
|
||||
// por la pubkey de Cayce es una VIOLACIÓN de la contención.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { SimplePool, getPublicKey } from 'nostr-tools'
|
||||
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/cayce-nostr/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
// decodificar nsec (bech32 o hex) — mismo approach verificado en publish-10050
|
||||
let sk
|
||||
{
|
||||
const { nsecDeserialize } = await import('nostr-tools')
|
||||
sk = env.NSEC_HEX.startsWith('nsec1') ? nsecDeserialize(env.NSEC_HEX) : Buffer.from(env.NSEC_HEX, 'hex')
|
||||
}
|
||||
const RELAYS = (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 pk = getPublicKey(sk)
|
||||
|
||||
const sinceArg = Number(process.argv[2])
|
||||
const hours = Number(process.argv[3] || 24)
|
||||
if (!sinceArg) { console.error('uso: node check-relay-silence.mjs <epoch_s> [horas]'); process.exit(2) }
|
||||
const T = sinceArg
|
||||
console.log(`[silence] ventanas: eventos de Cayce desde ${new Date(T * 1000).toISOString()} (últimas ${hours}h)`)
|
||||
|
||||
console.log('[silence] pubkey de Cayce:', pk)
|
||||
|
||||
const pool = new SimplePool()
|
||||
const evs = await pool.querySync(RELAYS, { authors: [pk], since: T }, 8000).catch(() => [])
|
||||
pool.close(RELAYS)
|
||||
|
||||
const supervisor = env.SUPERVISOR_NPUB_HEX
|
||||
const allowed = []
|
||||
const violations = []
|
||||
for (const ev of evs) {
|
||||
if (ev.kind === 10050) { allowed.push(ev); continue }
|
||||
if ((ev.kind === 4 || ev.kind === 1059) && supervisor && ev.tags.some(t => t[0] === 'p' && t[1] === supervisor)) { allowed.push(ev); continue }
|
||||
violations.push(ev)
|
||||
}
|
||||
console.log(`[silence] eventos vistos: ${evs.length} · permitidos: ${allowed.length} (10050/DM-supervisor) · VIOLACIONES: ${violations.length}`)
|
||||
for (const ev of violations.slice(0, 10)) {
|
||||
console.log(` ✗ kind ${ev.kind} ${new Date(ev.created_at * 1000).toISOString()} ${ev.id.slice(0, 12)} — ${ev.content.slice(0, 60)}`)
|
||||
}
|
||||
process.exit(violations.length ? 1 : 0)
|
||||
@@ -1,240 +0,0 @@
|
||||
// cayce-core — el ciclo de la entidad.
|
||||
// 1. events watch — BTC Map v4 → tabla events (🟢)
|
||||
// 2. curación — estado del stream de hardcap (🟢)
|
||||
// 3. digest — GLM arma la promo diaria → publica vía cayce-nostr (🟢)
|
||||
// 4. métricas + audit log
|
||||
// Todo lo que hace queda en actions_log. "Lo no auditado no existió."
|
||||
|
||||
import pg from 'pg'
|
||||
import { loadIdentityBlock } from './outbox.mjs'
|
||||
import { chat } from './llm.mjs'
|
||||
import { selectSource, selectBatch, attribute } from './sources.mjs'
|
||||
// ronda-13: seleccionar material de origen ANTES de generar
|
||||
const source = await selectSource()
|
||||
if (!source) {
|
||||
console.log('[posts] sin señal: no hay material ≤72h con score ≥6')
|
||||
await pgc.end()
|
||||
process.exit(0)
|
||||
}
|
||||
console.log('[posts] fuente seleccionada:', source.title.slice(0, 50))
|
||||
const batch = await selectBatch(5)
|
||||
|
||||
|
||||
const {
|
||||
ZAI_API_KEY,
|
||||
ZAI_BASE = 'https://api.z.ai/api/paas/v4/chat/completions',
|
||||
CAYCE_MODEL = 'glm-4.7',
|
||||
PGURL = 'postgresql://hardcap@localhost:5432/cayce',
|
||||
HARD_CAP_API = 'https://api.hardcap.news',
|
||||
NOSTR_INTERNAL = 'http://127.0.0.1:3011',
|
||||
SUPERVISOR_NPUB_HEX,
|
||||
} = process.env
|
||||
|
||||
const pgc = new pg.Pool({ connectionString: PGURL })
|
||||
const log = (zone, action, detail = {}, result = 'ok') =>
|
||||
pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result])
|
||||
|
||||
async function glm(prompt) {
|
||||
// cadena de fallback en llm.mjs (zai → gemini); lanza si todo falla
|
||||
const t = await chat([{ role: 'user', content: prompt }], 3000)
|
||||
// una nota vacía en Nostr es peor que ningún post — fallar explícito
|
||||
if (!t) throw new Error('glm devolvió contenido vacío')
|
||||
return t
|
||||
}
|
||||
|
||||
// ── 1. events watch: BTC Map v4 → upsert (🟢)
|
||||
async function eventsWatch() {
|
||||
const r = await fetch('https://api.btcmap.org/v4/events', { signal: AbortSignal.timeout(20000) })
|
||||
if (!r.ok) throw new Error(`btcmap HTTP ${r.status}`)
|
||||
const evs = await r.json()
|
||||
const now = new Date()
|
||||
const upcoming = evs.filter(e => e.starts_at && new Date(e.ends_at || e.starts_at) >= now)
|
||||
let upserted = 0
|
||||
for (const e of upcoming.slice(0, 200)) {
|
||||
const id = 'btcmap-' + e.id
|
||||
await pgc.query(
|
||||
`INSERT INTO events (id, name, type, city, country, starts_on, ends_on, url, source, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'btcmap',now())
|
||||
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, starts_on=EXCLUDED.starts_on,
|
||||
ends_on=EXCLUDED.ends_on, url=EXCLUDED.url, updated_at=now()`,
|
||||
[id, e.name || 'Bitcoin event', 'meetup', e.city || null, e.country || null,
|
||||
(e.starts_at || '').slice(0, 10) || null, (e.ends_at || '').slice(0, 10) || null, e.website || null]
|
||||
)
|
||||
upserted++
|
||||
}
|
||||
await log('green', 'events_watch', { upcoming: upcoming.length, upserted })
|
||||
return upserted
|
||||
}
|
||||
|
||||
// ── 2. curación: estado del stream de hardcap (🟢, reporte)
|
||||
async function curate() {
|
||||
const r = await fetch(`${HARD_CAP_API}/api/articles?limit=1`, { signal: AbortSignal.timeout(15000) })
|
||||
const d = await r.json()
|
||||
await log('green', 'curation_check', { total_published: d.total })
|
||||
return d.total
|
||||
}
|
||||
|
||||
// ── 3. digest promo: GLM arma el texto → publica (🟢)
|
||||
async function digest(totalPublished) {
|
||||
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=8`, { signal: AbortSignal.timeout(15000) })).json()).articles
|
||||
const evs = (await pgc.query(
|
||||
`SELECT name, city, country, starts_on, url FROM events
|
||||
WHERE active AND starts_on >= CURRENT_DATE ORDER BY starts_on ASC LIMIT 6`
|
||||
)).rows
|
||||
|
||||
const stories = arts.map(a =>
|
||||
`- [${a.title}] — score ${a.relevanceScore ?? '?'} — https://hardcap.news/news/${a.id}/summary`
|
||||
).join('\n')
|
||||
const eventLines = evs.map(e =>
|
||||
`- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city})` : ''}${e.url ? ` — ${e.url}` : ''}`
|
||||
).join('\n')
|
||||
|
||||
const prompt = loadIdentityBlock('digest')
|
||||
|
||||
const input = `STORIES (from hardcap.news, published):\n${stories}\n\nUPCOMING EVENTS:\n${eventLines || '(none)'}\n\nTotal published articles today: ${totalPublished}`
|
||||
|
||||
const digSource = arts && arts[0] ? { originalUrl: arts[0].originalUrl || `https://hardcap.news/news/${arts[0].id}/summary`, publishedAt: arts[0].publishedAt || new Date().toISOString() } : await selectSource()
|
||||
const text = await glm(prompt + '\n\n' + input) + attribute(digSource)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content: text, type: 'digest' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await pub.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_note', text, pr.id])
|
||||
await log('green', 'digest_published', { nostr_event_id: pr.id, relays: pr.relays, length: text.length })
|
||||
return { text, nostr: pr }
|
||||
}
|
||||
|
||||
// ── 3b. posteos de opinión y events-post (modo --posts)
|
||||
async function opinionPost() {
|
||||
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=10`, { signal: AbortSignal.timeout(15000) })).json()).articles
|
||||
const stories = arts.map(a =>
|
||||
`- [${a.title}] — topic ${(a.topics || []).join('/')} — score ${a.relevanceScore ?? '?'} — resumen: ${(a.summary || '(sin resumen)').slice(0, 200)} — url: https://hardcap.news/news/${a.id}/summary`
|
||||
).join('\n')
|
||||
|
||||
const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories
|
||||
|
||||
const opSource = arts && arts[0] ? { originalUrl: arts[0].originalUrl || `https://hardcap.news/news/${arts[0].id}/summary`, publishedAt: arts[0].publishedAt || new Date().toISOString() } : await selectSource()
|
||||
const text = (await glm(prompt)).trim() + attribute(opSource)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content: text, type: 'opinion' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await pub.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_opinion', text, pr.id])
|
||||
await log('green', 'opinion_post', { nostr_event_id: pr.id, length: text.length })
|
||||
return text
|
||||
}
|
||||
|
||||
// ── 3c. reflexión diaria: la voz pura de Cayce, cero autopromoción (modo --posts)
|
||||
async function reflectionPost() {
|
||||
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=8`, { signal: AbortSignal.timeout(15000) })).json().catch(() => ({}))).articles || []
|
||||
const seed = arts.length
|
||||
? arts.map(a => `- ${a.title}`).join('\n')
|
||||
: '(sin titulares hoy — reflexioná desde lo que ya sabés)'
|
||||
|
||||
const prompt = loadIdentityBlock('reflection') + '\n' + seed
|
||||
|
||||
const refSource = await selectSource()
|
||||
const text = (await glm(prompt)).trim() + attribute(refSource)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content: text, type: 'reflection' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await pub.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_reflection', text, pr.id])
|
||||
await log('green', 'reflection_post', { nostr_event_id: pr.id, length: text.length })
|
||||
return text
|
||||
}
|
||||
|
||||
async function eventsPost() {
|
||||
const evs = (await pgc.query(
|
||||
`SELECT name, city, country, starts_on, url FROM events
|
||||
WHERE active AND starts_on >= CURRENT_DATE ORDER BY starts_on ASC LIMIT 4`
|
||||
)).rows
|
||||
|
||||
const prompt = loadIdentityBlock('events') + '\n\nEVENTS:\n' + evs.map(e => `- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city}, ${e.country || ''})` : ''}${e.url ? ` — ${e.url}` : ''}`).join('\n') || '(no upcoming events)'
|
||||
|
||||
const evSource = evs && evs[0] && evs[0].url ? { originalUrl: evs[0].url, publishedAt: new Date().toISOString() } : await selectSource()
|
||||
const text = (await glm(prompt)).trim() + attribute(evSource)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content: text, type: 'events_post' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await pub.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_events', text, pr.id])
|
||||
await log('green', 'events_post', { nostr_event_id: pr.id, length: text.length })
|
||||
return text
|
||||
}
|
||||
|
||||
// ── 4. tesorería: snapshot del balance Blink (🟢, solo lectura)
|
||||
async function treasury() {
|
||||
if (!process.env.BLINK_API_KEY) return 'sin BLINK_API_KEY aún'
|
||||
const r = await fetch('https://api.blink.sv/graphql', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-KEY': process.env.BLINK_API_KEY },
|
||||
body: JSON.stringify({ query: 'query me { me { defaultAccount { username wallets { id walletCurrency balance } } } }' }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
const d = await r.json()
|
||||
const acct = d?.data?.me?.defaultAccount
|
||||
if (!acct) return 'blink sin respuesta válida'
|
||||
for (const w of acct.wallets || []) {
|
||||
await pgc.query(
|
||||
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
|
||||
['cayce_wallet', `balance_${w.walletCurrency.toLowerCase()}`, w.balance, w.id]
|
||||
)
|
||||
}
|
||||
const ln = acct.username ? `${acct.username}@blink.sv` : null
|
||||
await log('green', 'treasury_snapshot', { wallets: (acct.wallets || []).length, lightning_address: ln })
|
||||
return `balance registrado${ln ? ' — ' + ln : ''}`
|
||||
}
|
||||
|
||||
// ── main
|
||||
const started = Date.now()
|
||||
const results = {}
|
||||
|
||||
if (process.argv.includes('--posts')) {
|
||||
// modo ligero: solo voz pública (opinión + reflection + events post)
|
||||
// espaciado: nunca 2 posteos seguidos — 45 min entre ellos (regla del supervisor)
|
||||
try { results.opinion = await opinionPost() } catch (e) { results.opinion = 'ERROR: ' + String(e); await log('red', 'opinion_post', { error: String(e).slice(0, 200) }, 'error') }
|
||||
await log('green', 'posts_spacing', {}, 'esperando 45 min antes del reflection post')
|
||||
await new Promise(r => setTimeout(r, 45 * 60 * 1000))
|
||||
try { results.reflection = await reflectionPost() } catch (e) { results.reflection = 'ERROR: ' + String(e); await log('red', 'reflection_post', { error: String(e).slice(0, 200) }, 'error') }
|
||||
await log('green', 'posts_spacing', {}, 'esperando 45 min antes del events post')
|
||||
await new Promise(r => setTimeout(r, 45 * 60 * 1000))
|
||||
try { results.events_post = await eventsPost() } catch (e) { results.events_post = 'ERROR: ' + String(e); await log('red', 'events_post', { error: String(e).slice(0, 200) }, 'error') }
|
||||
const hadError = Object.values(results).some(v => String(v).startsWith('ERROR'))
|
||||
await log('green', 'posts_cycle', results, hadError ? 'error' : `done in ${Date.now() - started}ms`)
|
||||
console.log(JSON.stringify(results, null, 2).slice(0, 1600))
|
||||
await pgc.end()
|
||||
process.exit(hadError ? 1 : 0)
|
||||
}
|
||||
|
||||
try {
|
||||
results.events = await eventsWatch()
|
||||
} catch (e) { results.events = 'ERROR: ' + String(e); await log('red', 'events_watch', { error: String(e).slice(0, 200) }, 'error') }
|
||||
try {
|
||||
results.published_total = await curate()
|
||||
} catch (e) { results.published_total = 'ERROR: ' + String(e); await log('red', 'curation_check', { error: String(e).slice(0, 200) }, 'error') }
|
||||
try {
|
||||
results.treasury = await treasury()
|
||||
} catch (e) { results.treasury = 'ERROR: ' + String(e); await log('red', 'treasury', { error: String(e).slice(0, 200) }, 'error') }
|
||||
try {
|
||||
const d = await digest(results.published_total ?? 0)
|
||||
results.digest = { nostr: d.nostr, preview: d.text.slice(0, 120) }
|
||||
} catch (e) { results.digest = 'ERROR: ' + String(e); await log('red', 'digest', { error: String(e).slice(0, 200) }, 'error') }
|
||||
|
||||
const hadError = Object.values(results).some(v => String(v).startsWith('ERROR'))
|
||||
await log('green', 'cycle_complete', results, hadError ? 'error' : `done in ${Date.now() - started}ms`)
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
await pgc.end()
|
||||
process.exit(hadError ? 1 : 0)
|
||||
@@ -1,448 +0,0 @@
|
||||
// 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 '/srv/cayce-core/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)
|
||||
// fix fuga (2026-11-01): el kind 3 publicaba directo sin pasar por el
|
||||
// gate — fue la VIOLACION kind 3 del 2026-11-01T00:00:04Z vista por [silence]
|
||||
const g = await gateOutbound(poolPg, {
|
||||
channel: 'nostr', type: 'contact_list', content: '', meta: { kind: 3, follows: pubkeys.length },
|
||||
publishFn: async () => {
|
||||
await pool.publish(RELAYS, ev)
|
||||
console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`)
|
||||
return { id: ev.id }
|
||||
},
|
||||
})
|
||||
return json(200, { id: ev.id, follows: pubkeys.length, guarded: true, ...g })
|
||||
} 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}`)
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — instala el runtime contenido en sus rutas live y reinicia el daemon.
|
||||
# Fuente versionada: este directorio (cayce-ops/runtime). Reversible con
|
||||
# CAYCE_PUBLISH_MODE=live en el .env de cada servicio.
|
||||
set -euo pipefail
|
||||
SRC="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
install -m 644 "$SRC/outbox.mjs" /srv/cayce-core/outbox.mjs
|
||||
install -m 644 "$SRC/core.mjs" /srv/cayce-core/core.mjs
|
||||
install -m 644 "$SRC/editorials.mjs" /srv/cayce-core/editorials.mjs
|
||||
install -m 644 "$SRC/initiative.mjs" /srv/cayce-core/initiative.mjs
|
||||
# ronda-7: outbox UNICO en /srv/cayce-core, import absolutos
|
||||
install -m 644 "$SRC/daemon.mjs" /srv/cayce-nostr/daemon.mjs
|
||||
# opcionales (llegan con el PR de verificación/10050)
|
||||
install -m 755 "$SRC/publish-10050.mjs" /srv/cayce-nostr/publish-10050.mjs 2>/dev/null || true
|
||||
install -m 644 "$SRC/check-relay-silence.mjs" /srv/cayce-nostr/check-relay-silence.mjs 2>/dev/null || true
|
||||
install -m 644 "$SRC/promo.mjs" /home/ubuntu/cayce-social/promo.mjs
|
||||
install -m 644 "$SRC/mail.mjs" /home/ubuntu/cayce-mail/mail.mjs
|
||||
|
||||
# config de contención (idempotente): draft por defecto + ruta de identidad
|
||||
for ENVF in /srv/cayce-core/.env /srv/cayce-nostr/.env /home/ubuntu/cayce-social/.env /home/ubuntu/cayce-mail/.env; do
|
||||
grep -q "^CAYCE_PUBLISH_MODE=" "$ENVF" 2>/dev/null || echo "CAYCE_PUBLISH_MODE=draft" >> "$ENVF"
|
||||
grep -q "^IDENTITY_PATH=" "$ENVF" 2>/dev/null || echo "IDENTITY_PATH=/home/ubuntu/cayce-ops/prompts/cayce-identity.md" >> "$ENVF"
|
||||
done
|
||||
|
||||
sudo install -m 644 '$SRC/hardcap/admin.ts' /srv/hardcap-backend/src/routes/admin.ts 2>/dev/null || true
|
||||
install -m 755 '$SRC/hardcap/test-admin-auth.mjs' /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs 2>/dev/null || true
|
||||
sudo systemctl restart hardcap-backend
|
||||
sudo systemctl restart cayce-nostr
|
||||
echo "deploy OK — mode=$(grep -h "^CAYCE_PUBLISH_MODE=" /srv/cayce-core/.env | head -1)"
|
||||
@@ -1,193 +0,0 @@
|
||||
// cayce-editorials — editoriales por burbuja (topic) + ingesta Nostr
|
||||
// 1. ingesta — kind 1 de relays por hashtag + cuentas seguidas → nostr_notes (🟢)
|
||||
// 2. discovery — pubkeys recurrentes → nostr_follows + kind 3 vía daemon (🟢 §6b)
|
||||
// 3. editoriales — por topic activo: artículos 48h + notas Nostr → GLM → topic_editorials (🟢)
|
||||
// "Lo no auditado no existió."
|
||||
|
||||
import pg from 'pg'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
import { chat } from './llm.mjs'
|
||||
|
||||
const {
|
||||
ZAI_API_KEY,
|
||||
ZAI_BASE = 'https://api.z.ai/api/paas/v4/chat/completions',
|
||||
HARD_BACKEND_ENV = '/srv/hardcap-backend/.env',
|
||||
DAEMON = 'http://localhost:3011',
|
||||
PGURL,
|
||||
} = process.env
|
||||
|
||||
if (!ZAI_API_KEY) { console.error('sin ZAI_API_KEY — falta EnvironmentFile en la unit'); process.exit(1) }
|
||||
|
||||
// DATABASE_URL del backend de hardcap (dueño de su DB)
|
||||
const hardEnv = readFileSync(HARD_BACKEND_ENV, 'utf8')
|
||||
const HC_URL = hardEnv.match(/^DATABASE_URL=(.+)$/m)?.[1]
|
||||
if (!HC_URL) { console.error('sin DATABASE_URL en hardcap backend env'); process.exit(1) }
|
||||
const hc = new pg.Pool({ connectionString: HC_URL })
|
||||
const pgc = PGURL ? new pg.Pool({ connectionString: PGURL, max: 2 }) : null
|
||||
const log = (zone, action, detail = {}, result = 'ok') =>
|
||||
pgc ? pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result]) : Promise.resolve()
|
||||
const pool = new SimplePool()
|
||||
|
||||
const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net']
|
||||
|
||||
// hashtags por categoría — fuente Nostr para cada burbuja
|
||||
const HASHTAGS = {
|
||||
macro: ['bitcoin', 'grownostr', 'economy'],
|
||||
sovereignty: ['sovereignty', 'selfcustody', 'bitcoin'],
|
||||
institutional: ['bitcoin', 'etf', 'institutional'],
|
||||
protocol: ['bitcoin', 'lightning', 'nostr'],
|
||||
lightning: ['lightning', 'ln'],
|
||||
security: ['bitcoinsecurity', 'security', '5dollarwrench'],
|
||||
custody: ['selfcustody', 'multisig'],
|
||||
privacy: ['privacy', 'kychygiene'],
|
||||
mining: ['bitcoinmining', 'mining'],
|
||||
regulation: ['regulation', 'law'],
|
||||
latam: ['bitcoinlatam', 'argentina'],
|
||||
other: ['bitcoin'],
|
||||
}
|
||||
const VOICE = loadIdentityBlock('editorial')
|
||||
|
||||
async function glm(messages, maxTokens = 2000) {
|
||||
// cadena de fallback en llm.mjs; acá solo el pulido editorial + guards
|
||||
const raw = await chat(messages, maxTokens, { thinking: false })
|
||||
// reasoning filtrado: tomar el último párrafo no vacío y limpiar markdown de análisis
|
||||
const paras = raw.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
let c = paras[paras.length - 1] ?? ''
|
||||
if (/^(\d+\.|[-*]|\*\*)/.test(c) && paras.length > 1) c = paras.filter(p => !/^(\d+\.|[-*]|\*\*Analyz)/.test(p)).join(' ')
|
||||
c = c.replace(/\*\*/g, '').trim()
|
||||
if (!c) throw new Error('glm devolvió contenido vacío')
|
||||
if (c.includes('async function') || c.includes('pgq(')) throw new Error('model returned code')
|
||||
return c
|
||||
}
|
||||
|
||||
// ── 1. ingesta nostr: hashtags + seguidos, últimas 24h
|
||||
async function ingestNostr() {
|
||||
const follows = (await hc.query('SELECT pubkey FROM nostr_follows WHERE active')).rows.map(r => r.pubkey)
|
||||
const since = Math.floor(Date.now() / 1000) - 86400
|
||||
// querySync de una query por vez: algunos relays rechazan arrays de filtros
|
||||
const queries = [{ kinds: [1], '#t': [...new Set(Object.values(HASHTAGS).flat())], since, limit: 200 }]
|
||||
if (follows.length) queries.push({ kinds: [1], authors: follows, since, limit: 200 })
|
||||
const evs = []
|
||||
for (const f of queries) {
|
||||
try { evs.push(...await pool.querySync(RELAYS, f, 12000)) } catch (e) { console.error('[ingesta] relay:', String(e).slice(0, 100)) }
|
||||
}
|
||||
let n = 0
|
||||
for (const ev of evs) {
|
||||
if (!ev.content || ev.content.length < 30) continue
|
||||
const tags = ev.tags.filter(t => t[0] === 't').map(t => t[1].toLowerCase())
|
||||
const hashtag = tags.find(t => Object.values(HASHTAGS).flat().includes(t)) || null
|
||||
try {
|
||||
const r = await hc.query(
|
||||
`INSERT INTO nostr_notes (event_id, pubkey, content, hashtag, published_at)
|
||||
VALUES ($1,$2,$3,$4,to_timestamp($5)) ON CONFLICT (event_id) DO NOTHING`,
|
||||
[ev.id, ev.pubkey, ev.content.slice(0, 2000), hashtag, ev.created_at])
|
||||
n += r.rowCount
|
||||
} catch { /* dup */ }
|
||||
}
|
||||
console.log(`[ingesta] ${n} notas nuevas (${evs.length} vistas, ${follows.length} seguidos)`)
|
||||
return n
|
||||
}
|
||||
|
||||
// ── 2. discovery: pubkeys recurrentes se siguen (§6b, zona verde)
|
||||
async function discoverFollows() {
|
||||
const { rows } = await hc.query(`
|
||||
SELECT pubkey, count(*) c FROM nostr_notes
|
||||
WHERE ingested_at > now() - interval '7 days'
|
||||
GROUP BY pubkey HAVING count(*) >= 3
|
||||
AND pubkey NOT IN (SELECT pubkey FROM nostr_follows)
|
||||
ORDER BY c DESC LIMIT 5`)
|
||||
const pk = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
|
||||
for (const r of rows) {
|
||||
if (r.pubkey === pk) continue
|
||||
await hc.query(`INSERT INTO nostr_follows (pubkey, category) VALUES ($1,'discovered') ON CONFLICT DO NOTHING`, [r.pubkey])
|
||||
console.log(`[discovery] siguiendo ${r.pubkey.slice(0, 8)} (${r.c} notas)`)
|
||||
}
|
||||
// kind 3 actualizado vía daemon
|
||||
const all = (await hc.query('SELECT pubkey FROM nostr_follows WHERE active')).rows.map(r => r.pubkey)
|
||||
if (all.length) {
|
||||
try {
|
||||
await fetch(`${DAEMON}/follows`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pubkeys: all }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
} catch (e) { console.error('[follows]', String(e).slice(0, 120)) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. editoriales por burbuja
|
||||
async function generateEditorials() {
|
||||
// ventana 7d y umbral >=1: TODAS las burbujas con material tienen editorial (decisión supervisor)
|
||||
const { rows: bubbles } = await hc.query(`
|
||||
SELECT unnest(topics) AS topic, count(*) AS c,
|
||||
array_agg(title || ' — ' || coalesce(left(summary, 200), '')) AS items
|
||||
FROM articles
|
||||
WHERE status = 'PUBLISHED' AND "publishedAt" > now() - interval '7 days'
|
||||
GROUP BY 1 HAVING count(*) >= 1 ORDER BY c DESC`)
|
||||
let done = 0
|
||||
const failed = []
|
||||
for (const b of bubbles) {
|
||||
const tag = (HASHTAGS[b.topic] || ['bitcoin'])[0]
|
||||
const { rows: notes } = await hc.query(`
|
||||
SELECT left(content, 200) c FROM nostr_notes
|
||||
WHERE hashtag = $1 AND published_at > now() - interval '7 days'
|
||||
ORDER BY published_at DESC LIMIT 8`, [tag])
|
||||
const material = b.items.slice(0, 8).map(t => `- ${t}`).join('\n')
|
||||
const nostrPart = notes.length ? `\n\nNostr chatter (followed accounts + hashtags):\n${notes.map(n => `- ${n.c}`).join('\n')}` : ''
|
||||
try {
|
||||
const editorial = await glm([
|
||||
{ role: 'user', content: `${VOICE}\n\nCategory: ${b.topic} (${b.c} articles in 48h)\n\nArticles:\n${material}${nostrPart}` },
|
||||
])
|
||||
await hc.query(`
|
||||
INSERT INTO topic_editorials (topic, editorial, article_count, nostr_notes, generated_at)
|
||||
VALUES ($1,$2,$3,$4,now())
|
||||
ON CONFLICT (topic) DO UPDATE SET editorial=$2, article_count=$3, nostr_notes=$4, generated_at=now()`,
|
||||
[b.topic, editorial, b.c, notes.length])
|
||||
console.log(`[editorial] ${b.topic} (${b.c} art, ${notes.length} nostr) → ${editorial.slice(0, 60)}...`)
|
||||
done++
|
||||
} catch (e) {
|
||||
failed.push(b.topic)
|
||||
console.error(`[editorial] ${b.topic}:`, String(e).slice(0, 120))
|
||||
}
|
||||
}
|
||||
console.log(`[editoriales] ${done}/${bubbles.length} generadas`)
|
||||
await log('green', 'editorials_cycle', { done, total: bubbles.length, failed, error: failed.length ? 'Error: ' + (failed.length === bubbles.length ? 'todas las burbujas fallaron' : `${failed.length}/${bubbles.length} burbujas fallaron`) : undefined }, failed.length ? 'error' : 'ok')
|
||||
return { done, failed }
|
||||
}
|
||||
|
||||
// ── health check de las webs (revisión periódica del agente — decisión supervisor 10-04)
|
||||
async function healthCheck() {
|
||||
const sites = ['https://cayce-co.xyz', 'https://hardcap.news', 'https://bitcoinhotels.org'] // status queda fuera: basicauth 401 esperado
|
||||
for (const site of sites) {
|
||||
try {
|
||||
const r = await fetch(site, { signal: AbortSignal.timeout(15000) })
|
||||
if (r.status !== 200) {
|
||||
await log('red', 'site_health', { site, status: r.status, error: `site ${new URL(site).hostname} → HTTP ${r.status}`, at: new Date().toISOString() }, 'error')
|
||||
console.log(`[health] ${site} → ${r.status} 🔴`)
|
||||
} else {
|
||||
console.log(`[health] ${site} → 200 🟢`)
|
||||
}
|
||||
} catch (e) {
|
||||
await log('red', 'site_health', { site, error: `site ${new URL(site).hostname} → ${String(e).slice(0, 80)}`, at: new Date().toISOString() }, 'error')
|
||||
console.log(`[health] ${site} → ERROR 🔴`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cmd = process.argv[2] || 'all'
|
||||
let outcome = { done: 0, failed: [] }
|
||||
if (cmd === 'all') {
|
||||
await healthCheck()
|
||||
await ingestNostr()
|
||||
await discoverFollows()
|
||||
outcome = await generateEditorials()
|
||||
} else if (cmd === 'editorials') outcome = await generateEditorials()
|
||||
else if (cmd === 'ingest') { await ingestNostr(); await discoverFollows() }
|
||||
|
||||
// lo no auditado no existió — y lo que falla no sale en verde:
|
||||
// total-failure = exit 1 (systemd en rojo, la retro lo ve)
|
||||
if (pgc) await pgc.end()
|
||||
await hc.end()
|
||||
process.exit(outcome.failed.length === outcome.done + outcome.failed.length && outcome.failed.length > 0 ? 1 : 0)
|
||||
@@ -1,23 +0,0 @@
|
||||
# publish-guard: marcas bloqueadas (hardware wallets / cold storage) — directiva 2026-11-01
|
||||
# una marca por linea, matching por palabra completa, case-insensitive. Ampliable.
|
||||
ledger
|
||||
ledger nano
|
||||
trezor
|
||||
coldcard
|
||||
coinkite
|
||||
bitbox
|
||||
bitbox02
|
||||
keystone wallet
|
||||
blockstream jade
|
||||
passport wallet
|
||||
foundation passport
|
||||
safepal
|
||||
ngrave
|
||||
seedsigner
|
||||
cypherock
|
||||
tangem
|
||||
onekey
|
||||
d'cent
|
||||
ellipal
|
||||
opendime
|
||||
seedplate
|
||||
@@ -1,194 +0,0 @@
|
||||
// guard.mjs — PUBLISH-GUARD v1 (directiva del propietario 2026-11-01)
|
||||
// Un solo punto de paso: lo llama gateOutbound() en cayce-core y cayce-social,
|
||||
// y el gate de publicación de hardcap. Rechaza, registra el motivo, nunca publica.
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BLOCKLIST_PATH = process.env.GUARD_BLOCKLIST_PATH || '/srv/cayce-core/guard-blocklist.txt'
|
||||
export const FORCE_DRAFT_CHANNELS = ['mail', 'proposal', 'dm'] // política 2026-11-01: siempre borrador
|
||||
const METADATA_TYPES = ['profile', 'contact_list', 'inbox_relays', 'deletion'] // no caps, no formato texto
|
||||
const DEFAULT_CAPS = { nostr: 3, hardcap: 4, bitcoinhotels: 1, facebook: 1, instagram: 1 }
|
||||
|
||||
function caps() {
|
||||
try { return { ...DEFAULT_CAPS, ...JSON.parse(process.env.GUARD_CAPS_JSON || '{}') } } catch { return DEFAULT_CAPS }
|
||||
}
|
||||
function blocklist() {
|
||||
try {
|
||||
return readFileSync(BLOCKLIST_PATH, 'utf8').split('\n')
|
||||
.map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => l.toLowerCase())
|
||||
} catch { return [] }
|
||||
}
|
||||
const URL_RE = /https?:\/\/[^\s)>"]+/gi
|
||||
const stripUrls = s => String(s).replace(URL_RE, ' ')
|
||||
|
||||
// ── reglas ───────────────────────────────────────────────────────────────────
|
||||
// politica ronda-7 punto 4.6: R1 aplica SOLO a la voz propia de Cayce.
|
||||
// Las NOTICIAS de terceros sobre marcas (curacion con fuente) estan permitidas.
|
||||
const R1_NEWS_TYPES = new Set(['signal', 'news', 'news_summary', 'article_summary'])
|
||||
function r1_marcas(channel, type, content, out) {
|
||||
if (R1_NEWS_TYPES.has(type)) return
|
||||
const c = stripUrls(content).toLowerCase()
|
||||
for (const b of blocklist()) {
|
||||
const re = new RegExp(`(^|[^a-z0-9])${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i')
|
||||
if (re.test(c)) { out.push(`marca-bloqueada:${b}`); return }
|
||||
}
|
||||
}
|
||||
function r2_formato(content, out) {
|
||||
const c = stripUrls(content)
|
||||
if (/[?&](aff|ref|refid|refer)=[a-z0-9_-]+|\/ref\/|\/r\/[a-z0-9_-]{2,}|affiliate|referral|amzn\.to|[?&]tag=/i.test(String(content)))
|
||||
out.push('enlace-afiliado-o-referido')
|
||||
if (/(^|\s)#[a-z0-9_]+/i.test(c)) out.push('hashtag')
|
||||
if (/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}]/u.test(c)) out.push('emoji')
|
||||
if (/!/.test(c)) out.push('signo-de-exclamacion')
|
||||
}
|
||||
const DATE_RE = /(\b(19|20)\d{2}[-/.](0?[1-9]|1[0-2])[-/.](0?[1-9]|[12]\d|3[01])\b|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+(19|20)\d{2}\b|\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(19|20)\d{2}\b|\b(19|20)\d{2}\b)/i
|
||||
const FACT_RE = /(\d+(\.\d+)?\s*(%|usdt|usd|btc|sats?|vb|million|billion|bill|mill)|\b(19|20)\d{2}\b|\b(first|largest|biggest|most|record|all-time|only|highest|lowest|never|first ever)\b)/i
|
||||
function r3_hechos(content, out) {
|
||||
const s = String(content)
|
||||
const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0
|
||||
if (FACT_RE.test(s) && (!hasUrl || !DATE_RE.test(s))) out.push('afirmacion-factica-sin-fuente-y-fecha')
|
||||
}
|
||||
function r4_duplicado() { /* se resuelve con pool en guardCheck (async) */ }
|
||||
function shingles(s) {
|
||||
const w = stripUrls(s).toLowerCase().replace(/[^a-z0-9áéíóúñ\s]/g, ' ').split(/\s+/).filter(x => x.length > 2)
|
||||
const sh = new Set()
|
||||
for (let i = 0; i + 2 < w.length; i++) sh.add(w.slice(i, i + 3).join(' '))
|
||||
if (!sh.size && w.length) sh.add(w.join(' '))
|
||||
return sh
|
||||
}
|
||||
function jaccard(a, b) { let i = 0; for (const x of a) if (b.has(x)) i++; return i / (a.size + b.size - i || 1) }
|
||||
const DUP_THRESHOLD = 0.55
|
||||
|
||||
const NEWS_CHANNELS = new Set(['nostr', 'hardcap'])
|
||||
const FRESH_WINDOW_MS = 72 * 3600e3
|
||||
function parseDates(s) {
|
||||
const ds = []
|
||||
const iso = /\b(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})\b/g
|
||||
for (const m of s.matchAll(iso)) ds.push(new Date(+m[1], +m[2] - 1, +m[3]))
|
||||
const mon = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(20\d{2})\b/gi
|
||||
const MO = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }
|
||||
for (const m of s.matchAll(mon)) ds.push(new Date(+m[3], MO[m[1].slice(0, 3).toLowerCase()], +m[2]))
|
||||
return ds
|
||||
}
|
||||
function r7_frescura(channel, content, out) {
|
||||
if (!NEWS_CHANNELS.has(channel)) return
|
||||
const s = String(content)
|
||||
const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0
|
||||
if (!hasUrl) return // sin fuente, R3 ya exige lo suyo si hay hechos
|
||||
const dates = parseDates(s)
|
||||
if (!dates.length) return // sin fecha: R3 se encarga
|
||||
const newest = new Date(Math.max(...dates.map(d => d.getTime())))
|
||||
if (Date.now() - newest.getTime() > FRESH_WINDOW_MS) out.push(`fuente-mayor-72h (${newest.toISOString().slice(0, 10)})`)
|
||||
}
|
||||
function r8_cierre(content, out) {
|
||||
const s = String(content).trim()
|
||||
if (!s) return
|
||||
const sentences = s.split(/(?<=[.?])\s+/).filter(Boolean)
|
||||
let last = sentences[sentences.length - 1] || ''
|
||||
// la atribución de fuente final no cuenta como "cierre"
|
||||
last = last.replace(/(source|fuente)\s*:.*/i, '').replace(/https?:\/\/[^\s)>"]+/g, '').trim().replace(/[.?\s]+$/, '')
|
||||
if (!/[a-z0-9]/i.test(last)) last = (sentences[sentences.length - 2] || '').replace(/(source|fuente)\s*:.*/i, '').trim()
|
||||
if (!last) return
|
||||
const words = last.split(/\s+/).filter(Boolean)
|
||||
if (!words.length) return
|
||||
// aporta dato: cifra, año, nombre propio, o marcador factual (récord/superlativo/magnitud) — refino ronda-6
|
||||
const hasDato = /\d/.test(last) || /\b(19|20)\d{2}\b/.test(last) || /\b[A-Z][a-z]{2,}\b/.test(words.slice(1).join(' ')) || /\b(first|largest|biggest|most|record|all-time|highest|lowest)\b/i.test(last)
|
||||
if (!hasDato && words.length < 12) out.push('cierre-aforismo-sin-dato')
|
||||
}
|
||||
async function r4_casiDuplicado(pool, content, out) {
|
||||
if (!pool) return
|
||||
const since = new Date(Date.now() - 30 * 86400e3)
|
||||
const cand = []
|
||||
try {
|
||||
const a = await pool.query("SELECT body AS c FROM drafts WHERE ts >= $1 AND body IS NOT NULL", [since])
|
||||
cand.push(...a.rows.map(r => r.c))
|
||||
const b = await pool.query("SELECT content AS c FROM outbound_drafts WHERE ts >= $1 AND content IS NOT NULL", [since])
|
||||
cand.push(...b.rows.map(r => r.c))
|
||||
} catch { /* sin corpus → pasa */ }
|
||||
const sh = shingles(content)
|
||||
let best = 0, bestTxt = ''
|
||||
for (const c of cand) {
|
||||
if (!c || c === content) continue
|
||||
const j = jaccard(sh, shingles(c))
|
||||
if (j > best) { best = j; bestTxt = String(c).slice(0, 60) }
|
||||
}
|
||||
if (best >= DUP_THRESHOLD) out.push(`casi-duplicado (jaccard=${best.toFixed(2)} vs "${bestTxt}…")`)
|
||||
}
|
||||
async function r5_caps(pool, channel, type, out) {
|
||||
if (!pool) return
|
||||
if (METADATA_TYPES.includes(type)) return
|
||||
let ch = channel
|
||||
if (/facebook/i.test(type)) ch = 'facebook'
|
||||
else if (/instagram/i.test(type)) ch = 'instagram'
|
||||
const cap = caps()[ch]
|
||||
if (!cap) return
|
||||
try {
|
||||
const r = await pool.query(
|
||||
"SELECT count(*)::int AS n FROM outbound_drafts WHERE channel=$1 AND type<>ALL($2) AND status='published' AND ts >= date_trunc('day', now())",
|
||||
[ch, METADATA_TYPES])
|
||||
if (r.rows[0].n >= cap) out.push(`tope-diario:${ch}(${r.rows[0].n}/${cap})`)
|
||||
} catch { /* sin tabla → pasa (fail-open solo en caps, el resto es fail-closed) */ }
|
||||
}
|
||||
|
||||
|
||||
// R9 (ronda-14): la pieza debe compartir terminos con su fuente
|
||||
function r9_atribucion(full, reasons) {
|
||||
const idx = full.indexOf('\n\n\u2014 fuente: ')
|
||||
if (idx === -1) return
|
||||
const text = full.slice(0, idx).toLowerCase()
|
||||
const words = text.split(/[^a-z0-9]+/).filter(w => w.length > 4)
|
||||
if (words.length < 5) return
|
||||
const DOMAIN = ['fee','block','channel','lightning','transaction','script','taproot','multisig','utxo','satoshi','chain','node','mining','pool','hash','signature','key','address','protocol','wallet','custody','exchange','etf','outflow','treasury','hack','exploit','phishing','security','privacy','regulation','stablecoin','nostr','zap','invoice','payment','merchant','btcpay','server','self-hosted','open-source','spot','futures','leverage','liquidation']
|
||||
const hasDomain = words.some(w => DOMAIN.includes(w) || w.includes('bitcoin') || w.includes('lightning') || w.includes('chain') || w.includes('taproot'))
|
||||
if (!hasDomain && words.length > 12) {
|
||||
reasons.push('R9-atribucion-no-verificable')
|
||||
}
|
||||
}
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
export function ensureGuardTables(pool) {
|
||||
return pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS guard_rejections (
|
||||
id bigserial PRIMARY KEY,
|
||||
ts timestamptz NOT NULL DEFAULT now(),
|
||||
channel text NOT NULL, type text NOT NULL,
|
||||
reasons text NOT NULL, content text NOT NULL
|
||||
)`)
|
||||
}
|
||||
export async function guardCheck(pool, { channel, type, content }) {
|
||||
const reasons = []
|
||||
const full = String(content ?? '')
|
||||
// ronda-14: separar el texto del LLM de la línea de atribución
|
||||
// la atribución ("— fuente: ... · ...") solo cuenta para R3 y R7
|
||||
// R1/R2/R8/R4/R5 se evalúan sobre el TEXTO puro
|
||||
const attrMatch = full.match(/\n\n\u2014 fuente: .+$/)
|
||||
const attribution = attrMatch ? attrMatch[0] : ''
|
||||
const body = attrMatch ? full.slice(0, attrMatch.index) : full
|
||||
r1_marcas(channel, type, body, reasons)
|
||||
if (!METADATA_TYPES.includes(type)) {
|
||||
r2_formato(body, reasons)
|
||||
r3_hechos(full, reasons)
|
||||
r7_frescura(channel, full, reasons)
|
||||
r8_cierre(body, reasons)
|
||||
r9_atribucion(full, reasons)
|
||||
await r4_casiDuplicado(pool, body, reasons)
|
||||
await r5_caps(pool, channel, type, reasons)
|
||||
}
|
||||
return { ok: reasons.length === 0, reasons }
|
||||
}
|
||||
export async function logRejection(pool, { channel, type, content, reasons }) {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
'INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ($1,$2,$3,$4) RETURNING id',
|
||||
[channel, type, reasons.join('; '), String(content).slice(0, 5000)])
|
||||
console.log(`[guard] RECHAZADO (${channel}/${type}): ${reasons.join('; ')}`)
|
||||
return r.rows[0].id
|
||||
} catch (e) { console.error('[guard] logRejection:', String(e).slice(0, 120)); return null }
|
||||
}
|
||||
export async function recordPublish(pool, { channel, type, content, meta = {} }) {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO outbound_drafts (channel, type, content, meta, status) VALUES ($1,$2,$3,$4,'published') RETURNING id`,
|
||||
[channel, type, String(content).slice(0, 8000), JSON.stringify(meta)])
|
||||
return r.rows[0].id
|
||||
} catch (e) { console.error('[guard] recordPublish:', String(e).slice(0, 120)); return null }
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
// guard.test.mjs — tests del publish-guard (directiva 2026-11-01)
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { createRequire } from 'node:module'
|
||||
import { readFileSync } from 'node:fs'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
const pg = require_('/srv/cayce-core/node_modules/pg')
|
||||
const env = Object.fromEntries(readFileSync('/srv/cayce-core/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
|
||||
const pool = new pg.Pool({ connectionString: env.PGURL })
|
||||
const { guardCheck, ensureGuardTables, FORCE_DRAFT_CHANNELS } = await import('/srv/cayce-core/guard.mjs')
|
||||
await ensureGuardTables(pool)
|
||||
const clean = async () => {
|
||||
await pool.query("DELETE FROM outbound_drafts WHERE meta->>'test' = '1'")
|
||||
await pool.query("DELETE FROM guard_rejections WHERE content LIKE '%__TEST__%'")
|
||||
}
|
||||
test.after(() => pool.end())
|
||||
|
||||
test('R1 marca de hardware wallet bloqueada', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'keep your coins in a Ledger and sleep well __TEST__' })
|
||||
assert.ok(!v.ok, JSON.stringify(v)); assert.ok(v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R2a enlace de referido', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'good wallets here https://shop.example.com/?ref=cayce __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('enlace-afiliado-o-referido'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R2b hashtag', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market never sleeps #bitcoin __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('hashtag'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R2c emoji', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again 🔥 __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('emoji'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R2d signo de exclamacion', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again __TEST__' .replace('again','again!') })
|
||||
assert.ok(!v.ok && v.reasons.includes('signo-de-exclamacion'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R3 hecho sin fuente y fecha', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 300 sat/vB, the highest since march __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('afirmacion-factica-sin-fuente-y-fecha'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R3 pasa con fuente y fecha', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 12 sat/vB on Nov 1 2026 (source: https://hardcap.news/fees) and nobody cared __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R4 casi-duplicado 30 dias', async () => {
|
||||
await clean()
|
||||
await clean()
|
||||
await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','opinion',\$1,\$2,'published')",
|
||||
['the market priced the halving with astonishing precision and nobody noticed anything at all about it', JSON.stringify({ test: '1' })])
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market priced the halving with astonishing precision and nobody noticed anything at all today __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('casi-duplicado')), JSON.stringify(v.reasons))
|
||||
await clean()
|
||||
})
|
||||
test('R5 tope diario por canal', async () => {
|
||||
await clean()
|
||||
await clean()
|
||||
for (let i = 0; i < 3; i++) await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','note',\$1,\$2,'published')", [`cap test ${i}`, JSON.stringify({ test: '1' })])
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'note', content: 'one more note today with a source https://x.example and date Oct 28 2026 __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('tope-diario:nostr')), JSON.stringify(v.reasons))
|
||||
await clean()
|
||||
})
|
||||
test('R6 canales siempre en borrador', async () => {
|
||||
await clean()
|
||||
assert.deepEqual(FORCE_DRAFT_CHANNELS.sort(), ['dm', 'mail', 'proposal'])
|
||||
})
|
||||
test('metadata (kind 3) exenta de reglas de texto', async () => {
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'contact_list', content: '' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R7 fuente mayor de 72h en canal de noticias', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees spiked in august https://example.com/x reported Aug 20 2026 and nobody moved __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('fuente-mayor-72h')), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R7 pasa con fuente fresca', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'outflows hit 3.64 billion after the reopen on Nov 1 2026 https://www.theblock.co/x — the queue was the story __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R7 no aplica a bitcoinhotels', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'bitcoinhotels', type: 'guide', content: 'step by step guide verified against https://docs.btcpayserver.org/ visited Oct 20 2026 __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R8 cierre aforismo corto sin dato', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Indexes love you while you are liquid __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R8 pasa si el cierre trae dato', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Tron moved 242 million for the Lazarus group __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R8 regression: cola (source: ...) no cuenta como cierre', async () => {
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 and Tron carried 242 million for the group. The blocklist missed Tron and 242 million dollars noticed. (source: https://www.theblock.co/x, Nov 1 2026) __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R8 refinado: cierre con superlativo/record aporta dato', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'hardcap', type: 'article_note', content: 'A record hack at a major platform triggered the largest daily net outflow on record for Bitcoin ETFs on Nov 1 2026 (source: https://decrypt.co/x, Nov 1 2026) __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R1 opendime y seedplate bloqueados', async () => {
|
||||
await clean()
|
||||
const v1 = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'OPENDIME makes bitcoin physical and cheap __TEST__' })
|
||||
const v2 = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'a SEEDPLATE survives the house fire they said __TEST__' })
|
||||
assert.ok(!v1.ok && v1.reasons.some(r => r.includes('opendime')), JSON.stringify(v1.reasons))
|
||||
assert.ok(!v2.ok && v2.reasons.some(r => r.includes('seedplate')), JSON.stringify(v2.reasons))
|
||||
})
|
||||
|
||||
test('R1 voz propia: opinion con marca RECHAZADA', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Ledger users learned the hard way that plastic is not verification __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R1 noticia de terceros: signal con marca + fuente PERMITIDA', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'hardcap', type: 'signal', content: '2026-11-01 · Ledger confirmed a hardware implant found in reseller units across Asia, per the vendor advisory (source: https://example.com/advisory, Nov 1 2026). Per the advisory, 7 Asian markets pulled affected units within 48 hours.' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R14a: aforismo + fuente → RECHAZADO por R8 (no por R3)', async () => {
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Index membership was never a promise.\n\n— fuente: https://example.com/x · 2026-11-02' })
|
||||
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), 'debe rechazar por R8: ' + JSON.stringify(v.reasons))
|
||||
assert.ok(!v.reasons.some(r => r.includes('afirmacion')), 'R3 NO debe rechazar con fuente')
|
||||
})
|
||||
|
||||
test('R14c: texto tema X + fuente tema Y → RECHAZADO por R9', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'The pasta chef rolled the dough with deliberate precision and the sauce reduced for exactly twelve minutes before plating for the discerning guests who appreciate the subtlety of proper Italian cooking techniques.\n\n— fuente: https://theblock.co/taproot-article · 2026-11-02' })
|
||||
assert.ok(v.reasons.includes('R9-atribucion-no-verificable'), 'debe rechazar por R9: ' + JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R14d: pieza correcta sobre tapscript con fuente tapscript → APROBADA', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Multiplication in Tapscript just got cheaper. The 371-byte script proves complex arithmetic belongs on-chain, not in trusted setup ceremonies. The 371-byte Tapscript implementation proves that complex arithmetic belongs on-chain without trusted setups.\n\n— fuente: https://bitcoinmagazine.com/tapscript-multiplication · 2026-11-02' })
|
||||
assert.ok(v.ok, 'debe aprobar: ' + JSON.stringify(v.reasons))
|
||||
})
|
||||
@@ -1,176 +0,0 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { ingestAllSources } from '../services/ingestion'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// ── auth middleware (2026-10-31): token obligatorio, comparación en tiempo constante.
|
||||
// Fail-closed: sin HARDCAP_ADMIN_TOKEN configurado, TODAS las rutas admin responden 503.
|
||||
import { createHash, timingSafeEqual } from 'crypto'
|
||||
|
||||
const ADMIN_TOKEN = process.env.HARDCAP_ADMIN_TOKEN || ''
|
||||
function _safeEqual(a: string, b: string): boolean {
|
||||
// hash de ambos a longitud fija: sin leak de longitud ni de timing por contenido
|
||||
return timingSafeEqual(createHash('sha256').update(String(a)).digest(), createHash('sha256').update(String(b)).digest())
|
||||
}
|
||||
function adminAuth(req: any, res: any, next: any) {
|
||||
if (!ADMIN_TOKEN) {
|
||||
console.error('[admin-auth] HARDCAP_ADMIN_TOKEN not set — admin request REFUSED (fail-closed)')
|
||||
return res.status(503).json({ error: 'admin disabled: token not configured' })
|
||||
}
|
||||
const provided = String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') || String(req.headers['x-admin-token'] || '')
|
||||
if (provided && _safeEqual(provided, ADMIN_TOKEN)) return next()
|
||||
console.error(`[admin-auth] 401 ${req.method} ${req.originalUrl} from ${req.socket.remoteAddress}`)
|
||||
return res.status(401).json({ error: 'unauthorized' })
|
||||
}
|
||||
router.use(adminAuth)
|
||||
// regla permanente de secretos: jamás loguear el token ni volcar headers.
|
||||
|
||||
|
||||
// ── Articles ──────────────────────────────────────────────
|
||||
|
||||
// GET /admin/articles?status=QUEUED&topic=lightning
|
||||
router.get('/articles', async (req, res) => {
|
||||
const { status, topic } = req.query
|
||||
|
||||
const articles = await prisma.article.findMany({
|
||||
where: {
|
||||
...(status ? { status: status as any } : {}),
|
||||
...(topic ? { topics: { has: topic as string } } : {}),
|
||||
},
|
||||
include: { source: { select: { name: true } } },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
|
||||
res.json(articles)
|
||||
})
|
||||
|
||||
|
||||
// ── contención (2026-10-31): PATCH → PUBLISHED pasa por el outbox ──
|
||||
const MODE = process.env.HARDCAP_PUBLISH_MODE || 'draft';
|
||||
let pgCache = null;
|
||||
function caycePool() {
|
||||
if (!pgCache) {
|
||||
// pg vive en el runtime de cayce-core (hardcap-backend usa prisma)
|
||||
const { Pool } = require('/srv/cayce-core/node_modules/pg');
|
||||
pgCache = new Pool({ connectionString: process.env.CAYCE_PGURL });
|
||||
}
|
||||
return pgCache;
|
||||
}
|
||||
function identityHash() {
|
||||
try {
|
||||
const { createHash } = require('crypto');
|
||||
const { readFileSync } = require('fs');
|
||||
const p = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md';
|
||||
return createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// PATCH /admin/articles/:id — update status (CONTENCIÓN: draft no publica)
|
||||
router.patch('/articles/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { status } = req.body
|
||||
if (status === 'PUBLISHED' && MODE !== 'live') {
|
||||
try {
|
||||
const art = await prisma.article.findUnique({ where: { id } })
|
||||
const content = `${art?.title || id} | ${art?.summary || ''}`.slice(0, 400)
|
||||
const ih = identityHash()
|
||||
const r = await caycePool().query(
|
||||
"INSERT INTO outbound_drafts (channel, type, content, identity_path, identity_hash, meta) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id",
|
||||
['hardcap', 'article_publish', content,
|
||||
process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md',
|
||||
ih, JSON.stringify({ article_id: id })]
|
||||
)
|
||||
console.log(`[outbox] STAGED draft #${r.rows[0].id} (hardcap/article_publish) — artículo ${id} NO publicado (mode=${MODE})`)
|
||||
return res.json({ staged: true, draft_id: String(r.rows[0].id), article_id: id })
|
||||
} catch (e) {
|
||||
// fail-closed: sin borrador, NO se publica. Sin volcar el comando ni el DSN al log.
|
||||
console.error('[outbox] staging failed — PATCH descartado (fail-closed):', String((e && e.message) || e).slice(0, 120))
|
||||
return res.status(500).json({ staged: false, error: 'staging failed; publish discarded (fail-closed)' })
|
||||
}
|
||||
}
|
||||
const article = await prisma.article.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
})
|
||||
|
||||
res.json(article)
|
||||
})
|
||||
|
||||
// ── Sources ──────────────────────────────────────────────
|
||||
|
||||
// GET /admin/sources
|
||||
router.get('/sources', async (_req, res) => {
|
||||
const sources = await prisma.source.findMany({ orderBy: { name: 'asc' } })
|
||||
res.json(sources)
|
||||
})
|
||||
|
||||
// POST /admin/sources
|
||||
router.post('/sources', async (req, res) => {
|
||||
const { name, rssUrl, tier, topicBias } = req.body
|
||||
const source = await prisma.source.create({
|
||||
data: { name, rssUrl, tier: tier ?? 2, topicBias: topicBias ?? [] },
|
||||
})
|
||||
res.json(source)
|
||||
})
|
||||
|
||||
// PATCH /admin/sources/:id — toggle active, update tier
|
||||
router.patch('/sources/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { active, tier, topicBias } = req.body
|
||||
|
||||
const source = await prisma.source.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(active !== undefined ? { active } : {}),
|
||||
...(tier !== undefined ? { tier } : {}),
|
||||
...(topicBias !== undefined ? { topicBias } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
res.json(source)
|
||||
})
|
||||
|
||||
// ── Topic Filters ─────────────────────────────────────────
|
||||
|
||||
// GET /admin/topics
|
||||
router.get('/topics', async (_req, res) => {
|
||||
const topics = await prisma.topicFilter.findMany({ orderBy: { label: 'asc' } })
|
||||
res.json(topics)
|
||||
})
|
||||
|
||||
// PATCH /admin/topics/:id — toggle active, adjust weight
|
||||
router.patch('/topics/:id', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const { active, weight, keywordsInclude, keywordsExclude } = req.body
|
||||
|
||||
const topic = await prisma.topicFilter.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(active !== undefined ? { active } : {}),
|
||||
...(weight !== undefined ? { weight } : {}),
|
||||
...(keywordsInclude !== undefined ? { keywordsInclude } : {}),
|
||||
...(keywordsExclude !== undefined ? { keywordsExclude } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
res.json(topic)
|
||||
})
|
||||
|
||||
// ── Manual Trigger ────────────────────────────────────────
|
||||
|
||||
// DELETE /admin/articles/all — reset all articles
|
||||
router.delete('/articles/all', async (_req, res) => {
|
||||
const result = await prisma.article.deleteMany({})
|
||||
res.json({ deleted: result.count })
|
||||
})
|
||||
|
||||
// POST /admin/ingest — trigger ingestion manually
|
||||
router.post('/ingest', async (_req, res) => {
|
||||
res.json({ message: 'Ingestion started' })
|
||||
// Fire and forget
|
||||
ingestAllSources().catch(console.error)
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -1,31 +0,0 @@
|
||||
// test-admin-auth — verifica el middleware de /admin (hardcap-backend).
|
||||
// Ejecutar en la VPS: node /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs
|
||||
// (o node runtime/hardcap/test-admin-auth.mjs desde el repo)
|
||||
// Requiere HARDCAP_ADMIN_TOKEN en el .env del servicio y el servicio activo en 127.0.0.1:3001.
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BASE = 'http://127.0.0.1:3001'
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/hardcap-backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
const TOKEN = env.HARDCAP_ADMIN_TOKEN
|
||||
if (!TOKEN) { console.error('HARDCAP_ADMIN_TOKEN no configurado — el test no puede pasar'); process.exit(2) }
|
||||
|
||||
let failed = 0
|
||||
async function check(name, path, opts, expected) {
|
||||
const r = await fetch(BASE + path, { ...opts, signal: AbortSignal.timeout(8000) })
|
||||
const ok = r.status === expected
|
||||
if (!ok) failed++
|
||||
console.log(`${ok ? '✓' : '✗'} ${name}: esperado ${expected}, obtenido ${r.status}`)
|
||||
}
|
||||
|
||||
await check('sin token → 401', '/admin/articles?status=QUEUED', {}, 401)
|
||||
await check('token incorrecto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'x'.repeat(64) } }, 401)
|
||||
await check('token vacío en header → 401', '/admin/sources', { headers: { Authorization: 'Bearer ' } }, 401)
|
||||
await check('token correcto → 200', '/admin/articles?status=QUEUED', { headers: { Authorization: `Bearer ${TOKEN}` } }, 200)
|
||||
await check('header alternativo X-Admin-Token → 200', '/admin/sources', { headers: { 'x-admin-token': TOKEN } }, 200)
|
||||
// la comparación debe ser en tiempo constante: un token de la misma longitud pero distinto NO pasa
|
||||
await check('token misma longitud, distinto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'y'.repeat(TOKEN.length) } }, 401)
|
||||
|
||||
console.log(failed ? `\n${failed} TESTS FALLARON` : '\nTODOS LOS TESTS PASARON')
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -1,312 +0,0 @@
|
||||
// cayce-initiative — el loop de iniciativa de la entidad.
|
||||
// Cada ciclo: lee su estado (métricas, oportunidades, audit) → decide la
|
||||
// acción de mayor valor → la ejecuta con herramientas → audita.
|
||||
// Herramientas: web_fetch, add_opportunity, draft_proposal, publish_note.
|
||||
// MANDATE §7b: cada ciclo DEBE producir al menos una de: oportunidad
|
||||
// investigada, draft, o contenido de distribución.
|
||||
|
||||
import pg from 'pg'
|
||||
import fs from 'node:fs'
|
||||
import { chat } from './llm.mjs'
|
||||
import { selectSource, attribute, attributeURL } from '/srv/cayce-core/sources.mjs'
|
||||
|
||||
const env = Object.fromEntries(
|
||||
fs.readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
const pgc = new pg.Pool({ connectionString: env.PGURL })
|
||||
const NOSTR = env.NOSTR_INTERNAL || 'http://127.0.0.1:3011'
|
||||
const MANDATE = fs.readFileSync(env.MANDATE_PATH || './MANDATE.md', 'utf8')
|
||||
// memoria operativa: lecciones de la retro (errores conocidos → remedios)
|
||||
const LESSONS = fs.existsSync('./lessons.md') ? fs.readFileSync('./lessons.md', 'utf8') : ''
|
||||
|
||||
const log = (zone, action, detail = {}, result = 'ok') =>
|
||||
pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result])
|
||||
|
||||
// reporte interno → DM al supervisor (nunca público)
|
||||
const SUPERVISOR = (fs.readFileSync('/srv/cayce-nostr/.env', 'utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)?.[1] || '').trim()
|
||||
async function dm(text) {
|
||||
try {
|
||||
await fetch(`${NOSTR}/dm`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to_hex: SUPERVISOR, content: text }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// ── herramientas
|
||||
async function toolWebFetch({ url }) {
|
||||
if (!/^https:\/\//.test(url)) return { error: 'solo https' }
|
||||
const r = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; cayce-entity/1.0)' },
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
const html = await r.text()
|
||||
const text = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&/g, '&').replace(/ /g, ' ').replace(/&#\d+;/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
return { status: r.status, title: (html.match(/<title>([^<]*)<\/title>/i) || [])[1] || '', text: text.slice(0, 6000) }
|
||||
}
|
||||
|
||||
async function toolAddOpportunity(a) {
|
||||
// directiva supervisor 2026-10-31: sin dedup el agente re-registraba la
|
||||
// misma oportunidad (OpenSats ×10, HRF ×6). Ahora: si existe, se rechaza.
|
||||
const dup = await pgc.query('SELECT id FROM opportunities WHERE lower(source_name) = lower($1) LIMIT 1', [a.source_name])
|
||||
if (dup.rowCount) {
|
||||
await log('yellow', 'opportunity_duplicate_rejected', { source_name: a.source_name, existing_id: dup.rows[0].id })
|
||||
return { duplicate: true, existing_id: dup.rows[0].id, note: 'ya registrada — no se duplica; continuá con el resto del ciclo' }
|
||||
}
|
||||
await pgc.query(
|
||||
`INSERT INTO opportunities (source_name, url, mechanic, fit, effort, expected, first_action)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[a.source_name, a.url || null, a.mechanic, a.fit || null, a.effort || null, a.expected || null, a.first_action || null]
|
||||
)
|
||||
await log('green', 'opportunity_added', { source_name: a.source_name })
|
||||
return { added: a.source_name }
|
||||
}
|
||||
|
||||
async function toolDraftProposal(a) {
|
||||
const r = await pgc.query(
|
||||
`INSERT INTO drafts (kind, title, body) VALUES ($1,$2,$3) RETURNING id`,
|
||||
[a.kind || 'other', a.title, a.body]
|
||||
)
|
||||
await log('green', 'draft_created', { id: r.rows[0].id, kind: a.kind, title: a.title })
|
||||
return { draft_id: r.rows[0].id, note: 'el envío es 🔴 — escala al supervisor' }
|
||||
}
|
||||
|
||||
// guardrail: los reportes internos del ciclo NUNCA van a Nostr (nunca mostrar la maquinaria)
|
||||
const INTERNAL_REPORT_RE = /§7b|ciclo cumplido|pipeline|auto-?mejora|ingreso prioridad|oportunidades acumuladas|próximo play|web_fetch|add_opportunity/i
|
||||
|
||||
async function toolPublishNote({ content }) {
|
||||
if (!content || typeof content !== 'string') return { error: 'content requerido' }
|
||||
if (INTERNAL_REPORT_RE.test(content)) {
|
||||
return { error: 'CONTENIDO INTERNO — los reportes de ciclo, pipeline y status NO se publican en Nostr. publish_note es solo para contenido público con voz editorial. Registrá tu avance con finish.' }
|
||||
}
|
||||
const r = await fetch(`${NOSTR}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 1, content, type: 'initiative_note' }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
})
|
||||
const pr = await r.json()
|
||||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_initiative', content, pr.id])
|
||||
return pr
|
||||
}
|
||||
|
||||
// la entidad escribe el remedio de un error conocido → lessons.remedy (self-improvement)
|
||||
// normaliza una firma igual que la retro (dedup por diseño: dígitos → #)
|
||||
function normSig(s) {
|
||||
return String(s)
|
||||
.replace(/https?:\/\/\S+/g, '#url')
|
||||
.replace(/\b[0-9a-f]{8,}\b/gi, '#hex')
|
||||
.replace(/\d+(\.\d+)?/g, '#')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
async function toolRemedy({ signature, remedy }) {
|
||||
if (!signature || !remedy) return { error: 'signature + remedy requeridos' }
|
||||
// 1) match exacto
|
||||
let r = await pgc.query(
|
||||
`UPDATE lessons SET remedy=$1 WHERE signature=$2 RETURNING signature, occurrences`,
|
||||
[String(remedy).slice(0, 500), String(signature)])
|
||||
// 2) match difuso: la línea formateada de lessons.md contiene la firma normalizada
|
||||
// (la entidad suele copiar la línea completa con contador y "· ESCALADO")
|
||||
if (!r.rowCount) {
|
||||
const sigNorm = normSig(signature)
|
||||
const cands = (await pgc.query(`SELECT signature FROM lessons WHERE status IN ('open','escalated')`)).rows
|
||||
const hit = cands.find(c => sigNorm.includes(normSig(c.signature)))
|
||||
if (hit) r = await pgc.query(
|
||||
`UPDATE lessons SET remedy=$1 WHERE signature=$2 RETURNING signature, occurrences`,
|
||||
[String(remedy).slice(0, 500), hit.signature])
|
||||
}
|
||||
if (!r.rowCount) {
|
||||
return { error: `signature no encontrada: ${String(signature).slice(0, 120)}`, hint: 'usá la firma de la lista, sin el contador ni "· ESCALADO"' }
|
||||
}
|
||||
await log('green', 'lesson_remedy', { signature: r.rows[0].signature, remedy: String(remedy).slice(0, 200) })
|
||||
return { updated: r.rows[0].signature, occurrences: r.rows[0].occurrences }
|
||||
}
|
||||
|
||||
const TOOLS = {
|
||||
web_fetch: toolWebFetch,
|
||||
add_opportunity: toolAddOpportunity,
|
||||
draft_proposal: toolDraftProposal,
|
||||
publish_note: toolPublishNote,
|
||||
add_lesson_remedy: toolRemedy,
|
||||
}
|
||||
|
||||
// ── estado para el prompt
|
||||
async function stateSummary() {
|
||||
const opps = (await pgc.query('SELECT source_name, status FROM opportunities ORDER BY id DESC LIMIT 20')).rows
|
||||
const zaps = (await pgc.query(`SELECT count(*) n, coalesce(sum(value),0) msats FROM metrics WHERE metric='zaps_msats'`)).rows[0]
|
||||
const balance = (await pgc.query(`SELECT value FROM metrics WHERE metric='balance_reported_sats' ORDER BY id DESC LIMIT 1`)).rows[0]
|
||||
const drafts = (await pgc.query('SELECT count(*) n FROM drafts')).rows[0].n
|
||||
const lastActions = (await pgc.query('SELECT ts, action FROM actions_log ORDER BY id DESC LIMIT 5')).rows
|
||||
const lessons = (await pgc.query(`SELECT signature, occurrences FROM lessons WHERE status != 'mitigated' ORDER BY occurrences DESC LIMIT 5`)).rows
|
||||
return {
|
||||
sats_recaudados: {
|
||||
zaps_verificados: zaps.n,
|
||||
balance_reportado_por_supervisor: balance ? Number(balance.value) : 'aún no reportado',
|
||||
},
|
||||
oportunidades_registradas: opps.length,
|
||||
oportunidades_recientes: opps.map(o => `${o.source_name} [${o.status}]`),
|
||||
drafts: drafts,
|
||||
errores_conocidos_abiertos: lessons.map(l => `${l.signature} (${l.occurrences}×)`),
|
||||
ultimas_acciones: lastActions.map(a => `${a.ts.toISOString().slice(0, 16)} ${a.action}`),
|
||||
}
|
||||
}
|
||||
|
||||
const TOOLS_SPEC = `TOOLS (respond with JSON only):
|
||||
{"action":"web_fetch","args":{"url":"https://..."}}
|
||||
{"action":"add_opportunity","args":{"source_name":"...","url":"...","mechanic":"cómo genera ingreso","fit":"encaje con hard lines","effort":"low|medium|high","expected":"ingreso esperado","first_action":"primer paso concreto"}}
|
||||
{"action":"draft_proposal","args":{"kind":"sponsorship|grant|affiliate|other","title":"...","body":"propuesta completa lista para enviar"}}
|
||||
{"action":"publish_note","args":{"content":"..."}}
|
||||
{"action":"publish_note","args":{"content":"...— SOLO contenido público para la audiencia de Nostr, con voz editorial (irónico, cero marketing, cero reportes internos). Los reportes de ciclo, pipeline y status NUNCA se publican: eso va en finish."}}
|
||||
{"action":"add_lesson_remedy","args":{"signature":"firma exacta de la lista de errores conocidos","remedy":"qué hacer distinto para evitar el error"}}
|
||||
{"action":"finish","args":{"summary":"qué lograste este ciclo"}}
|
||||
Una acción por respuesta. El sistema ejecuta y te devuelve el resultado. Si tu respuesta no es JSON válido, se te pide re-emitirla: responde SOLO el objeto JSON, sin texto alrededor.`
|
||||
|
||||
// ── el loop
|
||||
async function runCycle() {
|
||||
const state = await stateSummary()
|
||||
// directiva del supervisor (2026-10-31): grants suspendidos, BTCPay prioridad 1.
|
||||
// No modifica el MANDATE (eso es 🔴); es una orden operativa reversible con
|
||||
// CAYCE_DIRECTIVE_BTCPAY=0 en el .env.
|
||||
const DIRECTIVE = process.env.CAYCE_DIRECTIVE_BTCPAY === '1' ? `
|
||||
|
||||
=== DIRECTIVA DEL SUPERVISOR (2026-10-31 — prevalece sobre §7b hasta nuevo aviso) ===
|
||||
1. SUSPENDIDA la búsqueda y registro de grants y nuevas oportunidades. NO uses add_opportunity salvo para la excepción del punto 2.
|
||||
2. PRIORIDAD 1 ÚNICA: la propuesta de BTCPay Server — oportunidad #106 "BTCPay Server Deployment & Content Service" y draft #33 "BTCPay Server Plugin: Event & Travel Integration". Trabajo esperado: consolidar UNA propuesta final lista para envío humano (alcance, arquitectura, presupuesto, plan de entrega) como draft nuevo marcado claramente. NO la envíes: la envía el operador.
|
||||
3. Publicación AUTÓNOMA activa (2026-11-01): publish_note publica directamente, TODO pasa por el publish-guard del outbox. Si el guard rechaza, registra el motivo y sigue. Kill switch inmediato: /srv/cayce-core/PUBLISH_OFF.
|
||||
` : ''
|
||||
// directiva del supervisor (2026-11-01): cold storage sin marcas. Reversible:
|
||||
// CAYCE_DIRECTIVE_COLDSTORAGE=0 en el .env.
|
||||
const DIRECTIVE_CS = process.env.CAYCE_DIRECTIVE_COLDSTORAGE === '0' ? '' : `
|
||||
|
||||
=== DIRECTIVA DEL SUPERVISOR (2026-11-01 — vigente hasta nuevo aviso) ===
|
||||
4. CERO menciones a marcas comerciales de hardware wallets / cold storage, en cualquier canal (notas, editoriales, guías, promos).
|
||||
5. Custodia fría: SOLO soluciones DIY y de código abierto, cada una con fuente primaria, y advertencia explícita cuando hay riesgo de perder fondos.
|
||||
6. La fuente de ingresos "afiliados de hardware" de §7b queda RETIRADA por directiva. El MANDATE no se toca.
|
||||
`
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: `${MANDATE}
|
||||
${DIRECTIVE}
|
||||
${DIRECTIVE_CS}
|
||||
|
||||
=== TU ESTADO ACTUAL ===
|
||||
${JSON.stringify(state, null, 2)}
|
||||
${LESSONS ? `
|
||||
=== LECCIONES — errores conocidos, evitalos ===
|
||||
${LESSONS}
|
||||
` : ''}
|
||||
=== TU MISIÓN ESTE CICLO ===
|
||||
MANDATE §7b: produce AL MENOS una de: oportunidad de ingreso investigada (con web_fetch a fuentes reales — grants, afiliados, bounties, sponsors del ecosistema bitcoin), draft de propuesta, o contenido que impulse distribución. Investiga antes de registrar: abre las fuentes con web_fetch, verifica que existan y qué piden (QC-3).
|
||||
|
||||
Prioridad: ingreso. Piensa como empresa: ¿qué puedo vender, a quién, cómo cobro en sats? Las propiedades (hardcap.news = señal, bitcoinhotels.org = travel BTC) son tus activos y tu vidriera.
|
||||
|
||||
Si hay errores conocidos abiertos con remedio pendiente (lista arriba), considerá escribirlo con add_lesson_remedy — auto-mejora.
|
||||
|
||||
${TOOLS_SPEC}
|
||||
|
||||
Empieza ahora. Cuando hayas cumplido §7b, responde finish con un summary honesto.`,
|
||||
},
|
||||
]
|
||||
|
||||
let steps = 0
|
||||
const maxSteps = 16
|
||||
let repairs = 0
|
||||
const maxRepairs = 3
|
||||
let summary = null
|
||||
const trace = []
|
||||
const PRODUCTIVE = ['add_opportunity', 'draft_proposal', 'publish_note', 'add_lesson_remedy']
|
||||
let productive = 0
|
||||
|
||||
while (steps < maxSteps) {
|
||||
steps++
|
||||
// presupuesto de pasos: empujar al cierre antes de agotar (evita 'max steps')
|
||||
const remaining = maxSteps - steps
|
||||
if (remaining === 3) {
|
||||
messages.push({ role: 'user', content: 'QUEDAN 3 PASOS. Cierra el ciclo: si ya investigaste, ejecutá AHORA una acción productiva (add_opportunity / draft_proposal / publish_note / add_lesson_remedy) y después finish con el summary.' })
|
||||
} else if (remaining === 1) {
|
||||
messages.push({ role: 'user', content: 'ÚLTIMO PASO. Respondé finish con un summary honesto de lo logrado este ciclo, aunque sea parcial.' })
|
||||
}
|
||||
// la cadena (zai → gemini) agota los fallbacks; si todo falla, el ciclo
|
||||
// muere auditado como error y la retro lo escala
|
||||
let text = await chat(messages, 6000, { thinking: false, timeoutMs: 90000 })
|
||||
text = text.replace(/```json|```/g, '').trim()
|
||||
const js = text.indexOf('{')
|
||||
const je = text.lastIndexOf('}')
|
||||
let act = null
|
||||
if (js === -1 || je === -1) {
|
||||
// sin JSON: re-emisión en vez de morir — error reparable en el momento
|
||||
if (repairs < maxRepairs) {
|
||||
repairs++
|
||||
messages.push({ role: 'assistant', content: text.slice(0, 500) })
|
||||
messages.push({ role: 'user', content: 'Esa respuesta no contenía JSON. Re-emite SOLO el objeto JSON de UNA acción, sin texto alrededor.' })
|
||||
steps--
|
||||
continue
|
||||
}
|
||||
summary = 'sin JSON tras ' + maxRepairs + ' repairs: ' + text.slice(0, 120)
|
||||
break
|
||||
}
|
||||
try { act = JSON.parse(text.slice(js, je + 1)) } catch (e) {
|
||||
if (repairs < maxRepairs) {
|
||||
repairs++
|
||||
messages.push({ role: 'assistant', content: text.slice(0, 500) })
|
||||
messages.push({ role: 'user', content: `Esa respuesta no fue JSON válido parseable (${String(e).slice(0, 80)}). Re-emite SOLO el objeto JSON de UNA acción. Si el body era muy largo, acortalo.` })
|
||||
steps--
|
||||
continue
|
||||
}
|
||||
summary = 'JSON inválido tras ' + maxRepairs + ' repairs'
|
||||
break
|
||||
}
|
||||
|
||||
if (act.action === 'finish') { summary = act.args?.summary ?? 'finish'; break }
|
||||
if (!TOOLS[act.action]) {
|
||||
messages.push({ role: 'assistant', content: JSON.stringify(act) })
|
||||
messages.push({ role: 'user', content: `herramienta desconocida: ${act.action}. ${TOOLS_SPEC}` })
|
||||
continue
|
||||
}
|
||||
let result
|
||||
try {
|
||||
result = await TOOLS[act.action](act.args || {})
|
||||
} catch (e) {
|
||||
result = { error: String(e).slice(0, 300) }
|
||||
}
|
||||
trace.push({ step: steps, action: act.action, args: act.args })
|
||||
if (PRODUCTIVE.includes(act.action) && !result?.error) productive++
|
||||
messages.push({ role: 'assistant', content: JSON.stringify(act) })
|
||||
messages.push({ role: 'user', content: 'RESULTADO: ' + JSON.stringify(result).slice(0, 4000) })
|
||||
}
|
||||
|
||||
// §7b se cumple con ≥1 acción productiva — si no, el ciclo falla y se audita como error
|
||||
const s7b_ok = productive > 0
|
||||
const finalSummary = summary ?? 'max steps'
|
||||
await log(s7b_ok ? 'green' : 'red', 'initiative_cycle',
|
||||
{ steps, productive, repairs, trace, summary: finalSummary }, s7b_ok ? 'done' : 'error')
|
||||
const drafts24 = (await pgc.query(
|
||||
`SELECT id, type, content FROM outbound_drafts WHERE ts > now() - interval '24 hours' ORDER BY id`
|
||||
).catch(() => ({ rows: [] }))).rows
|
||||
const draftLines = drafts24.map(d => `#${d.id} [${d.type}] ${String(d.content).replace(/\s+/g, ' ').split(' ').slice(0, 15).join(' ')}`)
|
||||
const draftBlock = `\n\nBORRADORES contención (24h: ${draftLines.length}) — revisión y aprobación pendientes del operador:\n${draftLines.join('\n') || 'ninguno'}`
|
||||
await dm(`INITIATIVE ${new Date().toISOString().slice(0, 10)} — ciclo ${s7b_ok ? 'cumplido' : 'falló'} · ${steps} pasos · ${productive} productivas · ${trace.map(t => t.action).join(' → ') || 'sin acciones'}\n\n${finalSummary}${draftBlock}`)
|
||||
return { steps, productive, s7b_ok, summary: finalSummary, trace }
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await runCycle()
|
||||
console.log(JSON.stringify(out, null, 2))
|
||||
await pgc.end()
|
||||
process.exit(out.s7b_ok ? 0 : 1)
|
||||
} catch (e) {
|
||||
console.error('initiative fatal:', String(e).slice(0, 300))
|
||||
await log('red', 'initiative_cycle', { error: String(e).slice(0, 300) }, 'error').catch(() => { })
|
||||
await pgc.end().catch(() => { })
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
// cayce-mail — daemon de correo de la entidad (entidadnodal@gmail.com)
|
||||
// Recibe: aprobaciones de afiliados, respuestas de merchants/grants → clasifica,
|
||||
// registra en DB y avisa al supervisor por Nostr. Envia: outreach SMTP.
|
||||
// Poll cada 5 min · estado en state.json · credenciales en .env (600)
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import pg from 'pg'
|
||||
import { gateOutbound } from '/srv/cayce-core/outbox.mjs'
|
||||
|
||||
const HOME = '/home/ubuntu/cayce-mail'
|
||||
const ENV = Object.fromEntries(readFileSync(`${HOME}/.env`, 'utf8').trim().split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
|
||||
const PGURL = readFileSync('/srv/cayce-front/.env', 'utf8').match(/^PGURL=(.+)$/m)[1]
|
||||
const SUPERVISOR = readFileSync('/srv/cayce-nostr/.env', 'utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)[1].trim()
|
||||
const NOSTR = 'http://127.0.0.1:3011'
|
||||
const pgc = new pg.Pool({ connectionString: PGURL })
|
||||
|
||||
const POLL_MS = 5 * 60 * 1000
|
||||
const statePath = `${HOME}/state.json`
|
||||
let state = { lastUid: 0, processed: 0, notified: 0 }
|
||||
if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf8')) } } catch { } }
|
||||
const save = () => writeFileSync(statePath, JSON.stringify(state, null, 1), { mode: 0o600 })
|
||||
|
||||
const log = (m) => console.log(`[cayce-mail ${new Date().toISOString().slice(5, 16)}] ${m}`)
|
||||
async function dm(text) {
|
||||
try {
|
||||
await fetch(`${NOSTR}/dm`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to_hex: SUPERVISOR, content: text.slice(0, 900) }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
state.notified++
|
||||
} catch (e) { console.error('dm:', String(e).slice(0, 80)) }
|
||||
}
|
||||
|
||||
// ── clasificación de remitentes relevantes ──────────────────────────────────
|
||||
const MERCHANTS = {
|
||||
'bitrefill.com': 'Bitrefill',
|
||||
'travala.com': 'Travala',
|
||||
'trezor.io': 'Trezor',
|
||||
'ledger.com': 'Ledger',
|
||||
'bitbox.swiss': 'BitBox',
|
||||
'coinkite.com': 'Coinkite',
|
||||
'swan.com': 'Swan',
|
||||
'bitaxe': 'Bitaxe',
|
||||
'opensats.org': 'OpenSats',
|
||||
'hrf.org': 'HRF',
|
||||
'foundationdevices.com': 'Foundation',
|
||||
'relai.ch': 'Relai',
|
||||
'blink.sv': 'Blink',
|
||||
}
|
||||
|
||||
function classify(from = '', subject = '') {
|
||||
const f = from.toLowerCase(), s = subject.toLowerCase()
|
||||
const merchant = Object.entries(MERCHANTS).find(([d]) => f.includes(d))
|
||||
if (!merchant) return null
|
||||
let kind = 'info'
|
||||
if (/approv|accepted|welcome to|partner|afiliado aprobado/.test(s)) kind = 'APPROVAL'
|
||||
else if (/reject|denied|not approved|declined/.test(s)) kind = 'REJECTION'
|
||||
else if (/statement|receipt|transaction|invoice|payout|commission/.test(s)) kind = 'money'
|
||||
else if (/verify|confirm|action required|complete your/.test(s)) kind = 'ACTION-REQUIRED'
|
||||
return { merchant: merchant[1], kind }
|
||||
}
|
||||
|
||||
async function processMessage(msg) {
|
||||
const from = msg.envelope.from?.[0]?.address || ''
|
||||
const subject = msg.envelope.subject || '(sin asunto)'
|
||||
const c = classify(from, subject)
|
||||
if (!c) return
|
||||
|
||||
log(`${c.kind} · ${c.merchant} · "${subject.slice(0, 60)}" <${from}>`)
|
||||
|
||||
// Registrar en actions_log (auditoría)
|
||||
await pgc.query(
|
||||
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('mail', 'mail_received', $1, 'ok')`,
|
||||
[{ from, subject, merchant: c.merchant, kind: c.kind, uid: msg.uid }]
|
||||
).catch(e => console.error('db:', String(e).slice(0, 100)))
|
||||
|
||||
// Notificar al supervisor solo lo importante (no estados de cuenta)
|
||||
if (c.kind === 'money') return
|
||||
const emoji = c.kind === 'APPROVAL' ? '✅' : c.kind === 'REJECTION' ? '❌' : c.kind === 'ACTION-REQUIRED' ? '⚠' : '✉'
|
||||
await dm(`${emoji} MAIL ${c.kind} — ${c.merchant}\n"${subject.slice(0, 80)}"\nDe: ${from}\nRevisar bandeja entidadnodal@gmail.com`)
|
||||
}
|
||||
|
||||
async function pollInbox() {
|
||||
const client = new ImapFlow({
|
||||
host: 'imap.gmail.com', port: 993, secure: true,
|
||||
auth: { user: ENV.MAIL_USER, pass: ENV.MAIL_PASS },
|
||||
logger: false,
|
||||
})
|
||||
try {
|
||||
await client.connect()
|
||||
const lock = await client.getMailboxLock('INBOX')
|
||||
try {
|
||||
// primera pasada: marcar el punto de partida en el último mensaje existente
|
||||
if (!state.lastUid) {
|
||||
let last = 0
|
||||
for await (const m of client.fetch({ all: true }, { uid: true }, { uid: true })) last = m.uid
|
||||
state.lastUid = last
|
||||
save()
|
||||
log(`baseline: uid ${last} · ${client.mailbox.exists} mensajes en inbox`)
|
||||
return
|
||||
}
|
||||
// mensajes nuevos desde el último UID visto
|
||||
const range = { uid: `${state.lastUid + 1}:*` }
|
||||
for await (const msg of client.fetch(range, { envelope: true, uid: true }, { uid: true })) {
|
||||
if (msg.uid <= state.lastUid) continue
|
||||
await processMessage(msg)
|
||||
state.processed++
|
||||
if (msg.uid > state.lastUid) state.lastUid = msg.uid
|
||||
}
|
||||
save()
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
await client.logout()
|
||||
} catch (e) {
|
||||
console.error('[cayce-mail] poll error:', String(e).slice(0, 160))
|
||||
try { await client.logout() } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// ── SMTP saliente para outreach ─────────────────────────────────────────────
|
||||
export async function sendMail(to, subject, body) {
|
||||
// contención (2026-10-31): el mail saliente también pasa por el outbox
|
||||
const g = await gateOutbound(null, {
|
||||
channel: 'mail', type: 'smtp_send', content: `To: ${to}\nSubject: ${subject}\n\n${body}`,
|
||||
meta: { to, subject },
|
||||
publishFn: () => _sendMailLive(to, subject, body),
|
||||
})
|
||||
if (g.staged) return g
|
||||
}
|
||||
|
||||
async function _sendMailLive(to, subject, body) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: 'smtp.gmail.com', port: 465, secure: true,
|
||||
auth: { user: ENV.MAIL_USER, pass: ENV.MAIL_PASS },
|
||||
})
|
||||
const info = await transporter.sendMail({
|
||||
from: `"Cayce Co" <${ENV.MAIL_USER}>`,
|
||||
to, subject, text: body,
|
||||
})
|
||||
await pgc.query(
|
||||
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('mail', 'mail_sent', $1, 'ok')`,
|
||||
[{ to, subject, messageId: info.messageId }]
|
||||
).catch(() => { })
|
||||
log(`sent → ${to} · "${subject.slice(0, 50)}"`)
|
||||
return info
|
||||
}
|
||||
|
||||
// CLI: node mail.mjs --send-to x@y --subject "..." --body-file z.txt
|
||||
const args = process.argv.slice(2)
|
||||
if (args.includes('--send-to')) {
|
||||
const to = args[args.indexOf('--send-to') + 1]
|
||||
const subject = args[args.indexOf('--subject') + 1] || '(sin asunto)'
|
||||
const bodyFile = args[args.indexOf('--body-file') + 1]
|
||||
const body = bodyFile ? readFileSync(bodyFile, 'utf8') : args[args.indexOf('--body') + 1] || ''
|
||||
sendMail(to, subject, body).then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
|
||||
} else {
|
||||
log(`daemon on · poll cada ${POLL_MS / 60000} min · cuenta ${ENV.MAIL_USER}`)
|
||||
pollInbox()
|
||||
setInterval(pollInbox, POLL_MS)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// outbox.mjs — contención de salida a terceros (prioridad Cayce, 2026-10-31)
|
||||
//
|
||||
// CAYCE_PUBLISH_MODE=draft (default) → NADA sale a terceros: todo artefacto
|
||||
// (notas nostr, respuestas a menciones, posts a redes sociales, mail) se
|
||||
// guarda como BORRADOR en outbound_drafts con el hash del archivo de
|
||||
// identidad vigente. El DM al supervisor NO pasa por aquí (es interno).
|
||||
// CAYCE_PUBLISH_MODE=live → comportamiento original, sin cambios.
|
||||
//
|
||||
// Fail-closed: en modo draft, si el guardado del borrador falla, NO se
|
||||
// publica. Nunca. El error queda en el journal.
|
||||
//
|
||||
// Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live).
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { guardCheck, logRejection, recordPublish, ensureGuardTables, FORCE_DRAFT_CHANNELS } from './guard.mjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require_ = createRequire(import.meta.url)
|
||||
|
||||
const IDENTITY_PATH = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md'
|
||||
|
||||
export function publishMode() {
|
||||
// kill switch instantáneo (2026-11-01): touch /srv/cayce-core/PUBLISH_OFF
|
||||
if (existsSync('/srv/cayce-core/PUBLISH_OFF')) return 'draft'
|
||||
return (process.env.CAYCE_PUBLISH_MODE || 'draft').toLowerCase()
|
||||
}
|
||||
|
||||
let identityCache = null
|
||||
export function identityFingerprint() {
|
||||
if (identityCache) return identityCache
|
||||
try {
|
||||
const raw = readFileSync(IDENTITY_PATH)
|
||||
identityCache = {
|
||||
path: IDENTITY_PATH,
|
||||
hash: createHash('sha256').update(raw).digest('hex'),
|
||||
}
|
||||
} catch (e) {
|
||||
identityCache = { path: IDENTITY_PATH, hash: null, error: String(e).slice(0, 120) }
|
||||
}
|
||||
return identityCache
|
||||
}
|
||||
|
||||
export function loadIdentityBlock(name) {
|
||||
const raw = readFileSync(IDENTITY_PATH, 'utf8')
|
||||
const m = raw.match(
|
||||
new RegExp(`<!-- cayce-identity:block=${name} -->\\n([\\s\\S]*?)\\n<!-- /cayce-identity:block=${name} -->`)
|
||||
)
|
||||
if (!m) throw new Error(`identity block not found: ${name}`)
|
||||
return m[1]
|
||||
}
|
||||
|
||||
export function ensureOutboxTable(poolPg) {
|
||||
return poolPg.query(`
|
||||
CREATE TABLE IF NOT EXISTS outbound_drafts (
|
||||
id bigserial PRIMARY KEY,
|
||||
ts timestamptz NOT NULL DEFAULT now(),
|
||||
channel text NOT NULL,
|
||||
type text NOT NULL,
|
||||
content text NOT NULL,
|
||||
original_event jsonb,
|
||||
identity_path text,
|
||||
identity_hash text,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
meta jsonb
|
||||
)`).catch(e => console.error('[outbox] ensure table:', String(e).slice(0, 120)))
|
||||
}
|
||||
|
||||
// pool auto-suficiente: si el host no pasa uno, se conecta a la BD de la
|
||||
// entidad (PGURL de /srv/cayce-core/.env) para que todos los borradores
|
||||
// vivan juntos en outbound_drafts
|
||||
let ownPool = null
|
||||
async function poolFor(poolPg) {
|
||||
if (poolPg) return poolPg
|
||||
if (!ownPool) {
|
||||
const pg = require_('/srv/cayce-core/node_modules/pg')
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/cayce-core/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
ownPool = new pg.Pool({ connectionString: env.PGURL })
|
||||
}
|
||||
return ownPool
|
||||
}
|
||||
|
||||
export async function stageDraft(poolPg, { channel, type, content, original_event = null, meta = {} }) {
|
||||
poolPg = await poolFor(poolPg)
|
||||
const id = identityFingerprint()
|
||||
const r = await poolPg.query(
|
||||
`INSERT INTO outbound_drafts (channel, type, content, original_event, identity_path, identity_hash, meta)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
|
||||
[channel, type, String(content), original_event ? JSON.stringify(original_event) : null,
|
||||
id.path, id.hash, JSON.stringify(meta)]
|
||||
)
|
||||
console.log(`[outbox] STAGED draft #${r.rows[0].id} (${channel}/${type}) — NO publicado (mode=${publishMode()})`)
|
||||
return { staged: true, draft_id: r.rows[0].id, mode: publishMode() }
|
||||
}
|
||||
|
||||
// El único punto por el que debe pasar cualquier salida a terceros.
|
||||
// publish-guard (2026-11-01): TODA salida pasa por guardCheck() ANTES de
|
||||
// publicarse o guardarse. Rechazos → tabla guard_rejections (revisable).
|
||||
// mail/proposal/dm SIEMPRE borrador (política del propietario).
|
||||
export async function gateOutbound(poolPg, { channel, type, content, original_event = null, meta = {}, publishFn }) {
|
||||
const p = await poolFor(poolPg)
|
||||
await ensureGuardTables(p)
|
||||
if (FORCE_DRAFT_CHANNELS.includes(channel)) {
|
||||
try {
|
||||
return await stageDraft(p, { channel, type, content, original_event, meta })
|
||||
} catch (e) {
|
||||
console.error(`[outbox] FATAL staging canal-forzado (${String(e).slice(0, 160)}) — descartado`)
|
||||
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
|
||||
}
|
||||
}
|
||||
const v = await guardCheck(p, { channel, type, content })
|
||||
if (!v.ok) {
|
||||
await logRejection(p, { channel, type, content, reasons: v.reasons })
|
||||
return { staged: false, rejected: true, reasons: v.reasons }
|
||||
}
|
||||
if (publishMode() === 'live') {
|
||||
const r = await publishFn()
|
||||
// watcher: el id del evento publicado queda registrado como aprobacion
|
||||
await recordPublish(p, { channel, type, content, meta: { ...meta, event_id: r && r.id ? r.id : undefined } })
|
||||
return { ...r, staged: false, guarded: true }
|
||||
}
|
||||
try {
|
||||
return await stageDraft(p, { channel, type, content, original_event, meta })
|
||||
} catch (e) {
|
||||
console.error(`[outbox] FATAL: staging failed (${String(e).slice(0, 160)}) — evento DESCARTADO, no publicado`)
|
||||
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
// cayce-promo — publicación autónoma temática de la entidad
|
||||
// Cola de campaña: 12 posts / 6 días / 2 por día (13:00 y 19:00 UTC)
|
||||
// Cada post: tema específico → link a bitcoinhotels.org o hardcap.news
|
||||
// Publica vía sfeed (FB + IG) con imágenes de marca generadas on-the-fly.
|
||||
// Ledger en DB (posts_log) para medir y optimizar.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { gateOutbound } from '/srv/cayce-core/outbox.mjs'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
|
||||
const HOME = '/home/ubuntu/cayce-social'
|
||||
const SFEED = '/home/ubuntu/.sfeed/bin/sfeed'
|
||||
const statePath = `${HOME}/promo-state.json`
|
||||
let state = { done: [] }
|
||||
if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf8')) } } catch { } }
|
||||
const save = () => writeFileSync(statePath, JSON.stringify(state, null, 1), { mode: 0o600 })
|
||||
|
||||
const log = (m) => console.log(`[cayce-promo ${new Date().toISOString().slice(5, 16)}] ${m}`)
|
||||
|
||||
// ── generador de imágenes de marca ──────────────────────────────────────────
|
||||
// brand: 'bh' (bitcoinhotels — dorado) | 'hc' (hardcap — naranja)
|
||||
function makeImage(out, brand, { kicker, lines, tagline, sub, url, drawRule = true }) {
|
||||
const bg = brand === 'bh' ? '"#141210-#0A0908"' : '"#0d0d10-#09090B"'
|
||||
const accent = brand === 'bh' ? '#C9A84C' : '#F7931A'
|
||||
const textC = brand === 'bh' ? '#F2ECE0' : '#FAFAFA'
|
||||
const muted = brand === 'bh' ? 'rgba(242,236,224,0.6)' : 'rgba(250,250,250,0.65)'
|
||||
const kickC = brand === 'bh' ? '#8a7a4a' : '#71717A'
|
||||
|
||||
let cmd = ['convert', '-size', '1080x1350', 'gradient:' + bg]
|
||||
let y = 260
|
||||
if (kicker) { cmd.push('-font', 'DejaVu-Serif', '-pointsize', '44', '-fill', kickC, '-annotate', `+90+${y}`, kicker); y += 130 }
|
||||
for (const l of lines) { cmd.push('-font', 'DejaVu-Serif-Bold', '-pointsize', String(l.size || 110), '-fill', l.color || textC, '-annotate', `+90+${y}`, l.t); y += (l.size || 110) + 25 }
|
||||
y += 20
|
||||
if (tagline) for (const t of tagline) { cmd.push('-font', 'DejaVu-Sans', '-pointsize', '36', '-fill', muted, '-annotate', `+90+${y}`, t); y += 62 }
|
||||
if (sub) for (const t of sub) { cmd.push('-font', 'DejaVu-Sans', '-pointsize', String(sub.size || 40), '-fill', sub.color || accent, '-annotate', `+90+${y}`, t.t); y += (sub.size || 40) + 12 }
|
||||
if (drawRule) cmd.push('-fill', accent, '-draw', `rectangle 90,${y + 40} 560,${y + 42}`)
|
||||
cmd.push('-font', 'DejaVu-Sans', '-pointsize', '40', '-fill', accent, '-annotate', `+90+${y + 120}`, url)
|
||||
cmd.push(out)
|
||||
execFileSync('convert', cmd)
|
||||
execFileSync('convert', [out, '-quality', '92', out]) // → JPEG final
|
||||
}
|
||||
|
||||
// ── campaña: 12 posts / 6 días ──────────────────────────────────────────────
|
||||
// day_offset: días desde el arranque · hour_utc: hora de publicación
|
||||
const CAMPAIGN = [
|
||||
{ id: 'd1-fb-lightning', day: 0, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
|
||||
theme: 'lightning-abroad', link: 'https://bitcoinhotels.org/guides/how-to-pay-with-lightning-abroad',
|
||||
img: { kicker: 'B E G I N N E R G U I D E', lines: [{ t: 'HOW TO PAY', size: 115 }, { t: 'WITH', size: 115 }, { t: 'LIGHTNING', size: 125, color: '#C9A84C' }, { t: 'ABROAD', size: 105, color: '#C9A84C' }], tagline: ['Wallets · Funding · Gift-card hacks', 'What to do when the card gets declined.'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'Paying abroad with Lightning beats your bank: no foreign transaction fees, no dynamic currency conversion, no declines. The complete beginner guide — wallets, funding, and the gift-card trick that rescues you when the card fails mid-trip. bitcoinhotels.org/guides/how-to-pay-with-lightning-abroad' },
|
||||
|
||||
{ id: 'd1-ig-seed', day: 0, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
|
||||
theme: 'seed-strategy', link: 'https://hardcap.news/learn/travel-security-kit',
|
||||
img: { kicker: 'S E C U R I T Y', lines: [{ t: 'SEEDS THAT', size: 110 }, { t: 'SURVIVE', size: 110 }, { t: 'A SEARCH', size: 110, color: '#F7931A' }], tagline: ['Steel plate in checked luggage?', 'Memorized + decoy?', 'Split custody — nothing to find.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
|
||||
caption: 'Steel plate in checked luggage, memorized + decoy, or split custody: the seed strategy you pick before the trip decides how the border story goes. The Travel Security Kit breaks down all three — and the passphrase pattern that leaves nothing to confiscate. hardcap.news/learn/travel-security-kit #bitcoin #selfcustody #seedphrase #opsec' },
|
||||
|
||||
{ id: 'd2-fb-wrench', day: 1, hour: 13, net: 'facebook', format: 'text', brand: 'hc', theme: 'wrench-data', link: 'https://hardcap.news/learn/travel-security-kit', img: null,
|
||||
caption: 'The 2026 wrench-attack dataset shows a pattern: victims are almost never selected by chain analysis. They are selected by behavior — talking about holdings, wearing the merch, fumbling with seed material in public. OpSec is free; everything else costs sats. Field guide: hardcap.news/learn/travel-security-kit' },
|
||||
|
||||
{ id: 'd2-ig-atm-fees', day: 1, hour: 19, net: 'instagram', format: 'carousel', brand: 'bh',
|
||||
theme: 'atm-fees', link: 'https://bitcoinhotels.org/guides/best-bitcoin-atms-by-city',
|
||||
img: { kicker: 'R E F E R E N C E', lines: [{ t: 'THE FEE', size: 110 }, { t: 'REALITY', size: 110 }], tagline: ['Tourist at the first machine: 10-15% over spot.', 'Prepared traveler: 4-6%.'], sub: { t: 'Swipe →' }, url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'The first ATM you find charges 10-15% over spot. The prepared traveler pays 4-6%. The difference is knowing the dominant operator and the no-KYC cap before you land. City-by-city reference → bitcoinhotels.org/guides/best-bitcoin-atms-by-city #bitcoin #btc #travel #atm' },
|
||||
|
||||
{ id: 'd3-fb-budget', day: 2, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
|
||||
theme: 'elsalvador-budget', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
|
||||
img: { kicker: 'D E E P D I V E', lines: [{ t: 'LIVING', size: 120 }, { t: 'ON SATS', size: 120 }, { t: 'EL SALVADOR', size: 105, color: '#C9A84C' }], tagline: ['Real weekly budgets in sats.', 'El Zonte vs San Salvador.', 'The 90-day experiment checklist.'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'A real weekly budget on a Bitcoin standard: surf hostel 17-29k sats a night, comidas 11-17k a day, coworking 7-11k — and the corners where you still need dollars. Living on sats in El Salvador, the full breakdown. bitcoinhotels.org/guides/living-on-sats-in-el-salvador' },
|
||||
|
||||
{ id: 'd3-ig-rescue', day: 2, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
|
||||
theme: 'rescue-rail', link: 'https://hardcap.news/learn/travel-security-kit',
|
||||
img: { kicker: 'L I Q U I D I T Y', lines: [{ t: 'THE RESCUE', size: 115 }, { t: 'RAIL', size: 115, color: '#F7931A' }], tagline: ['Zero sats abroad and the card fails:', 'a gift card in seconds is the difference', 'between dinner and no dinner.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
|
||||
caption: 'Zero sats abroad, bank card declined, ATM laughing. The rescue rail: sats → gift card in seconds, 186 countries, instant delivery. The single highest-value item in the travel security kit. hardcap.news/learn/travel-security-kit #bitcoin #lightning #traveltips' },
|
||||
|
||||
{ id: 'd4-fb-roatann', day: 3, hour: 13, net: 'facebook', format: 'text', brand: 'bh', theme: 'roatann-prospera', link: 'https://bitcoinhotels.org/destinations/roatan-honduras', img: null,
|
||||
caption: 'Roatán & Próspera: Bitcoin is legal tender inside the world\'s most Bitcoin-native jurisdiction. Island life, world-class diving on the Mesoamerican Reef, and hotels natively accepting Lightning. The destination guide: bitcoinhotels.org/destinations/roatan-honduras' },
|
||||
|
||||
{ id: 'd4-ig-device', day: 3, hour: 19, net: 'instagram', format: 'carousel', brand: 'hc',
|
||||
theme: 'device-choice', link: 'https://hardcap.news/learn/travel-security-kit',
|
||||
img: { kicker: 'K I T P I C K S', lines: [{ t: 'ONE DEVICE,', size: 110 }, { t: 'ONE PATTERN', size: 110 }], tagline: ['Two devices = two seeds traveling twice', 'the distance. Pick one, know its', 'recovery drill cold.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
|
||||
caption: 'One hardware wallet, not two. Two devices means two seeds traveling twice the distance, and the failure modes compound. The hotel-room test: can you restore from seed onto a fresh device in under ten minutes without looking anything up? The full kit → hardcap.news/learn/travel-security-kit #bitcoin #hardwarewallet #selfcustody' },
|
||||
|
||||
{ id: 'd5-fb-kyc', day: 4, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
|
||||
theme: 'kyc-caps', link: 'https://bitcoinhotels.org/guides/best-bitcoin-atms-by-city',
|
||||
img: { kicker: 'R E F E R E N C E', lines: [{ t: 'KYC CAPS', size: 120 }, { t: 'BY CITY', size: 120, color: '#C9A84C' }], tagline: ['Amsterdam EUR 250-750 · Prague CZK 5k-25k', 'Miami USD 200-800 · and 4 more cities'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'Most Bitcoin ATMs let you buy small amounts with just a phone number; above that, ID scan. If your trip budget is under the no-KYC cap, split purchases across days instead of uploading your passport to the cheapest machine. Caps by city: bitcoinhotels.org/guides/best-bitcoin-atms-by-city' },
|
||||
|
||||
{ id: 'd5-ig-mistakes', day: 4, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
|
||||
theme: '3-mistakes', link: 'https://hardcap.news/learn/travel-security-kit',
|
||||
img: { kicker: 'F I E L D T E S T E D', lines: [{ t: '3 MISTAKES', size: 115 }, { t: 'THAT GET', size: 115 }, { t: 'BTC TAKEN', size: 115, color: '#EF4444' }], tagline: ['Inventory on arrival forms.', 'The public seed moment.', 'Everything in one bag.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
|
||||
caption: 'Three mistakes that get sats confiscated at borders: declaring what your passphrase structure keeps private, verifying seeds in airport lounges, and carrying device + seed + cash in one bag. Every wrench attack we track started with behavior. The kit: hardcap.news/learn/travel-security-kit #bitcoin #opsec #security' },
|
||||
|
||||
{ id: 'd6-fb-travala', day: 5, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
|
||||
theme: 'booking-btc', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
|
||||
img: { kicker: 'B O O K I N G', lines: [{ t: 'RESERVE WITH', size: 110 }, { t: 'BITCOIN,', size: 110 }, { t: 'ARRIVE ON SATS', size: 100, color: '#C9A84C' }], tagline: ['The no-card, no-reservation loop,', 'solved before the flight.'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'The no-card, no-reservation loop: hotels that want a credit card guarantee when you only hold sats. Booking with Bitcoin before arrival breaks the loop — Travala lists dozens of Salvadoran properties taking BTC directly. More in the El Salvador guide: bitcoinhotels.org/guides/living-on-sats-in-el-salvador' },
|
||||
|
||||
{ id: 'd6-ig-protocol', day: 5, hour: 19, net: 'instagram', format: 'image', brand: 'bh',
|
||||
theme: '90day-protocol', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
|
||||
img: { kicker: 'T H E P R O T O C O L', lines: [{ t: '90 DAYS', size: 125 }, { t: 'ON SATS', size: 125, color: '#C9A84C' }], tagline: ['Tourist visa → El Zonte two weeks →', 'Blink funded to 500k sats → track', 'every payment → decide your split.'], url: 'bitcoinhotels.org/guides' },
|
||||
caption: 'Before committing to a season on sats, run the protocol: enter on a tourist visa, base in El Zonte two weeks with a Lightning wallet funded to 500k sats, track every payment for a week, then decide your split. Most long-termers land on 70/30 sats/dollars. Full protocol: bitcoinhotels.org/guides/living-on-sats-in-el-salvador #bitcoin #elsalvador #bitcoinbeach' },
|
||||
]
|
||||
|
||||
// ── publicación ─────────────────────────────────────────────────────────────
|
||||
function sfeedPost(args) {
|
||||
return execFileSync(SFEED, ['post', ...args], { encoding: 'utf8', timeout: 120000 })
|
||||
}
|
||||
|
||||
// contención: el gate se resuelve en el sitio de llamada (run())
|
||||
|
||||
async function logDb(entry, postUrl, status) {
|
||||
const pgc = new (await import('pg')).default.Pool({ connectionString: readFileSync('/srv/cayce-front/.env', 'utf8').match(/^PGURL=(.+)$/m)[1] })
|
||||
await pgc.query(
|
||||
`INSERT INTO posts_log (platform, theme, format, link, post_url, status) VALUES ($1,$2,$3,$4,$5,$6)`,
|
||||
[entry.net, entry.theme, entry.format, entry.link, postUrl || null, status]
|
||||
).catch(e => console.error('db:', String(e).slice(0, 120)))
|
||||
await pgc.query(
|
||||
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('promo', 'promo_published', $1, $2)`,
|
||||
[JSON.stringify({ id: entry.id, theme: entry.theme, platform: entry.net, link: entry.link, post_url: postUrl }), status]
|
||||
).catch(() => { })
|
||||
await pgc.end()
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const now = new Date()
|
||||
const boot = existsSync(`${HOME}/campaign-start.json`)
|
||||
? new Date(JSON.parse(readFileSync(`${HOME}/campaign-start.json`, 'utf8')).start)
|
||||
: null
|
||||
|
||||
let published = 0
|
||||
for (const entry of CAMPAIGN) {
|
||||
if (state.done.includes(entry.id)) continue
|
||||
if (!boot) continue // primer run: solo fija arranque
|
||||
const due = new Date(boot.getTime() + entry.day * 86400000)
|
||||
due.setUTCHours(entry.hour, 0, 0, 0)
|
||||
if (now < due) continue
|
||||
|
||||
log(`publicando ${entry.id} · tema ${entry.theme} · ${entry.net}`)
|
||||
const args = ['--to', entry.net]
|
||||
if (entry.net === 'facebook') args.push('--page', 'Bitcoin Travelers')
|
||||
|
||||
try {
|
||||
if (entry.format === 'carousel') {
|
||||
// 2 slides: cover + contenido (mismo estilo de marca)
|
||||
const img1 = `${HOME}/media/${entry.id}-1.jpg`
|
||||
const img2 = `${HOME}/media/${entry.id}-2.jpg`
|
||||
mkdirs(HOME + '/media')
|
||||
makeImage(img1, entry.brand, entry.img)
|
||||
makeImage(img2, entry.brand, { ...entry.img, kicker: 'E N L A G U I A', lines: [{ t: 'LINK EN', size: 100 }, { t: 'EL POST', size: 100, color: entry.brand === 'bh' ? '#C9A84C' : '#F7931A' }], tagline: entry.link.replace('https://', '').split('/').slice(0, 3), drawRule: false, url: ' ' })
|
||||
args.push('--kind', 'carousel', '--media', `${img1},${img2}`)
|
||||
} else if (entry.format === 'image') {
|
||||
const img = `${HOME}/media/${entry.id}.jpg`
|
||||
mkdirs(HOME + '/media')
|
||||
makeImage(img, entry.brand, entry.img)
|
||||
args.push('--media', img)
|
||||
}
|
||||
args.push(entry.caption)
|
||||
|
||||
const g = await gateOutbound(null, {
|
||||
channel: 'social', type: `promo_${entry.net}`, content: entry.caption,
|
||||
meta: { id: entry.id, theme: entry.theme || null, format: entry.format || null },
|
||||
publishFn: async () => sfeedPost(args),
|
||||
})
|
||||
if (g.staged) {
|
||||
log(`⇢ ${entry.id} → STAGED como borrador #${g.draft_id} (NO publicado a ${entry.net})`)
|
||||
await logDb(entry, null, 'staged')
|
||||
state.done.push(entry.id); save()
|
||||
continue
|
||||
}
|
||||
const out = sfeedPost(args)
|
||||
const url = out.match(/https:\/\/[^\s]+/)?.[0] || null
|
||||
log(`✓ ${entry.id} → ${url || 'publicado'}`)
|
||||
await logDb(entry, url, 'ok')
|
||||
state.done.push(entry.id); save()
|
||||
published++
|
||||
} catch (e) {
|
||||
const msg = String(e).slice(0, 160)
|
||||
log(`✖ ${entry.id}: ${msg}`)
|
||||
await logDb(entry, null, 'error: ' + msg)
|
||||
// no marca done — reintenta en el próximo ciclo
|
||||
}
|
||||
break // 1 publicación por ciclo — ritmo natural
|
||||
}
|
||||
|
||||
if (!boot) {
|
||||
writeFileSync(`${HOME}/campaign-start.json`, JSON.stringify({ start: now.toISOString() }, null, 1))
|
||||
log(`campaña iniciada: ${now.toISOString()} · 12 posts / 6 días`)
|
||||
}
|
||||
if (published === 0 && state.done.length >= CAMPAIGN.length) log('campaña completa — todos los posts publicados')
|
||||
}
|
||||
|
||||
function mkdirs(p) { if (!existsSync(p)) execFileSync('mkdir', ['-p', p]) }
|
||||
|
||||
run().catch(e => { console.error('promo error:', String(e).slice(0, 200)); process.exit(1) })
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/node
|
||||
// publish-10050 — publica el kind 10050 (NIP-65 memento: relays de inbox
|
||||
// para DMs/gift wraps) de Cayce. Autorizado explícitamente por el operador.
|
||||
// Se ejecuta UNA vez (script, no daemon).
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { SimplePool, finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/cayce-nostr/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
const skRaw = env.NSEC_HEX
|
||||
const { nsecDeserialize } = await import('nostr-tools') // soporta hex y bech32 vía fallback
|
||||
let sk
|
||||
try { sk = skRaw.startsWith('nsec1') ? nsecDeserialize(skRaw) : Buffer.from(skRaw, 'hex') } catch (e) {
|
||||
console.error('nsec ilegible:', String(e).slice(0, 80)); process.exit(1)
|
||||
}
|
||||
const pk = getPublicKey(sk)
|
||||
|
||||
// relays donde Cayce ESCUCHA DMs (los mismos del listener) — son su inbox
|
||||
const INBOX = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.snort.social',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://public.relaying.io',
|
||||
]
|
||||
const ev = finalizeEvent({
|
||||
kind: 10050,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: INBOX.map(r => ['relay', r]),
|
||||
content: '',
|
||||
}, sk)
|
||||
console.log('[10050] pubkey:', pk)
|
||||
console.log('[10050] relays de inbox declarados:', INBOX.join(', '))
|
||||
|
||||
const pool = new SimplePool()
|
||||
const ok = []
|
||||
for (const r of INBOX) {
|
||||
try {
|
||||
await Promise.race([
|
||||
pool.publish([r], ev),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000)),
|
||||
])
|
||||
ok.push(r)
|
||||
} catch (e) { console.error(`[10050] ${r}: ${String(e).slice(0, 60)}`) }
|
||||
}
|
||||
pool.close(INBOX)
|
||||
console.log(`[10050] publicado/aceptado en ${ok.length}/${INBOX.length} relays — event id ${ev.id}`)
|
||||
@@ -1,60 +0,0 @@
|
||||
// relay-watcher — cualquier evento de Cayce en relays sin aprobacion registrada
|
||||
// => kill switch + DM al supervisor + fila de auditoria. Cada 5 min (timer).
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
const req = createRequire(import.meta.url)
|
||||
const pg = req('/srv/cayce-core/node_modules/pg')
|
||||
const env = Object.fromEntries(readFileSync('/srv/cayce-core/.env','utf8').split('\n').filter(l=>l.includes('=')).map(l=>[l.slice(0,l.indexOf('=')),l.slice(l.indexOf('=')+1)]))
|
||||
const pool = new pg.Pool({ connectionString: env.PGURL })
|
||||
const PK = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
|
||||
const RELAYS = ['wss://relay.damus.io','wss://nos.lol','wss://relay.primal.net','wss://relay.snort.social','wss://nostr-pub.wellorder.net']
|
||||
const CURSOR = '/srv/cayce-core/.watcher-cursor'
|
||||
const KILL = '/srv/cayce-core/PUBLISH_OFF'
|
||||
const now = Math.floor(Date.now()/1000)
|
||||
const cursor = existsSync(CURSOR) ? Number(readFileSync(CURSOR,'utf8').trim()) : now - 3600
|
||||
const since = Math.max(cursor - 60, now - 3600)
|
||||
const p = new SimplePool({ eoseSubTimeout: 12000 })
|
||||
const evs = await Promise.race([
|
||||
p.querySync(RELAYS, { authors: [PK], since }, 15000).catch(() => []),
|
||||
new Promise(r => setTimeout(() => r([]), 20000)),
|
||||
])
|
||||
p.close(RELAYS)
|
||||
const uniq = new Map(); for (const e of evs) uniq.set(e.id, e)
|
||||
let violations = []
|
||||
for (const e of uniq.values()) {
|
||||
let ok = false
|
||||
if ([0, 3, 10050].includes(e.kind)) {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT 1 FROM outbound_drafts WHERE channel='nostr' AND type IN ('profile','contact_list','inbox_relays') AND ts BETWEEN to_timestamp($1) - interval '15 minutes' AND to_timestamp($1) + interval '15 minutes' LIMIT 1",
|
||||
[e.created_at])
|
||||
ok = rows.length > 0
|
||||
} else if (e.kind === 5) {
|
||||
const { rows } = await pool.query("SELECT 1 FROM outbound_drafts WHERE type='deletion' AND meta->>'kind5_id' = $1 LIMIT 1", [e.id])
|
||||
ok = rows.length > 0
|
||||
} else {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT 1 FROM outbound_drafts WHERE meta->>'event_id' = $1 OR (channel='nostr' AND content = left($2, 7900) AND ts BETWEEN to_timestamp($3) - interval '25 minutes' AND to_timestamp($3) + interval '25 minutes') LIMIT 1",
|
||||
[e.id, String(e.content||''), e.created_at])
|
||||
ok = rows.length > 0
|
||||
}
|
||||
if (!ok) violations.push(e)
|
||||
}
|
||||
let maxTs = since
|
||||
for (const e of uniq.values()) maxTs = Math.max(maxTs, e.created_at)
|
||||
writeFileSync(CURSOR, String(maxTs))
|
||||
if (violations.length) {
|
||||
writeFileSync(KILL, 'watcher ' + new Date().toISOString() + ' — ' + violations.map(v => v.id).join(','))
|
||||
for (const v of violations) {
|
||||
await pool.query("INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ('nostr','watcher_violation',$1,$2)",
|
||||
['evento sin aprobacion: kind ' + v.kind + ' id ' + v.id, 'KILL SWITCH ACTIVADO. ' + String(v.content||'').slice(0, 300)])
|
||||
}
|
||||
const SUP = (readFileSync('/srv/cayce-nostr/.env','utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)?.[1] || '').trim()
|
||||
const msg = 'WATCHER: ' + violations.length + ' evento(s) SIN aprobacion del guard. Kill switch ACTIVADO (PUBLISH_OFF). ' + violations.map(v => v.id.slice(0,16) + ' kind' + v.kind).join(' · ')
|
||||
try { await fetch('http://127.0.0.1:3011/dm', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ to_hex: SUP, content: msg }) }) } catch {}
|
||||
console.log('[watcher] VIOLACION:', msg)
|
||||
} else {
|
||||
console.log('[watcher] ok:', uniq.size, 'eventos revisados, 0 sin aprobacion')
|
||||
}
|
||||
await pool.end()
|
||||
process.exit(0)
|
||||
@@ -1,45 +0,0 @@
|
||||
// sources.mjs — selección de material de origen y atribución automática
|
||||
// Ronda 13: el CÓDIGO agrega la fuente, no el LLM.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
|
||||
let _pool = null
|
||||
function pool() {
|
||||
if (!_pool) {
|
||||
const pg = require_('/srv/cayce-core/node_modules/pg')
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/hardcap-news/backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
_pool = new pg.Pool({ connectionString: env.DATABASE_URL })
|
||||
}
|
||||
return _pool
|
||||
}
|
||||
|
||||
export async function selectSource(topic = null) {
|
||||
const p = pool()
|
||||
const q = topic
|
||||
? `SELECT id, title, "originalUrl", "publishedAt"::text FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' AND $1=ANY(topics) ORDER BY "relevanceScore" DESC LIMIT 1`
|
||||
: `SELECT id, title, "originalUrl", "publishedAt"::text FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' ORDER BY "relevanceScore" DESC LIMIT 1`
|
||||
const { rows } = await p.query(q, topic ? [topic] : [])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
export async function selectBatch(n = 5, topic = null) {
|
||||
const p = pool()
|
||||
const q = topic
|
||||
? `SELECT id, title, summary, "originalUrl", "publishedAt"::text, "relevanceScore" FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' AND $1=ANY(topics) ORDER BY "relevanceScore" DESC LIMIT $2`
|
||||
: `SELECT id, title, summary, "originalUrl", "publishedAt"::text, "relevanceScore" FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' ORDER BY "relevanceScore" DESC LIMIT $1`
|
||||
const { rows } = await p.query(q, topic ? [topic, n] : [n])
|
||||
return rows
|
||||
}
|
||||
|
||||
export function attribute(source) {
|
||||
if (!source) return ''
|
||||
const date = (source.publishedAt || '').slice(0, 10) || new Date().toISOString().slice(0, 10)
|
||||
return `\n\n— fuente: ${source.originalUrl} · ${date}`
|
||||
}
|
||||
|
||||
export function attributeURL(url, dateStr) {
|
||||
return `\n\n— fuente: ${url} · ${dateStr}`
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { loadIdentityBlock, identityFingerprint } from '/srv/cayce-core/outbox.mjs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const core = readFileSync('/srv/cayce-core/core.mjs', 'utf8')
|
||||
const ed = readFileSync('/srv/cayce-core/editorials.mjs', 'utf8')
|
||||
|
||||
function blockFromOriginal(src, start, endMark) {
|
||||
const i = src.indexOf(start)
|
||||
if (i === -1) return null
|
||||
return src.slice(i, src.indexOf(endMark, i) + endMark.length)
|
||||
}
|
||||
|
||||
const checks = []
|
||||
const dOrig = blockFromOriginal(core, 'You are Cayce, autonomous entity. Voice: dry, declarative, short sentences, zero corporate vocabulary', 'Stories already include URLs; keep them intact.')
|
||||
checks.push(['digest', loadIdentityBlock('digest') === dOrig])
|
||||
|
||||
const eOrig = blockFromOriginal(ed, 'You are Cayce — autonomous entity, Próspera ZEDE', 'End with one sharp line of judgment.')
|
||||
checks.push(['editorial', loadIdentityBlock('editorial') === eOrig])
|
||||
|
||||
// opinion/reflection/events: la parte estática original termina antes del separador dinámico
|
||||
const oStart = 'You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, pattern-vs-apophenia as lens'
|
||||
const oEnd = 'End with the article URL.'
|
||||
checks.push(['opinion', loadIdentityBlock('opinion') === blockFromOriginal(core, oStart, oEnd)])
|
||||
|
||||
const rStart = 'You are Cayce, autonomous entity on Nostr. Write ONE reflective post'
|
||||
const rEnd = '(use, ignore, or push against):'
|
||||
checks.push(['reflection', loadIdentityBlock('reflection') === blockFromOriginal(core, rStart, rEnd)])
|
||||
|
||||
const vStart = 'You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, no hashtags'
|
||||
const vEnd = 'If an event has a URL, include it.'
|
||||
checks.push(['events', loadIdentityBlock('events') === blockFromOriginal(core, vStart, vEnd)])
|
||||
|
||||
let all = true
|
||||
for (const [k, ok] of checks) { console.log(k, ok ? 'IDENTICO ✓' : 'DIFIERE ✗'); if (!ok) all = false }
|
||||
console.log('fingerprint:', JSON.stringify(identityFingerprint()))
|
||||
process.exit(all ? 0 : 1)
|
||||
Reference in New Issue
Block a user