// 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// 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`; await dm(chat, `๐Ÿ’ฐ You just caught ${evt.pol.toFixed(2)} POL โ€” ${kind}.\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} ยท 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} ยท 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} ` : `๐Ÿ”ฅ 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//?startapp=[_] โ€” 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 \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 โ€” 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 };