diff --git a/server.js b/server.js index 678f356..8f15fe5 100644 --- a/server.js +++ b/server.js @@ -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} RM Circle \u00b7 '+pm, c.telegramEchoTopicId||null, null, 'HTML'); } + if(want){ const pm=proofMsg(evt); if(pm) sendTelegramTo(c.telegramEchoChatId||c.telegramChatId, '\u{1F7E3} RM Circle \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){