f876351391
- /app decodes startapp=<sponsorId>[_<angle>] -> unlinked visitors land on the sponsor squeeze page /join/<ref>?src=miniapp (linked members still go to their dashboard); no-invite prospects get a gold Join button -> /join-now - tg-app.js: sync __rmcInTg flag; external links route out of the webview via openLink/openTelegramLink (wallet deep links reach the wallet app reliably) - join-now.js in the webview leads with the MetaMask/Trust deep links (no injected wallet can exist there); ref + src survive into the wallet browser - tgbot: links command + weekly digest include the t.me/<bot>/<short>?startapp Telegram-native invite once config.miniAppShortName is set (new PATCH key) - Synced chat.js canned answer + AI prompt (prospects can join from the app) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
315 lines
20 KiB
JavaScript
315 lines
20 KiB
JavaScript
// 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');
|
||
|
||
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`;
|
||
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 === '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\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.`); } 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 };
|