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