Files
rm-circle-team-router/tgbot.js
T
martbost 6838ab3845 Member pages: send readers to the proof, not back to the same video
Marty spotted that a member's page played the angle video and then its button
sent readers to /join/<id>?v=<angle> — which is a SQUEEZE step that shows
that same video again before anything else.

His instinct was to jump straight to /join-now. That fixes the replay but
skips the proof layer: the live payout feed, the 2-4-8-16 explainer, the
toolkit line and the sponsor card all live on the full invite page. Going
from a personal story directly to "connect your wallet and send POL" is a big
jump for somebody who arrived cold from a Traffic Desk banner.

So the button now drops the ?v= and lands on the full invite page. The
existing design already gives exactly the wanted behaviour — the squeeze step
is skipped, the proof is kept, and no new parameter was needed.

The QR deliberately keeps the angle: a scanned code often reaches someone who
never saw the page, off a printout, and the hook video is what they need.

Also: payout alerts now name what an upgrade unlocked, on upgrade pass-ups
only. Marty noticed the toolkit was never mentioned — the unlock line was
wired to `upgraded` events, which are rare next to payouts. On a pass-up the
buyer below just climbed, so saying what they unlocked makes the reason for
the payment concrete. Entry rewards stay silent: nobody changed level.

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

330 lines
21 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;
}
await dm(chatId, `That link code is expired or already used. Get a fresh one from the Messages panel on your dashboard: https://rmcircle.team/my`);
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 };