Files
rm-circle-team-router/tgbot.js
T
martbost 6ac1bcab55 Telegram companion bot, phase 1
New tgbot.js (raw Bot API, zero deps, private-chats only so the
group feeds are untouched): wallet-verified linking via dashboard
deep-link codes, personal payout + joined-on-your-link DMs, a
message bridge that delivers site messages natively in Telegram
with reply-to-answer routing (matrix-line permissions enforced by
the existing messages.js rules), and links/msg/help commands.
Webhook self-registers; secret derived from the token. Config key
companionBotToken overrides the feed bot token when Marty creates
a dedicated bot. Dashboard Messages panel gains Connect Telegram;
chatbot + AI prompt synced.
2026-08-22 19:39:55 -05:00

166 lines
8.6 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 (companionBotToken overrides telegramBotToken when set).
'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); } }
function token() { const c = getConfig(); return String(c.companionBotToken || c.telegramBotToken || '').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 || {}));
}
// --- 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;
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));
}
// --- 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 commands --------------------------------------------------------
function linksText(id) {
const b = 'https://rmcircle.team';
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` +
`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: links — or: msg <id> <your message>`);
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\nmsg <id> <text> — message someone on your team line\nReply to any 📨 message to answer it.\nEverything else lives on your dashboard: https://rmcircle.team/my/${linked}`); 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 };