Files
rm-circle-team-router/tgbot.js
T
martbost bd42708fa4 Telegram bot: don't tell a linked member their code expired
Janie tapped a stale link code, got "That link code is expired or already
used", and immediately below it "Linked to position #34!". Both messages were
accurate — two different codes, the old one tapped first — but back to back
they read as a contradiction, and the expired message sends someone back to
the dashboard to redo something already done.

The bot now checks whether that chat is already linked before reporting a
dead code, and says so plainly: already linked to #34, nothing more to do,
here is what you can type. Only genuinely unlinked members get the
get-a-fresh-code path, and that copy now explains WHY codes die (single-use,
short-lived) and says to use the newest link.

Verified against live data: chat 652806260 -> position #34, so the link was
real and only the message was wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 06:29:23 -05:00

340 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Telegram companion bot: identity linking, downline message bridge, personal
// payout pings. Deliberately additive — private-chat updates only (the same
// bot keeps posting group feeds untouched), no wallet actions ever, all state
// in one JSON file on the volume. Uses the raw Bot API via fetch; token comes
// from config key companionBotToken ONLY (see hard rule below).
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const suiteTools = require('./suite-tools');
let DATA_DIR = '', chain = null, getConfig = null, messages = null;
const FILE = () => path.join(DATA_DIR, 'tg-links.json');
function load() { try { return JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) { return { u: '', members: {}, chats: {}, codes: {}, rmap: {} }; } }
function save(d) { try { fs.writeFileSync(FILE(), JSON.stringify(d)); } catch (e) { console.error('tgbot save', e.message); } }
// HARD RULE: the companion runs ONLY on its own dedicated bot token. The
// shared telegramBotToken belongs to @CTBRewards_Bot (group feeds + the CTB
// Rewards project) — registering a webhook on it breaks CTB's bot. Until
// companionBotToken is set in config, every companion feature is a no-op.
function token() { const c = getConfig(); return String(c.companionBotToken || '').trim(); }
function webhookSecret() { const t = token(); return t ? crypto.createHash('sha1').update('tg-hook:' + t).digest('hex').slice(0, 24) : ''; }
async function api(method, payload) {
const t = token(); if (!t) return null;
try {
const r = await fetch(`https://api.telegram.org/bot${t}/${method}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {})
});
return await r.json();
} catch (e) { console.error('tgbot api', method, e.message); return null; }
}
async function dm(chatId, text, extra) {
return api('sendMessage', Object.assign({ chat_id: chatId, text, disable_web_page_preview: true }, extra || {}));
}
// --- Mini App: verify Telegram WebApp initData (HMAC per Bot API spec) ------
// secret_key = HMAC_SHA256(key="WebAppData", bot_token); hash covers the
// sorted key=value lines of every field except hash itself.
const INITDATA_MAX_AGE_S = 12 * 3600;
function verifyInitData(initData) {
const t = token(); if (!t) return { error: 'bot-offline' };
if (typeof initData !== 'string' || !initData || initData.length > 4096) return { error: 'bad-initdata' };
let params; try { params = new URLSearchParams(initData); } catch (e) { return { error: 'bad-initdata' }; }
const hash = params.get('hash');
if (!hash || !/^[0-9a-f]{64}$/.test(hash)) return { error: 'bad-initdata' };
params.delete('hash');
const dcs = [...params.entries()].map(([k, v]) => `${k}=${v}`).sort().join('\n');
const secret = crypto.createHmac('sha256', 'WebAppData').update(t).digest();
const check = crypto.createHmac('sha256', secret).update(dcs).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(check), Buffer.from(hash))) return { error: 'bad-hash' };
const authDate = Number(params.get('auth_date')) || 0;
if (Math.abs(Date.now() / 1000 - authDate) > INITDATA_MAX_AGE_S) return { error: 'stale' };
let user = null; try { user = JSON.parse(params.get('user') || 'null'); } catch (e) {}
if (!user || !user.id) return { error: 'no-user' };
return { userId: user.id, user };
}
// --- setup: learn our username + point the webhook at ourselves -------------
async function ensureWebhook(baseUrl) {
const d = load();
const me = await api('getMe', {});
if (me && me.ok) { d.u = me.result.username; save(d); }
const secret = webhookSecret();
if (!secret) return;
// Menu button (bottom-left ☰ in the private chat) opens the Mini App.
// Idempotent; BotFather /newapp is only needed for t.me/<bot>/<app> links.
await api('setChatMenuButton', { menu_button: { type: 'web_app', text: 'Open App', web_app: { url: `${baseUrl}/app` } } });
const url = `${baseUrl}/api/tg-hook/${secret}`;
const info = await api('getWebhookInfo', {});
if (info && info.ok && info.result.url === url) return;
const r = await api('setWebhook', { url, allowed_updates: ['message'] });
console.log('tgbot webhook:', r && r.ok ? url : JSON.stringify(r).slice(0, 120));
}
function init(opts) {
DATA_DIR = opts.dataDir; chain = opts.chain; getConfig = opts.getConfig; messages = opts.messages;
ensureWebhook(opts.baseUrl || 'https://rmcircle.team').catch(e => console.error('tgbot init', e.message));
setInterval(() => runDigests(), 20 * 60 * 1000).unref();
}
// --- linking ----------------------------------------------------------------
function makeLinkCode(memberId) {
const d = load();
// one pending code per member; 10-minute expiry
for (const [c, rec] of Object.entries(d.codes)) if (rec.id === memberId || rec.exp < Date.now()) delete d.codes[c];
const code = crypto.randomBytes(8).toString('hex');
d.codes[code] = { id: memberId, exp: Date.now() + 10 * 60 * 1000 };
save(d);
return { code, url: d.u ? `https://t.me/${d.u}?start=${code}` : null };
}
function memberChat(memberId) { const d = load(); return d.members[String(memberId)] || null; }
function chatMember(chatId) { const d = load(); return d.chats[String(chatId)] || null; }
// --- outbound notifications -------------------------------------------------
async function notifyEvent(evt) {
try {
if (evt.type === 'payout') {
const chat = memberChat(evt.toId);
if (!chat) return;
const kind = evt.kind === 'upline' ? `${evt.gen ? `Gen ${evt.gen} ` : ''}upgrade pass-up from #${evt.fromId}` : `referral reward from #${evt.fromId}'s entry`;
// Upgrade pass-up: tell the catcher what the person below just unlocked.
// It makes the reason for the payment concrete, and it is a natural nudge
// toward their own next rung without asking for anything.
const upTools = evt.kind === 'upline' ? suiteTools.unlockText(0, evt.levelName, getConfig()) : '';
await dm(chat, `💰 You just caught ${evt.pol.toFixed(2)} POL — ${kind}.` +
(upTools ? `\n\n#${evt.fromId} reached ${evt.levelName}, which unlocked ${upTools} for them — and their climb is what put this on you.` : '') +
`\nVerify: https://polygonscan.com/tx/${evt.tx}\n\nYour pipeline: https://rmcircle.team/my/${evt.toId}`);
} else if (evt.type === 'upgraded') {
// Congratulate the member who upgraded, and tell them what it opened up.
const chat = memberChat(evt.id);
if (!chat) return;
const tools = suiteTools.unlockText(evt.level, evt.levelName, getConfig());
await dm(chat, `⚡ You're now ${evt.levelName}!\n\nYour reach just extended one generation deeper — payments from that layer can now land on you.` +
(tools ? `\n\n🧰 This level also unlocked ${tools}.\nOpen it: https://rmcircle.team/suite` : '') +
`\n\nNow teach your two to climb: https://rmcircle.team/training#lesson-8`);
} else if (evt.type === 'registered' && evt.referrerId) {
const chat = memberChat(evt.referrerId);
if (!chat) return;
await dm(chat, `🎉 #${evt.id} just joined on your link!\nThe first 48 hours decide everything — run the play: https://rmcircle.team/training#lesson-6\nSay hello now: reply here with:\nmsg ${evt.id} Welcome to the team!`);
}
} catch (e) { console.error('tgbot notifyEvent', e.message); }
}
async function notifyMessage(fromId, toId, body) {
try {
const chat = memberChat(toId);
if (!chat) return;
const r = await dm(chat, `📨 Message from #${fromId} (your team line):\n\n${body}\n\n↩️ Reply to THIS message to answer — it goes straight back to them.`);
if (r && r.ok) {
const d = load();
const key = String(chat);
d.rmap[key] = d.rmap[key] || {};
d.rmap[key][String(r.result.message_id)] = fromId;
const ids = Object.keys(d.rmap[key]); if (ids.length > 50) for (const old of ids.slice(0, ids.length - 50)) delete d.rmap[key][old];
save(d);
}
} catch (e) { console.error('tgbot notifyMessage', e.message); }
}
// --- member data (reuses the site's own assembled API) ----------------------
async function fetchMember(id) {
try {
const r = await fetch(`http://127.0.0.1:${process.env.PORT || 3000}/api/public/member?id=${id}`);
return await r.json();
} catch (e) { return null; }
}
const LESSONS = {
1: 'The Two-Person Mindset', 2: 'Your Warm List', 3: 'The Conversation', 4: 'Objections Without Flinching',
5: 'Your Dashboard Is Your Coaching Desk', 6: 'The First 48 Hours', 7: 'Stalled People & Pass-Ups',
8: 'Timing Upgrades to Catches', 9: 'Run the Same Play', 10: 'The Weekly Rhythm'
};
const lessonLink = n => `https://rmcircle.team/training#lesson-${n}`;
function coachText(d) {
const c = d && d.coach;
if (!c || !c.ready) return 'Coaching data is still indexing — try again in a few minutes.';
const out = [];
(c.rollForward || []).slice(0, 3).forEach(r => out.push(`💬 #${r.id} is qualified and their Ascensus is already covered by ${Math.round(r.earnedPol)} POL of catches — one friendly message converts this.\n Play: msg ${r.id} <your note> · send Lesson 8: ${lessonLink(8)}`));
(c.atRisk || []).filter(r => r.qualified).slice(0, 3).forEach(r => out.push(`⚠️ #${r.id} has ${Math.round(r.atRiskPol)} POL forming but needs ${r.neededLevelName} to catch it.\n Play: msg ${r.id} <heads-up> · send Lesson 8: ${lessonLink(8)}`));
(c.atRisk || []).filter(r => !r.qualified).slice(0, 2).forEach(r => out.push(`⏰ #${r.id} has ${Math.round(r.atRiskPol)} POL forming but is ${r.directCount}/2 — qualification is their fix.\n Play: send Lesson 2: ${lessonLink(2)}`));
(c.oneAway || []).slice(0, 2).forEach(r => out.push(`🎯 #${r.id} is one direct away from qualifying.\n Play: send Lesson 3: ${lessonLink(3)}`));
if (!out.length) return 'Nothing urgent on your coach list right now — your leg is either moving or quiet. A great time for one share: type links';
return `🧭 Your coach list (the Method play for each):\n\n` + out.join('\n\n') + `\n\nRemember: the answer to every question is a lesson, not a lecture.`;
}
function teamText(d) {
const st = d && d.subtree;
if (!st) return 'Team data is still indexing — try again shortly.';
let gens = 0, layer = [st];
while (layer.length && gens < 60) {
const next = [];
layer.forEach(n => { if (n.left) next.push(n.left); if (n.right) next.push(n.right); });
if (!next.length) break; gens++; layer = next;
}
const usd = d.polUsd > 0 ? ` (~$${Math.round(st.downPol * d.polUsd)})` : '';
const step = d.nextStep && d.nextStep.cost ? `${Math.round(d.nextStep.cost)} POL — wallet ${d.nextStep.funded ? 'already covers it ✅' : 'not there yet (let catches stack)'}` : 'you are at the top level';
return `📊 Your organization, #${d.id}:\n👥 ${st.downCount} members · ${gens} generations deep\n💰 ${Math.round(st.downPol).toLocaleString()} POL earned below you${usd}\n⬆️ Your next step: ${step}\n\nFull dashboard: https://rmcircle.team/my/${d.id}`;
}
// --- weekly rhythm digest ---------------------------------------------------
const ANGLE_ROTATION = ['pocket', 'phone', 'two', 'graveyard'];
function chicagoNow() {
const p = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Chicago', weekday: 'short', hour: 'numeric', hour12: false }).formatToParts(new Date());
const get = t2 => (p.find(x => x.type === t2) || {}).value;
return { dow: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(get('weekday')), hour: Number(get('hour')) % 24 };
}
async function digestFor(memberId) {
const d = await fetchMember(memberId);
if (!d || !d.registered) return null;
const st = d.subtree || {};
const links = load();
const prev = (links.stats || {})[String(memberId)] || {};
const delta = prev.count != null ? st.downCount - prev.count : null;
links.stats = links.stats || {};
links.stats[String(memberId)] = { count: st.downCount, at: Date.now() };
save(links);
const week = Math.floor(Date.now() / (7 * 864e5));
const angle = ANGLE_ROTATION[week % 4];
const c = d.coach || {};
const mover = (c.rollForward || [])[0] || (c.atRisk || []).filter(r => r.qualified)[0];
const quiet = (c.oneAway || [])[0] || (c.atRisk || []).filter(r => !r.qualified)[0];
const lines = [`🔁 Your 20 minutes, #${memberId} — the field report:\n`];
lines.push(`📊 READ: ${st.downCount != null ? st.downCount : '—'} in your org${delta != null ? ` (${delta >= 0 ? '+' + delta : delta} this week)` : ''} · ${Math.round(st.downPol || 0).toLocaleString()} POL earned below you`);
lines.push(mover ? `🔥 LIFT ONE: #${mover.id} — ${mover.ascensusCost ? 'their next step is already covered by catches; one message converts it' : 'money is forming below them; a heads-up now beats a pass-up later'}. Try: msg ${mover.id} <your note>` : `🔥 LIFT ONE: nobody flagged this week — congratulate your newest member instead.`);
lines.push(quiet ? `🕯️ CHECK ONE: #${quiet.id} — one kind line, no pressure: msg ${quiet.id} Thinking of you — no rush on anything, here if you need me.` : `🕯️ CHECK ONE: your quiet list is empty. Enjoy that.`);
const maShare = miniAppInvite(memberId, angle);
lines.push(`📤 SHARE ONE: this week's angle is "${angle}" — your matched link:\nhttps://rmcircle.team/join/${memberId}?v=${angle}${maShare ? `\nSharing inside Telegram? Use this instead — it opens right here:\n${maShare}` : ''}`);
lines.push(`\nThen close the tab. 20 every week beats 2 hours twice. (Lesson 10: ${lessonLink(10)})\nrhythm off to stop these · rhythm now to rerun anytime`);
return lines.join('\n');
}
async function runDigests() {
try {
const d = load();
const now = chicagoNow();
const weekTag = Math.floor(Date.now() / (7 * 864e5));
for (const [mid, chatId] of Object.entries(d.members)) {
const pref = (d.rhythm || {})[mid] || {};
if (pref.off) continue;
const dow = pref.dow != null ? pref.dow : 6, hour = pref.hour != null ? pref.hour : 10;
if (now.dow !== dow || now.hour !== hour) continue;
if (pref.lastWeek === weekTag) continue;
const text = await digestFor(Number(mid));
if (text) {
await dm(chatId, text);
const dd = load(); dd.rhythm = dd.rhythm || {}; dd.rhythm[mid] = Object.assign({}, dd.rhythm[mid], { lastWeek: weekTag, dow, hour }); save(dd);
}
}
} catch (e) { console.error('tgbot digests', e.message); }
}
// --- member commands --------------------------------------------------------
// t.me/<bot>/<shortname>?startapp=<id>[_<angle>] — the Telegram-native invite;
// app.js decodes start_param and routes prospects to the sponsor's squeeze
// page. Requires config.miniAppShortName (set after BotFather /newapp).
function miniAppInvite(id, angle) {
const d = load(), short = String(getConfig().miniAppShortName || '').trim();
if (!d.u || !short) return null;
return `https://t.me/${d.u}/${short}?startapp=${id}${angle ? '_' + angle : ''}`;
}
function linksText(id) {
const b = 'https://rmcircle.team';
const ma = miniAppInvite(id);
return `🔗 Your links, #${id} (each one credits YOU):\n\n` +
`Moving invite link (routes to whoever needs help next in your leg):\n${b}/join/${id}\n\n` +
(ma ? `⚡ Telegram-native invite — opens instantly INSIDE Telegram (best for sharing in groups and DMs here):\n${ma}\n(Tap it yourself to preview exactly what your prospects will see.)\n\n` : '') +
`Angle-matched links — pick the hook that fits your person:\n` +
`💸 Pocket Change: ${b}/join/${id}?v=pocket\n` +
`📱 Your Phone: ${b}/join/${id}?v=phone\n` +
`🙋 I Don't Know Anyone: ${b}/join/${id}?v=two\n` +
`🪦 Side Hustle Graveyard: ${b}/join/${id}?v=graveyard\n\n` +
`Videos + ready-made posts: ${b}/tools?id=${id}`;
}
async function sendAsMember(memberId, toId, text, chatId) {
const r = messages.send({ id: memberId }, { toId, body: text });
if (r.error) { await dm(chatId, `⚠️ ${r.error}`); return; }
await dm(chatId, `✅ Sent to #${toId}. If they've linked Telegram they got it instantly; otherwise it's waiting on their dashboard.`);
notifyMessage(memberId, toId, text);
}
async function handleUpdate(update) {
try {
const msg = update && update.message;
if (!msg || !msg.chat || msg.chat.type !== 'private' || !msg.text) return;
const chatId = msg.chat.id;
const text = msg.text.trim();
const linked = chatMember(chatId);
if (/^\/start(\s|$)/.test(text)) {
const code = text.split(/\s+/)[1];
if (code) {
const d = load();
const rec = d.codes[code];
if (rec && rec.exp > Date.now()) {
delete d.codes[code];
d.members[String(rec.id)] = chatId;
d.chats[String(chatId)] = rec.id;
save(d);
await dm(chatId, `✅ Linked to position #${rec.id}!\n\nFrom now on:\n💰 You get a DM the moment your position catches a payment\n📨 Team messages reach you here — reply to answer\n🎉 You're pinged when someone joins on your link\n\nTry: coach · team · links — or: msg <id> <your message>\n📱 Tap the ☰ menu button (next to the message box) to open your full dashboard right inside Telegram — no login needed.\nYour weekly 20-minute digest arrives Saturdays 10am Central (rhythm to adjust).`);
return;
}
// A dead code from someone who is ALREADY linked is not a failure — it
// is usually a stale link they tapped twice, or an old one left over
// from an earlier attempt. Telling them it "expired" right after we
// confirmed their link reads as a contradiction and sends them back to
// the dashboard to redo something that is already done.
const already = chatMember(chatId);
if (already) {
await dm(chatId, `✅ You're already linked to position #${already} — nothing more to do.\n\nThat code was an older one (link codes are single-use), but your link is active. Try: coach · team · links\n📱 Tap the ☰ menu button next to the message box for your full dashboard.`);
return;
}
await dm(chatId, `That link code has expired or was already used — they're single-use and short-lived.\n\nGrab a fresh one: open https://rmcircle.team/my → Messages → sign in with your wallet → "Connect Telegram", and use the newest link.`);
return;
}
await dm(chatId, `👋 This is the RM Circle companion bot.\n\nTo link your position: open your dashboard (https://rmcircle.team/my), sign in to Messages with your wallet, and tap "Connect Telegram". That proves the position is yours — no passwords.\n\nOnce linked you get payout pings, team messages, and your share links right here.`);
return;
}
if (!linked) { await dm(chatId, `You're not linked yet. Open https://rmcircle.team/my → Messages → "Connect Telegram".`); return; }
if (/^\/?links$/i.test(text)) { await dm(chatId, linksText(linked)); return; }
if (/^\/?help$/i.test(text)) { await dm(chatId, `Commands:\nlinks — your invite + angle links\ncoach — who to help + which lesson to send\nteam — your org numbers\nmsg <id> <text> — message a teammate\nlesson <1-10> — grab any Circle Method lesson link\nrhythm — your weekly 20-minute digest (rhythm now / rhythm sat 9 / rhythm off)\nReply to any 📨 message to answer it.\n📱 The ☰ menu button opens your full dashboard inside Telegram — no login.\nDashboard: https://rmcircle.team/my/${linked}`); return; }
if (/^\/?coach$/i.test(text)) { const d = await fetchMember(linked); await dm(chatId, coachText(d)); return; }
if (/^\/?team$/i.test(text)) { const d = await fetchMember(linked); await dm(chatId, teamText(d)); return; }
const lm = text.match(/^\/?lesson\s+(\d{1,2})$/i);
if (lm) { const n = Number(lm[1]); if (LESSONS[n]) { await dm(chatId, `🎓 Lesson ${n} — ${LESSONS[n]}:\n${lessonLink(n)}\n\nForward this to whoever needs it. The answer to every question is a lesson.${n > 1 ? '\n(Lessons 2-10 are members-area — they unlock with a wallet sign-in on the page, or open instantly through the ☰ Mini App.)' : ''}`); } else { await dm(chatId, 'Lessons run 1–10. Try: lesson 7'); } return; }
const rm = text.match(/^\/?rhythm(?:\s+(\S+))?(?:\s+(\d{1,2}))?$/i);
if (rm) {
const d2 = load(); d2.rhythm = d2.rhythm || {}; const key = String(linked);
const sub = (rm[1] || '').toLowerCase();
if (sub === 'off') { d2.rhythm[key] = Object.assign({}, d2.rhythm[key], { off: true }); save(d2); await dm(chatId, 'Weekly digest off. rhythm on brings it back.'); return; }
if (sub === 'now') { const dd = await digestFor(linked); await dm(chatId, dd || 'Data still indexing — try again shortly.'); return; }
const days = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
if (sub in days) { const hour = Math.min(23, Math.max(0, Number(rm[2] != null ? rm[2] : 10))); d2.rhythm[key] = { dow: days[sub], hour }; save(d2); await dm(chatId, `Weekly digest set: every ${sub.toUpperCase()} at ${hour}:00 US Central.`); return; }
d2.rhythm[key] = Object.assign({}, d2.rhythm[key], { off: false }); save(d2);
const cur = d2.rhythm[key];
await dm(chatId, `Weekly digest is ON — ${['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][cur.dow != null ? cur.dow : 6]} at ${cur.hour != null ? cur.hour : 10}:00 US Central.\nChange it: rhythm sat 9 · run now: rhythm now · stop: rhythm off`);
return;
}
const m = text.match(/^\/?msg\s+#?(\d+)\s+([\s\S]+)/i);
if (m) { await sendAsMember(linked, Number(m[1]), m[2].trim(), chatId); return; }
// replying to a bridged message routes back to its sender
if (msg.reply_to_message) {
const d = load();
const target = (d.rmap[String(chatId)] || {})[String(msg.reply_to_message.message_id)];
if (target) { await sendAsMember(linked, target, text, chatId); return; }
}
await dm(chatId, `Not sure what you mean — try "help". (Questions about the program? The chat bubble on rmcircle.team answers in any language.)`);
} catch (e) { console.error('tgbot handleUpdate', e.message); }
}
module.exports = { init, handleUpdate, notifyEvent, notifyMessage, makeLinkCode, webhookSecret, memberChat, chatMember, verifyInitData };