From 56fd71a6a0fc5a2edb898ba66b17bbcf9c6fbebe Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 16:35:28 +0800 Subject: [PATCH] feat(bot): inline keyboards + Telegram slash menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX improvements driven by live testing: - setMyCommands populates Telegram's '/' picker with all commands and descriptions, so the operator gets autocomplete instead of guessing syntax. - /start replies with an inline keyboard ([πŸ“’ Accounts] [πŸ“‘ How to Pair] [❓ Help]) β€” quick navigation without typing. - /accounts emits one message per account with [πŸ“‚ Groups] [πŸ—‘ Unpair] inline buttons. Tapping triggers a callback (no typed labels needed). - New callbacks module wires the buttons. Unpair shows a confirm/cancel prompt before acting. /pair still requires a typed label since the value is operator-defined content rather than a selection from existing data. --- apps/bot/src/telegram/bot.ts | 39 +++++++ apps/bot/src/telegram/callbacks.ts | 119 +++++++++++++++++++++ apps/bot/src/telegram/commands/accounts.ts | 16 ++- apps/bot/src/telegram/commands/start.ts | 13 ++- 4 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 apps/bot/src/telegram/callbacks.ts 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