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){