// tweet.js — auto-post on-chain payout "proof" to X/Twitter (@cryptoteambuild) // via Blotato. Fires on each NEW team payout the indexer emits. // // Safety by design: // - OFF unless config.tweetEnabled === true (or env TWEET_ON_PAYOUT=1). Ships off. // - Dedupes by payout tx key (persisted) so a restart/re-scan never re-posts. // - Drains a queue with a min gap so a burst of payments can't exceed Blotato's // 30-requests/min limit (single payments still fire right away). // - Never logs the API key. const fs = require('fs'); const path = require('path'); const suiteTools = require('./suite-tools'); const BLOTATO_URL = 'https://backend.blotato.com/v2/posts'; const MIN_GAP_MS = 2600; // ~23/min ceiling, under Blotato's 30/min const DATA_DIR = process.env.DATA_DIR || '.'; const TWEETED_FILE = path.join(DATA_DIR, 'tweeted-payouts.json'); const DEFAULT_CTA = 'https://rmcircle.team/?utm_source=x'; // new primary domain; short (Blotato counts raw URL length toward 280) const DEFAULT_TAGS = '#RMCircle #Polygon #SmartContract #Crypto #Community'; const DEFAULT_TWITTER_ID = '7998'; // @cryptoteambuild in Blotato function readKey() { if (process.env.BLOTATO_API_KEY) return process.env.BLOTATO_API_KEY.trim(); for (const p of [path.join(DATA_DIR, 'blotato.key'), path.join(DATA_DIR, '.blotato-key')]) { try { const k = fs.readFileSync(p, 'utf8').trim(); if (k) return k; } catch (e) {} } return null; } let tweeted = new Set(); try { tweeted = new Set(JSON.parse(fs.readFileSync(TWEETED_FILE, 'utf8'))); } catch (e) {} function remember(key) { tweeted.add(key); try { fs.writeFileSync(TWEETED_FILE, JSON.stringify([...tweeted].slice(-3000))); } catch (e) {} } function fmtPol(n) { const v = Number(n) || 0; const s = v >= 100 ? v.toFixed(1) : v.toFixed(2); const [int, dec] = s.split('.'); const withSep = Number(int).toLocaleString('en-US'); return dec && dec !== '0' ? `${withSep}.${dec}` : withSep; } // Deterministic variety (no RNG): pick a template by recipient+timestamp so the // same payout always renders the same text, but consecutive payouts differ — // which also keeps X from rejecting near-identical posts. function render(evt, cfg) { const cta = (cfg && cfg.tweetCtaUrl) || DEFAULT_CTA; const tags = (cfg && cfg.tweetHashtags) || DEFAULT_TAGS; const pol = fmtPol(evt.pol); const seed = (Number(evt.toId) || 0) + Math.floor(Number(evt.ts) || 0); const pick = arr => arr[seed % arr.length]; let body; if (evt.kind === 'upline') { const u = evt.upgrade; const lvl = (u && (u.levelName)) || evt.levelName; const up = (u && u.id && lvl) ? `Member #${u.id} upgraded to ${lvl} and ` : ''; // What that upgrade unlocked in the Circle Suite — only when the tools are // live AND config.suiteToolsInAlerts is on (see suite-tools.js). const tool = suiteTools.unlockTerse((u && u.level), lvl, cfg); if (tool) { body = pick([ `🚀 Level up! ${up}${pol} POL landed on Member #${evt.toId}'s position — and that upgrade unlocked ${tool}. 💎`, `⚡ ${up}Member #${evt.toId} received ${pol} POL straight from the contract — the upgrade also opened up ${tool}. 🔗`, `🔥 ${up}${pol} POL paid up to Member #${evt.toId} on Polygon — plus ${tool} unlocked with the level. 🚀`, ]); const headT = `${body}\n\nSee how it works 👉 ${cta}`; let outT = `${headT}\n\n${tags}`; if (outT.length > 280) outT = headT; if (outT.length > 280) outT = headT.slice(0, 279) + '…'; return outT; } body = pick([ `🚀 Level up! ${up}${pol} POL just landed on Member #${evt.toId}'s position — instant, on-chain, unstoppable. 💎`, `⚡ Another upgrade paid! ${up}Member #${evt.toId} received ${pol} POL straight from the smart contract — no middleman. 🔗`, `🔥 ${up}${pol} POL paid up to Member #${evt.toId} — automatically, on Polygon, verifiable by anyone. 🚀`, ]); } else { body = pick([ `💸 Boom! Member #${evt.fromId} just joined and Member #${evt.toId} instantly earned ${pol} POL — paid on-chain, verifiable by anyone. ⚡`, `🎉 New teammate! Member #${evt.fromId} came aboard and Member #${evt.toId} banked ${pol} POL the same moment. 🔗`, `💰 Cha-ching! Member #${evt.fromId} joined the team and Member #${evt.toId} earned ${pol} POL on-chain — instantly. 🚀`, ]); } const head = `${body}\n\nSee how it works 👉 ${cta}`; // Hard 280 guard (Blotato counts raw string length, no t.co shortening): // keep body + CTA, drop hashtags first, then trim the body as a last resort. let out = `${head}\n\n${tags}`; if (out.length > 280) out = head; if (out.length > 280) out = head.slice(0, 279) + '…'; return out; } const queue = []; let draining = false; async function drain() { if (draining) return; draining = true; while (queue.length) { const job = queue.shift(); try { await postNow(job); } catch (e) { console.error('tweet post error', e.message); } if (queue.length) await new Promise(r => setTimeout(r, MIN_GAP_MS)); } draining = false; } async function postNow(job) { if (job.dryRun) { console.log('tweet DRY-RUN →', job.text.replace(/\n/g, ' ⏎ ')); return; } const key = readKey(); if (!key) { console.error('tweet: no Blotato API key available — skipping'); return; } const body = { post: { accountId: String(job.accountId), content: { text: job.text, mediaUrls: [], platform: 'twitter' }, target: { targetType: 'twitter' } } }; const r = await fetch(BLOTATO_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'blotato-api-key': key }, body: JSON.stringify(body) }); const txt = await r.text().catch(() => ''); if (!r.ok) console.error('tweet: blotato', r.status, txt.slice(0, 300)); else console.log('tweet posted → @cryptoteambuild:', job.text.slice(0, 70).replace(/\n/g, ' ')); } function enabled(cfg) { return process.env.TWEET_ON_PAYOUT === '1' || !!(cfg && cfg.tweetEnabled === true); } // Public: hand a payout event to the tweeter. No-op unless enabled. function queuePayoutTweet(evt, cfg) { try { if (!enabled(cfg)) return; if (!evt || evt.type !== 'payout') return; const key = `${evt.tx || evt.ts}:${evt.toId}:${evt.kind}`; if (tweeted.has(key)) return; remember(key); const accountId = (cfg && cfg.blotatoTwitterId) || process.env.BLOTATO_TWITTER_ID || DEFAULT_TWITTER_ID; queue.push({ text: render(evt, cfg), accountId, dryRun: process.env.TWEET_DRY_RUN === '1' }); drain(); } catch (e) { console.error('queuePayoutTweet error', e.message); } } // Public: post one arbitrary text now (used by the admin test-tweet button). async function postText(text, cfg) { const accountId = (cfg && cfg.blotatoTwitterId) || process.env.BLOTATO_TWITTER_ID || DEFAULT_TWITTER_ID; return postNow({ text, accountId, dryRun: false }); } module.exports = { queuePayoutTweet, postText, render, fmtPol, readKey, enabled };