diff --git a/apps/bot/src/telegram/bot.ts b/apps/bot/src/telegram/bot.ts index bc17871..d29517e 100644 --- a/apps/bot/src/telegram/bot.ts +++ b/apps/bot/src/telegram/bot.ts @@ -9,6 +9,14 @@ import { handlePair } from "./commands/pair.js"; import { handleUnpair } from "./commands/unpair.js"; import { handleAccounts } from "./commands/accounts.js"; import { handleGroups } from "./commands/groups.js"; +import { + handleGroupsCallback, + handleUnpairPromptCallback, + handleUnpairConfirmCallback, + handleUnpairCancelCallback, + handleMenuPairCallback, + handleMenuHelpCallback, +} from "./callbacks.js"; export function createTelegramBot(): Bot { const bot = new Bot(env.TELEGRAM_BOT_TOKEN); @@ -23,9 +31,40 @@ export function createTelegramBot(): Bot { bot.command("accounts", handleAccounts); bot.command("groups", handleGroups); + // Inline keyboard callbacks. Prefixes keep callback_data well under 64 bytes. + bot.callbackQuery(/^g:(.+)$/, async (ctx) => { + await handleGroupsCallback(ctx, ctx.match[1]!); + }); + bot.callbackQuery(/^u:(.+)$/, async (ctx) => { + await handleUnpairPromptCallback(ctx, ctx.match[1]!); + }); + bot.callbackQuery(/^uc:(.+)$/, async (ctx) => { + await handleUnpairConfirmCallback(ctx, ctx.match[1]!); + }); + bot.callbackQuery("ux", handleUnpairCancelCallback); + bot.callbackQuery("m:accounts", async (ctx) => { + await ctx.answerCallbackQuery(); + await handleAccounts(ctx); + }); + bot.callbackQuery("m:pair", handleMenuPairCallback); + bot.callbackQuery("m:help", handleMenuHelpCallback); + bot.catch((err) => { logger.error({ err }, "telegram error"); }); + // Populate Telegram's slash menu with our commands. Run async so it doesn't + // block bot startup; errors are logged but not fatal. + void bot.api + .setMyCommands([ + { command: "start", description: "Show the welcome menu" }, + { command: "accounts", description: "List paired WhatsApp accounts" }, + { command: "pair", description: "Pair a new WhatsApp account (usage: /pair Label)" }, + { command: "unpair", description: "Unpair a WhatsApp account (usage: /unpair Label)" }, + { command: "groups", description: "List groups for an account (usage: /groups Label)" }, + { command: "help", description: "Show command help" }, + ]) + .catch((err) => logger.warn({ err }, "setMyCommands failed")); + return bot; } diff --git a/apps/bot/src/telegram/callbacks.ts b/apps/bot/src/telegram/callbacks.ts new file mode 100644 index 0000000..0b05b72 --- /dev/null +++ b/apps/bot/src/telegram/callbacks.ts @@ -0,0 +1,119 @@ +import type { Context } from "grammy"; +import { InlineKeyboard } 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"; + +// Callback data uses short prefixes to stay well under Telegram's 64-byte limit +// g: → list groups for this account +// u: → ask for unpair confirmation +// uc: → confirm unpair (proceed) +// ux → cancel unpair +// m:pair → show pair instructions +// m:help → show help text + +async function findAccountForOperator(ctx: Context, accountId: string) { + const operatorId = ctx.from?.id; + if (!operatorId) return null; + const operatorRow = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, operatorId), + }); + if (!operatorRow) return null; + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorRow.id)), + }); + if (!account) return null; + return { account, operatorRow }; +} + +export async function handleGroupsCallback(ctx: Context, accountId: string): Promise { + const found = await findAccountForOperator(ctx, accountId); + if (!found) { + await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); + return; + } + const groups = await db.query.whatsappGroups.findMany({ + where: (g, { eq }) => eq(g.accountId, accountId), + orderBy: (g, { asc }) => [asc(g.name)], + }); + await ctx.answerCallbackQuery(); + if (groups.length === 0) { + await ctx.reply(`No groups synced for "${found.account.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 "${found.account.label}":\n${lines.join("\n")}${overflow}`); +} + +export async function handleUnpairPromptCallback(ctx: Context, accountId: string): Promise { + const found = await findAccountForOperator(ctx, accountId); + if (!found) { + await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); + return; + } + const kb = new InlineKeyboard() + .text("✅ Yes, unpair", `uc:${accountId}`) + .text("❌ Cancel", "ux"); + await ctx.answerCallbackQuery(); + await ctx.reply(`Unpair "${found.account.label}"? Session files will be deleted.`, { + reply_markup: kb, + }); +} + +export async function handleUnpairConfirmCallback(ctx: Context, accountId: string): Promise { + const found = await findAccountForOperator(ctx, accountId); + if (!found) { + await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); + return; + } + await sessionManager.stop(accountId); + await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true }); + await db + .update(whatsappAccounts) + .set({ status: "logged_out", phoneNumber: null }) + .where(eq(whatsappAccounts.id, accountId)); + await writeAuditLog(db, { + operatorId: found.operatorRow.id, + source: "telegram", + action: "account.unpaired", + targetType: "whatsapp_account", + targetId: accountId, + payload: { label: found.account.label, via: "callback" }, + }); + await ctx.answerCallbackQuery({ text: "Unpaired." }); + await ctx.editMessageText(`🗑 "${found.account.label}" unpaired. Session files deleted.`); +} + +export async function handleUnpairCancelCallback(ctx: Context): Promise { + await ctx.answerCallbackQuery({ text: "Cancelled." }); + await ctx.editMessageText("Cancelled."); +} + +export async function handleMenuPairCallback(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + await ctx.reply( + "📡 To pair a new WhatsApp account:\n\n" + + "Send `/pair YourLabel` (e.g. `/pair Sales 1`).\n\n" + + "A QR code will appear within ~5 seconds. Scan it from WhatsApp → Settings → Linked Devices.", + { parse_mode: "Markdown" }, + ); +} + +export async function handleMenuHelpCallback(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + await ctx.reply( + "Available commands:\n\n" + + "/start — show the welcome menu\n" + + "/help — show this help\n" + + "/pair