From a77df43ae4fae6c8d6ad17f3c60d52c972f5e1a7 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 16:23:22 +0800 Subject: [PATCH] feat(bot): add /pair /unpair /accounts /groups commands --- apps/bot/src/telegram/bot.ts | 8 ++ apps/bot/src/telegram/commands/accounts.ts | 30 ++++++ apps/bot/src/telegram/commands/groups.ts | 41 ++++++++ apps/bot/src/telegram/commands/pair.ts | 105 +++++++++++++++++++++ apps/bot/src/telegram/commands/unpair.ts | 53 +++++++++++ 5 files changed, 237 insertions(+) create mode 100644 apps/bot/src/telegram/commands/accounts.ts create mode 100644 apps/bot/src/telegram/commands/groups.ts create mode 100644 apps/bot/src/telegram/commands/pair.ts create mode 100644 apps/bot/src/telegram/commands/unpair.ts diff --git a/apps/bot/src/telegram/bot.ts b/apps/bot/src/telegram/bot.ts index a59d385..bc17871 100644 --- a/apps/bot/src/telegram/bot.ts +++ b/apps/bot/src/telegram/bot.ts @@ -5,6 +5,10 @@ import { makeWhitelistMiddleware } from "./middleware/whitelist.js"; import { auditMiddleware } from "./middleware/audit.js"; import { handleStart } from "./commands/start.js"; import { handleHelp } from "./commands/help.js"; +import { handlePair } from "./commands/pair.js"; +import { handleUnpair } from "./commands/unpair.js"; +import { handleAccounts } from "./commands/accounts.js"; +import { handleGroups } from "./commands/groups.js"; export function createTelegramBot(): Bot { const bot = new Bot(env.TELEGRAM_BOT_TOKEN); @@ -14,6 +18,10 @@ export function createTelegramBot(): Bot { bot.command("start", handleStart); bot.command("help", handleHelp); + bot.command("pair", handlePair); + bot.command("unpair", handleUnpair); + bot.command("accounts", handleAccounts); + bot.command("groups", handleGroups); bot.catch((err) => { logger.error({ err }, "telegram error"); diff --git a/apps/bot/src/telegram/commands/accounts.ts b/apps/bot/src/telegram/commands/accounts.ts new file mode 100644 index 0000000..02f1794 --- /dev/null +++ b/apps/bot/src/telegram/commands/accounts.ts @@ -0,0 +1,30 @@ +import type { Context } from "grammy"; +import { db } from "../../db.js"; +import { sessionManager } from "../../whatsapp/session-manager.js"; + +export async function handleAccounts(ctx: Context): Promise { + const operatorId = ctx.from?.id; + if (!operatorId) return; + + const operatorRow = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, operatorId), + }); + if (!operatorRow) return; + + const accounts = await db.query.whatsappAccounts.findMany({ + where: (a, { eq }) => eq(a.operatorId, operatorRow.id), + orderBy: (a, { asc }) => [asc(a.label)], + }); + + if (accounts.length === 0) { + await ctx.reply('No accounts paired yet. Use /pair "Label" to add one.'); + return; + } + + const lines = accounts.map((a) => { + const live = sessionManager.getState(a.id); + const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : ""; + return `β€’ ${a.label}${phone} β€” db:${a.status} live:${live}`; + }); + await ctx.reply(`πŸ“’ Paired accounts:\n${lines.join("\n")}`); +} diff --git a/apps/bot/src/telegram/commands/groups.ts b/apps/bot/src/telegram/commands/groups.ts new file mode 100644 index 0000000..72d406a --- /dev/null +++ b/apps/bot/src/telegram/commands/groups.ts @@ -0,0 +1,41 @@ +import type { Context } from "grammy"; +import { db } from "../../db.js"; + +export async function handleGroups(ctx: Context): Promise { + const text = ctx.message?.text ?? ""; + const label = text.replace(/^\/groups\s*/, "").trim().replace(/^["']|["']$/g, ""); + if (!label) { + await ctx.reply('Usage: /groups "Account Label"'); + return; + } + + const operatorId = ctx.from?.id; + if (!operatorId) return; + + const operatorRow = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, operatorId), + }); + if (!operatorRow) return; + + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)), + }); + if (!account) { + await ctx.reply(`No account labelled "${label}".`); + return; + } + + const groups = await db.query.whatsappGroups.findMany({ + where: (g, { eq }) => eq(g.accountId, account.id), + orderBy: (g, { asc }) => [asc(g.name)], + }); + + if (groups.length === 0) { + await ctx.reply(`No groups synced for "${label}" yet.`); + return; + } + + const lines = groups.slice(0, 50).map((g) => `β€’ ${g.name} (${g.participantCount})`); + const overflow = groups.length > 50 ? `\n…and ${groups.length - 50} more` : ""; + await ctx.reply(`πŸ‘₯ Groups in "${label}":\n${lines.join("\n")}${overflow}`); +} diff --git a/apps/bot/src/telegram/commands/pair.ts b/apps/bot/src/telegram/commands/pair.ts new file mode 100644 index 0000000..66f88d7 --- /dev/null +++ b/apps/bot/src/telegram/commands/pair.ts @@ -0,0 +1,105 @@ +import type { Context } from "grammy"; +import { InputFile } from "grammy"; +import { whatsappAccounts } from "@cmbot/db"; +import { db } from "../../db.js"; +import { logger } from "../../logger.js"; +import { sessionManager } from "../../whatsapp/session-manager.js"; +import { renderQrPng } from "../../whatsapp/qr-renderer.js"; +import { syncGroupsForAccount } from "../../whatsapp/group-sync.js"; +import { writeAuditLog } from "../../audit.js"; + +const qrMessageIdByAccount = new Map(); + +export async function handlePair(ctx: Context): Promise { + const text = ctx.message?.text ?? ""; + const label = text.replace(/^\/pair\s*/, "").trim().replace(/^["']|["']$/g, ""); + if (!label) { + await ctx.reply('Usage: /pair "Account Label"'); + return; + } + + const operatorId = ctx.from?.id; + if (!operatorId) return; + + const operatorRow = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, operatorId), + }); + if (!operatorRow) { + await ctx.reply("Your Telegram ID is whitelisted but no operator row exists. Run scripts/db.sh seed."); + return; + } + + const existing = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)), + }); + if (existing && existing.status === "connected") { + await ctx.reply(`Account "${label}" is already connected. Use /unpair first.`); + return; + } + + let accountId = existing?.id; + if (!accountId) { + const [created] = await db + .insert(whatsappAccounts) + .values({ operatorId: operatorRow.id, label, status: "pending" }) + .returning({ id: whatsappAccounts.id }); + accountId = created!.id; + } + + await ctx.reply(`πŸ“‘ Starting pairing for "${label}". A QR code will arrive shortly.`); + + const off = sessionManager.on(async (id, _state, event) => { + if (id !== accountId) return; + try { + if (event.type === "qr") { + const png = await renderQrPng(event.payload); + const file = new InputFile(png, `pair-${id}.png`); + const caption = `πŸ“± Scan with WhatsApp β†’ Linked Devices.\nLabel: "${label}". Expires in ~30s.`; + const existingMsg = qrMessageIdByAccount.get(id); + if (existingMsg) { + await ctx.api.editMessageMedia(ctx.chat!.id, existingMsg, { + type: "photo", + media: file, + caption, + }); + } else { + const sent = await ctx.replyWithPhoto(file, { caption }); + qrMessageIdByAccount.set(id, sent.message_id); + } + } else if (event.type === "open") { + qrMessageIdByAccount.delete(id); + await ctx.reply( + `βœ… "${label}" connected${event.phoneNumber ? ` as +${event.phoneNumber}` : ""}.`, + ); + await writeAuditLog(db, { + operatorId: operatorRow.id, + source: "telegram", + action: "account.paired", + targetType: "whatsapp_account", + targetId: id, + payload: { label }, + }); + const session = sessionManager.getSession(id); + if (session) { + const result = await syncGroupsForAccount(id, session.socket); + await ctx.reply(`Synced ${result.synced} groups. Ready to send reminders.`); + } + off(); + } else if (event.type === "close" && event.loggedOut) { + qrMessageIdByAccount.delete(id); + await ctx.reply(`⚠️ Pairing failed (logged out).`); + off(); + } + } catch (err) { + logger.error({ err, accountId: id }, "pair handler error"); + } + }); + + try { + await sessionManager.start(accountId); + } catch (err) { + logger.error({ err, accountId }, "pair: start failed"); + await ctx.reply(`Pairing failed to start: ${(err as Error).message}`); + off(); + } +} diff --git a/apps/bot/src/telegram/commands/unpair.ts b/apps/bot/src/telegram/commands/unpair.ts new file mode 100644 index 0000000..a3079a6 --- /dev/null +++ b/apps/bot/src/telegram/commands/unpair.ts @@ -0,0 +1,53 @@ +import type { Context } from "grammy"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { whatsappAccounts } from "@cmbot/db"; +import { db } from "../../db.js"; +import { env } from "../../env.js"; +import { sessionManager } from "../../whatsapp/session-manager.js"; +import { writeAuditLog } from "../../audit.js"; + +export async function handleUnpair(ctx: Context): Promise { + const text = ctx.message?.text ?? ""; + const label = text.replace(/^\/unpair\s*/, "").trim().replace(/^["']|["']$/g, ""); + if (!label) { + await ctx.reply('Usage: /unpair "Account Label"'); + return; + } + + const operatorId = ctx.from?.id; + if (!operatorId) return; + + const operatorRow = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, operatorId), + }); + if (!operatorRow) return; + + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.operatorId, operatorRow.id), eq(a.label, label)), + }); + if (!account) { + await ctx.reply(`No account labelled "${label}".`); + return; + } + + await sessionManager.stop(account.id); + await rm(join(env.SESSIONS_DIR, account.id), { recursive: true, force: true }); + + await db + .update(whatsappAccounts) + .set({ status: "logged_out", phoneNumber: null }) + .where(eq(whatsappAccounts.id, account.id)); + + await writeAuditLog(db, { + operatorId: operatorRow.id, + source: "telegram", + action: "account.unpaired", + targetType: "whatsapp_account", + targetId: account.id, + payload: { label }, + }); + + await ctx.reply(`πŸ—‘ "${label}" unpaired. Session files deleted.`); +}