Add auto-tweet of on-chain payout proof to @cryptoteambuild (Blotato)

New tweet.js posts a tweet to X via Blotato each time the indexer emits
a NEW team payout. Org-gated to the #21 organization; fires on all
payout types. Ships DISABLED (config.tweetEnabled default off / env
TWEET_ON_PAYOUT), so nothing posts until explicitly turned on.

Safety: dedupes by tx key (persisted to DATA_DIR/tweeted-payouts.json)
so restarts never re-post; a min-gap queue keeps bursts under Blotato's
30/min limit; API key read from env BLOTATO_API_KEY or DATA_DIR key
file, never committed or logged. Varied emoji/hashtag templates with a
CTA (+UTM) to the main page; deterministic template pick avoids X
duplicate-content rejection. Admin config allowlist gains tweetEnabled,
tweetCtaUrl, tweetHashtags, blotatoTwitterId.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-15 11:35:05 -05:00
parent d36a405581
commit c418aa516a
2 changed files with 131 additions and 1 deletions
+10 -1
View File
@@ -4,6 +4,7 @@ const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { URL } = require('url'); const { URL } = require('url');
const chain = require('./chain'); const chain = require('./chain');
const tweet = require('./tweet');
const PORT = Number(process.env.PORT || 3000); const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname; const ROOT = __dirname;
@@ -489,7 +490,7 @@ async function handleApi(req,res,pathname){
const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors}); const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors});
} }
if(req.method==='PATCH'&&pathname==='/api/admin/config'){ if(req.method==='PATCH'&&pathname==='/api/admin/config'){
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next}); const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId','tweetEnabled','tweetCtaUrl','tweetHashtags','blotatoTwitterId'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
} }
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/); const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/);
if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'}); if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});
@@ -640,6 +641,14 @@ chain.startIndexer(evt=>{
if(rec&&rec.email&&!sent.has(rec.email.toLowerCase()))sendPaidEmail(rec.email,'there',evt,unsubUrl(evt.toId)); if(rec&&rec.email&&!sent.has(rec.email.toLowerCase()))sendPaidEmail(rec.email,'there',evt,unsubUrl(evt.toId));
} }
}catch(e){console.error('paid email error',e.message)} }catch(e){console.error('paid email error',e.message)}
// Auto-tweet on-chain payout proof to @cryptoteambuild via Blotato (Marty
// 2026-08-15). Org-gated to the #21 organization; OFF unless config.tweetEnabled.
try{
if(evt.type==='payout'){
const orgRoot=Number(getConfig().orgRootId)||21;
if(chain.isInTeam(evt.toId,orgRoot))tweet.queuePayoutTweet(evt,getConfig());
}
}catch(e){console.error('tweet hook error',e.message)}
// Auto-count directs + AUTO-ADVANCE (Marty 2026-08-15): a new registration // Auto-count directs + AUTO-ADVANCE (Marty 2026-08-15): a new registration
// whose referrer sits in the rotation queue increments that sponsor's directs. // whose referrer sits in the rotation queue increments that sponsor's directs.
// When it reaches 2/2 the sponsor is auto-qualified and the next waiting // When it reaches 2/2 the sponsor is auto-qualified and the next waiting
+121
View File
@@ -0,0 +1,121 @@
// 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 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.saasy.top/?utm_source=twitter&utm_medium=social&utm_campaign=payproof';
const DEFAULT_TAGS = '#Crypto #Polygon #POL #Web3 #CryptoTeamBuild';
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 ` : '';
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! A new teammate joined and Member #${evt.toId} instantly earned ${pol} POL — paid on-chain, verifiable by anyone. ⚡`,
`🎉 New teammate, instant reward! Member #${evt.toId} just banked ${pol} POL the moment someone joined under them. 🔗`,
`💰 Cha-ching! Member #${evt.toId} earned ${pol} POL on-chain the second a new member came aboard — no waiting. 🚀`,
]);
}
return `${body}\n\nBuild with us 👉 ${cta}\n\n${tags}`;
}
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 };