Files
cayce-ops/runtime/promo.mjs
cayce b81f7f3625
ci / lint-test-leaks (pull_request) Canceled after 0s
containment: every third-party send becomes a draft (outbox), identity file versioned
Problem: Cayce publishes straight to relays with no approval gate —
cayce-initiative publish_note, core.mjs digest/opinion/reflection/events,
mention auto-replies, social promo (Facebook/Instagram via sfeed), and
mail — while the operator believed approvals existed (they never did;
ADR-0005 Telegram queue was never implemented).

Mechanism (outbox.mjs, deployed to all runtimes):
- CAYCE_PUBLISH_MODE=draft (default): gateOutbound() stages EVERY outbound
  artifact into outbound_drafts (entity DB) with identity_path +
  identity_hash (sha256 of prompts/cayce-identity.md) before anything
  else happens. Fail-closed: if staging fails, the event is discarded,
  never published.
- CAYCE_PUBLISH_MODE=live: original behavior, unchanged (reversible with
  one line per service .env).
- Chokepoint: the nostr daemon /publish endpoint gates ALL callers
  (initiative publish_note, core.mjs ×4, banner kind 0); the daemon own
  mention-reply path gates too, storing original_event for context;
  startup profile publish gates as well. DM-to-supervisor (/dm) is
  internal and stays live.

Identity (point 8b):
- prompts/cayce-identity.md: the 5 hardcoded voice blocks extracted
  VERBATIM (verified byte-identical by runtime test) with block markers.
- core.mjs / editorials.mjs now load their voice blocks from this file
  (loadIdentityBlock); identity hash recorded on every draft.
- runtime/ holds the versioned copies of the previously-unversioned
  runtime scripts + deploy.sh installer.

No timers enabled or disabled. No production data modified.
2026-10-31 16:44:57 +00:00

193 lines
16 KiB
JavaScript

// 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 './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) })