Post each on-chain event once per feed, permanently
Marty is still seeing duplicate payment lines, and the instrumented log finally showed enough to act on rather than keep hunting. Two things were wrong. The dedupe I added earlier keyed on message TEXT within 15 minutes, which fails in both directions. The log caught it swallowing a legitimate post: two different registrations generate identical "the team just grew" copy, so the second one was suppressed as a duplicate. Meanwhile anything carrying a different transaction link sails straight past it. And it only ever lived in memory, so it could not help across a restart or across the moment during a deploy when two containers are both alive and both tailing the chain. The key is now the on-chain event itself — type, transaction, the member ids, the amount — recorded per destination chat in a file on the volume, written before the request goes out. Same event, same feed, one post, no matter how many times anything calls it or how many processes are running. Distinct events are never confused, because the transaction hash is in the key. All four feeds carry it: team-build topic, recruit topic, proof channel, shared payments topic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,9 +157,9 @@ function submitRateLimited(ip) {
|
||||
// Post to the team Telegram. topicId overrides the default team-build topic
|
||||
// (config.telegramTopicId) — used to fan the same event out to a second forum
|
||||
// topic (e.g. the recruiting/new-members topic) with different copy.
|
||||
function sendTelegram(text, topicId, replyMarkup) {
|
||||
function sendTelegram(text, topicId, replyMarkup, dedupeKey) {
|
||||
const c = getConfig();
|
||||
return sendTelegramTo(c.telegramChatId, text, topicId != null ? topicId : c.telegramTopicId, replyMarkup);
|
||||
return sendTelegramTo(c.telegramChatId, text, topicId != null ? topicId : c.telegramTopicId, replyMarkup, undefined, dedupeKey);
|
||||
}
|
||||
// Same bot, any chat: the team forum, or the public payment-proof channel
|
||||
// (config.telegramProofChatId — the bot just has to be an admin there).
|
||||
@@ -173,24 +173,47 @@ function sendTelegram(text, topicId, replyMarkup) {
|
||||
// members and make them doubt the ledger. So the sender itself refuses to post the same text
|
||||
// to the same chat twice within the window. Distinct events never collide — every line
|
||||
// carries its own ids, amount and transaction hash.
|
||||
const TG_RECENT = new Map();
|
||||
// random per-process id: if two instances are posting, their sends carry different ids
|
||||
const TG_INSTANCE = Math.random().toString(36).slice(2, 8);
|
||||
const TG_DEDUPE_MS = 15 * 60 * 1000;
|
||||
function tgSeenRecently(chatId, topicId, text) {
|
||||
const k = String(chatId) + '|' + String(topicId || '') + '|' + text;
|
||||
const now = Date.now();
|
||||
if (TG_RECENT.size > 500) for (const [kk, ts] of TG_RECENT) if (now - ts > TG_DEDUPE_MS) TG_RECENT.delete(kk);
|
||||
const prev = TG_RECENT.get(k);
|
||||
if (prev && now - prev < TG_DEDUPE_MS) return true;
|
||||
TG_RECENT.set(k, now);
|
||||
|
||||
// Post each EVENT once per chat — permanently, and across processes.
|
||||
//
|
||||
// The first attempt at this deduped on message TEXT within 15 minutes, which was wrong in
|
||||
// both directions: two different registrations produce identical "the team just grew" copy,
|
||||
// so a legitimate post got swallowed, while anything with a different tx link slipped past.
|
||||
//
|
||||
// The key is now the on-chain event itself. It lives in a file on the volume, written before
|
||||
// the request goes out, so a replay, a restart mid-tick, or two containers overlapping during
|
||||
// a deploy all collapse to a single post. A payment feed that repeats itself makes members
|
||||
// doubt the ledger, so this is deliberately belt-and-braces rather than clever.
|
||||
const TG_POSTED_FILE = path.join(DATA_DIR, 'tg-posted.json');
|
||||
function tgAlreadyPosted(chatId, key) {
|
||||
if (!key) return false;
|
||||
const k = String(chatId) + '|' + key;
|
||||
let rec = {};
|
||||
try { rec = JSON.parse(fs.readFileSync(TG_POSTED_FILE, 'utf8')) || {}; } catch (e) { rec = {}; }
|
||||
if (rec[k]) return true;
|
||||
rec[k] = Date.now();
|
||||
const keys = Object.keys(rec);
|
||||
if (keys.length > 5000) {
|
||||
keys.sort((a, b) => rec[a] - rec[b]).slice(0, keys.length - 4000).forEach(x => delete rec[x]);
|
||||
}
|
||||
try {
|
||||
const tmp = TG_POSTED_FILE + '.tmp';
|
||||
fs.writeFileSync(tmp, JSON.stringify(rec));
|
||||
fs.renameSync(tmp, TG_POSTED_FILE);
|
||||
} catch (e) { console.error('tg-posted write failed', e.message); }
|
||||
return false;
|
||||
}
|
||||
function sendTelegramTo(chatId, text, topicId, replyMarkup, parseMode) {
|
||||
// a stable id for one on-chain event, so the same event never posts to the same chat twice
|
||||
function tgEventKey(evt) {
|
||||
if (!evt || !evt.tx) return null;
|
||||
return evt.type + ':' + evt.tx + ':' + (evt.toId || evt.id || '') + ':' + (evt.fromId || '') + ':' + (evt.level || evt.pol || '');
|
||||
}
|
||||
function sendTelegramTo(chatId, text, topicId, replyMarkup, parseMode, dedupeKey) {
|
||||
const c = getConfig();
|
||||
if (!c.telegramBotToken || !chatId) return;
|
||||
if (tgSeenRecently(chatId, topicId, text)) {
|
||||
console.warn('telegram duplicate suppressed:', String(text).replace(/\s+/g, ' ').slice(0, 90));
|
||||
if (tgAlreadyPosted(chatId, dedupeKey)) {
|
||||
console.warn('telegram duplicate suppressed:', dedupeKey, '->', String(chatId));
|
||||
return;
|
||||
}
|
||||
// TEMPORARY (2026-09-18): members are seeing payment lines twice. This process has a
|
||||
@@ -1898,6 +1921,7 @@ chain.startIndexer(evt=>{
|
||||
try{tgbot.notifyEvent(evt);}catch(e){}
|
||||
try{
|
||||
const c=getConfig();
|
||||
const EK=tgEventKey(evt); // one id for this on-chain event, so no feed can post it twice
|
||||
// teamRootId accepts a comma list ("21,136") — alerts fire for ANY listed org
|
||||
const roots=String(c.teamRootId||'').split(',').map(n=>Number(n.trim())).filter(n=>n>0);
|
||||
// A brand-new member has no uplineId yet at the moment the 'registered'
|
||||
@@ -1930,10 +1954,10 @@ chain.startIndexer(evt=>{
|
||||
// team-build topic: telegramTeamEvents 'all' (default) | 'no-payouts' (payout lines live in
|
||||
// the shared payments topic instead) | 'none' (Marty 2026-09-12: nothing but the daily
|
||||
// snapshot, which the cron posts to this topic on its own).
|
||||
{ const tm=String(c.telegramTeamEvents||'all'); if(tm!=='none' && !(evt.type==='payout' && tm==='no-payouts')) sendTelegram(text); }
|
||||
{ const tm=String(c.telegramTeamEvents||'all'); if(tm!=='none' && !(evt.type==='payout' && tm==='no-payouts')) sendTelegram(text, undefined, undefined, EK&&('team:'+EK)); }
|
||||
// recruiting-framed copy of the SAME event to the new-members topic
|
||||
// (social proof, CTA -> home). Fires only when a recruit topic is set.
|
||||
if(c.telegramRecruitTopicId){ const rm=recruitMsg(evt); if(rm){ const cta='https://'+String(c.recruitCtaUrl||'rmcircle.team').replace(/^https?:\/\//,''); sendTelegram(rm, c.telegramRecruitTopicId, {inline_keyboard:[[{text:'🚀 Get Started — rmcircle.team',url:cta+(cta.includes('?')?'&':'?')+'utm_source=tg'}]]}); } }
|
||||
if(c.telegramRecruitTopicId){ const rm=recruitMsg(evt); if(rm){ const cta='https://'+String(c.recruitCtaUrl||'rmcircle.team').replace(/^https?:\/\//,''); sendTelegram(rm, c.telegramRecruitTopicId, {inline_keyboard:[[{text:'🚀 Get Started — rmcircle.team',url:cta+(cta.includes('?')?'&':'?')+'utm_source=tg'}]]}, EK&&('recruit:'+EK)); } }
|
||||
// company payment-proof channel: the recruiting copy plus the Polygonscan
|
||||
// receipt, posted by the SAME bot to a separate chat. Payouts only unless
|
||||
// telegramProofEvents is 'all'. Fires only when a proof chat id is set.
|
||||
@@ -1941,7 +1965,7 @@ chain.startIndexer(evt=>{
|
||||
if(c.telegramProofChatId){
|
||||
const mode=String(c.telegramProofEvents||'payouts+upgrades');
|
||||
const want=evt.type==='payout' || (evt.type==='upgraded' && mode!=='payouts') || (evt.type==='registered' && mode==='all');
|
||||
if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramProofChatId, pm, c.telegramProofTopicId||null, null, 'HTML'); }
|
||||
if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramProofChatId, pm, c.telegramProofTopicId||null, null, 'HTML', EK&&('proof:'+EK)); }
|
||||
}
|
||||
// Echo feed: the same proof line into a shared cross-program payments topic
|
||||
// (config.telegramEchoTopicId in the team forum, or telegramEchoChatId for another
|
||||
@@ -1951,7 +1975,7 @@ chain.startIndexer(evt=>{
|
||||
if(c.telegramEchoTopicId||c.telegramEchoChatId){
|
||||
const mode=String(c.telegramEchoEvents||'payouts');
|
||||
const want=evt.type==='payout' || (evt.type==='upgraded' && mode!=='payouts') || (evt.type==='registered' && mode==='all');
|
||||
if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramEchoChatId||c.telegramChatId, '\u{1F7E3} <b>RM Circle</b> \u00b7 '+pm, c.telegramEchoTopicId||null, null, 'HTML'); }
|
||||
if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramEchoChatId||c.telegramChatId, '\u{1F7E3} <b>RM Circle</b> \u00b7 '+pm, c.telegramEchoTopicId||null, null, 'HTML', EK&&('echo:'+EK)); }
|
||||
}
|
||||
// admin email alert — same team-gated events, so deep-leg action still surfaces
|
||||
if(c.teamAlertEmail){
|
||||
|
||||
Reference in New Issue
Block a user