Files
rm-circle-team-router/tgbot.js
T
martbost c9aa61e593 Telegram: say what qualifying actually opened, at the moment it happens
Nothing in the system marks the moment a member crosses two directs. That is
the exact point where the job changes from recruiting to climbing, and it is
where the org stalls — 53 qualified members parked at Ascensus, most of them
having treated two directs as the finish line.

The registered-event DM already goes to the referrer. When that registration
is the one that took them to exactly two, it now also tells them they are
qualified, what their next rung costs, which generation it reaches, how many
people they already have sitting there, and that the first catch is double
the rung's price.

It also names the actual failure mode rather than just linking a lesson: the
trap is spending catches instead of climbing with them. The wallet scan says
that is what is happening — 83 of 87 qualified members cannot cover their
next rung despite having earned well past it.

Wrapped so the join notice still sends if the lookup fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 05:22:28 -05:00

361 lines
23 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;
let msg = `🎉 #${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!`;
// If THIS registration is the one that took them to two directs, they have
// just crossed the eligibility gate and nothing else in the system tells
// them. Qualification is where members stall — 53 of them sat at Ascensus
// because getting two felt like the finish line. Say what it actually
// opened, at the only moment they are guaranteed to be paying attention.
try {
const me = await fetchMember(evt.referrerId);
if (me && Number(me.directCount) === 2) {
const ns = me.nextStep || {}, op = ns.opens || {};
msg += `\n\n━━━━━━━━━━━━━━\n✅ That was your SECOND direct — you are now QUALIFIED.\n\n` +
`From here you catch your two's first upgrades in full, without owning the level yourself.`;
if (op.perCatch > 0 && ns.cost > 0) {
msg += `\n\nYour next rung costs ${ns.cost.toFixed(2)} POL and extends your reach to generation ${op.gen}` +
(op.genSize > 0 ? `, where you already have ${op.genSize} ${op.genSize === 1 ? 'person' : 'people'}` : '') +
`. Each one pays you ${op.perCatch.toFixed(2)} POL — double what the rung costs, because every level is twice the last.` +
`\n\nThe trap is spending your catches instead of climbing with them: https://rmcircle.team/training#lesson-8` +
`\n\nYour dashboard: https://rmcircle.team/my/${evt.referrerId}`;
}
}
} catch (e) { /* the join notice still goes out */ }
await dm(chat, msg);
}
} 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 110. 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 };