From c418aa516ae9c7ce708b9caf39822afbcca29da2 Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 15 Aug 2026 11:35:05 -0500 Subject: [PATCH] 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 --- server.js | 11 ++++- tweet.js | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tweet.js diff --git a/server.js b/server.js index d555d50..2512b5f 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ const path = require('path'); const crypto = require('crypto'); const { URL } = require('url'); const chain = require('./chain'); +const tweet = require('./tweet'); const PORT = Number(process.env.PORT || 3000); 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}); } 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))?$/); 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)); } }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 // 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 diff --git a/tweet.js b/tweet.js new file mode 100644 index 0000000..41a8862 --- /dev/null +++ b/tweet.js @@ -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 };