Telegram Mini App v1: initData auth bridge into the existing site

- POST /api/public/tg-webapp-auth: HMAC-verifies WebApp initData against the
  companion bot token (12h freshness, timing-safe), maps chat -> member via
  tg-links.json, mints a message session -> linked members land on /my/<id>
  with zero login
- /app entry page (vendored telegram-web-app.js keeps CSP script-src 'self');
  unlinked users get the one-time wallet-link instructions
- tg-app.js on all pages: no-op in browsers; inside the webview lazy-loads the
  SDK, expands, themes header/background #071421, wires native BackButton
- Bot menu button set programmatically to open /app; /start + help mention it
- Synced chat.js canned answer + AI system prompt (Mini App facts)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-23 05:56:51 -05:00
parent 2a666d4063
commit cd361a1eaa
19 changed files with 3567 additions and 19 deletions
+28 -3
View File
@@ -35,6 +35,28 @@ 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();
@@ -42,6 +64,9 @@ async function ensureWebhook(baseUrl) {
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;
@@ -228,7 +253,7 @@ async function handleUpdate(update) {
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>\nYour weekly 20-minute digest arrives Saturdays 10am Central (rhythm to adjust).`);
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`);
@@ -241,7 +266,7 @@ async function handleUpdate(update) {
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.\nDashboard: https://rmcircle.team/my/${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; }
@@ -275,4 +300,4 @@ async function handleUpdate(update) {
} catch (e) { console.error('tgbot handleUpdate', e.message); }
}
module.exports = { init, handleUpdate, notifyEvent, notifyMessage, makeLinkCode, webhookSecret, memberChat };
module.exports = { init, handleUpdate, notifyEvent, notifyMessage, makeLinkCode, webhookSecret, memberChat, chatMember, verifyInitData };