diff --git a/apps/bot/src/telegram/bot.ts b/apps/bot/src/telegram/bot.ts index d29517e..ce287bc 100644 --- a/apps/bot/src/telegram/bot.ts +++ b/apps/bot/src/telegram/bot.ts @@ -3,20 +3,21 @@ import { env } from "../env.js"; import { logger } from "../logger.js"; 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 { handlePair, executePairFlow } 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, + showMainMenu, + showHelpMenu, + showAccountsMenu, + showAccountDetail, + showGroupsList, + showUnpairConfirm, + executeUnpair, + showPairPrompt, } from "./callbacks.js"; +import { consumePendingPairLabel, clearPendingPairLabel } from "./state.js"; export function createTelegramBot(): Bot { const bot = new Bot(env.TELEGRAM_BOT_TOKEN); @@ -24,43 +25,76 @@ export function createTelegramBot(): Bot { bot.use(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST)); bot.use(auditMiddleware); - bot.command("start", handleStart); + // Slash commands. /start and /menu both open the main menu. + bot.command(["start", "menu"], async (ctx) => { + const tgId = ctx.from?.id; + if (tgId !== undefined) clearPendingPairLabel(tgId); + await showMainMenu(ctx); + }); bot.command("help", handleHelp); bot.command("pair", handlePair); bot.command("unpair", handleUnpair); - bot.command("accounts", handleAccounts); + bot.command("accounts", async (ctx) => { + // Backward-compatible: /accounts now opens the accounts menu in the same chat. + await showAccountsMenu(ctx); + }); bot.command("groups", handleGroups); // Inline keyboard callbacks. Prefixes keep callback_data well under 64 bytes. + bot.callbackQuery("m:main", async (ctx) => { + const tgId = ctx.from?.id; + if (tgId !== undefined) clearPendingPairLabel(tgId); + await ctx.answerCallbackQuery(); + await showMainMenu(ctx); + }); + bot.callbackQuery("m:accounts", showAccountsMenu); + bot.callbackQuery("m:help", showHelpMenu); + bot.callbackQuery("m:pair", showPairPrompt); + bot.callbackQuery(/^acc:(.+)$/, async (ctx) => { + await showAccountDetail(ctx, ctx.match[1]!); + }); bot.callbackQuery(/^g:(.+)$/, async (ctx) => { - await handleGroupsCallback(ctx, ctx.match[1]!); + await showGroupsList(ctx, ctx.match[1]!); }); bot.callbackQuery(/^u:(.+)$/, async (ctx) => { - await handleUnpairPromptCallback(ctx, ctx.match[1]!); + await showUnpairConfirm(ctx, ctx.match[1]!); }); bot.callbackQuery(/^uc:(.+)$/, async (ctx) => { - await handleUnpairConfirmCallback(ctx, ctx.match[1]!); + await executeUnpair(ctx, ctx.match[1]!); }); - bot.callbackQuery("ux", handleUnpairCancelCallback); - bot.callbackQuery("m:accounts", async (ctx) => { - await ctx.answerCallbackQuery(); - await handleAccounts(ctx); + + // Plain-text messages: if the operator is in the "pending pair label" state + // (because they tapped πŸ“‘ Pair New), treat their next non-command message as + // the label. Otherwise, gently nudge them toward /menu. + bot.on("message:text", async (ctx) => { + const text = ctx.message?.text ?? ""; + if (text.startsWith("/")) return; // commands are handled above + const tgId = ctx.from?.id; + if (tgId === undefined) return; + if (consumePendingPairLabel(tgId)) { + const label = text.trim().replace(/^["'β€œβ€β€˜β€™]|["'β€œβ€β€˜β€™]$/g, ""); + if (!label) { + await ctx.reply("That label is empty. Tap /menu and try again."); + return; + } + await executePairFlow(ctx, label); + return; + } + await ctx.reply("Tap /menu to see what I can do."); }); - 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. + // Populate Telegram's slash menu with our commands. void bot.api .setMyCommands([ - { command: "start", description: "Show the welcome menu" }, + { command: "menu", description: "Open the main menu" }, + { command: "start", description: "Open the main 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: "pair", description: "Pair a new account (usage: /pair Label)" }, + { command: "unpair", description: "Unpair an account (usage: /unpair Label)" }, { command: "groups", description: "List groups for an account (usage: /groups Label)" }, { command: "help", description: "Show command help" }, ]) diff --git a/apps/bot/src/telegram/callbacks.ts b/apps/bot/src/telegram/callbacks.ts index 0b05b72..28ec5a5 100644 --- a/apps/bot/src/telegram/callbacks.ts +++ b/apps/bot/src/telegram/callbacks.ts @@ -1,74 +1,115 @@ 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 { logger } from "../logger.js"; import { sessionManager } from "../whatsapp/session-manager.js"; import { writeAuditLog } from "../audit.js"; +import { setPendingPairLabel } from "./state.js"; +import { + mainMenu, + helpMenu, + pairPromptMenu, + accountsMenu, + accountDetailMenu, + groupsListMenu, + unpairConfirmMenu, + unpairDoneMenu, + type MenuView, +} from "./menus.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), +async function findOperator(ctx: Context) { + const tgId = ctx.from?.id; + if (!tgId) return null; + return db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, tgId), }); - if (!operatorRow) return null; +} + +// Edit the current message to render a new menu view. Falls back to a fresh +// reply if the previous message can't be edited (e.g. a photo message β€” Telegram +// won't let us turn it back into a text message). +async function showMenu(ctx: Context, view: MenuView): Promise { + try { + await ctx.editMessageText(view.text, { + reply_markup: view.keyboard, + parse_mode: "Markdown", + }); + } catch (err) { + logger.debug({ err }, "showMenu: edit failed, sending fresh message"); + await ctx.reply(view.text, { + reply_markup: view.keyboard, + parse_mode: "Markdown", + }); + } +} + +export async function showMainMenu(ctx: Context): Promise { + await showMenu(ctx, mainMenu()); +} + +export async function showHelpMenu(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + await showMenu(ctx, helpMenu()); +} + +export async function showAccountsMenu(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const view = await accountsMenu(op.id); + await showMenu(ctx, view); +} + +export async function showAccountDetail(ctx: Context, accountId: string): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const view = await accountDetailMenu(op.id, accountId); + if (!view) { + await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); + return; + } + await showMenu(ctx, view); +} + +export async function showGroupsList(ctx: Context, accountId: string): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const view = await groupsListMenu(op.id, accountId); + if (!view) { + await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); + return; + } + await showMenu(ctx, view); +} + +export async function showUnpairConfirm(ctx: Context, accountId: string): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const view = await unpairConfirmMenu(op.id, accountId); + if (!view) { + await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); + return; + } + await showMenu(ctx, view); +} + +export async function executeUnpair(ctx: Context, accountId: string): Promise { + const op = await findOperator(ctx); + if (!op) { + await ctx.answerCallbackQuery(); + return; + } const account = await db.query.whatsappAccounts.findFirst({ - where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorRow.id)), + where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.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) { + if (!account) { await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true }); return; } @@ -79,41 +120,20 @@ export async function handleUnpairConfirmCallback(ctx: Context, accountId: strin .set({ status: "logged_out", phoneNumber: null }) .where(eq(whatsappAccounts.id, accountId)); await writeAuditLog(db, { - operatorId: found.operatorRow.id, + operatorId: op.id, source: "telegram", action: "account.unpaired", targetType: "whatsapp_account", targetId: accountId, - payload: { label: found.account.label, via: "callback" }, + payload: { label: account.label, via: "menu" }, }); await ctx.answerCallbackQuery({ text: "Unpaired." }); - await ctx.editMessageText(`πŸ—‘ "${found.account.label}" unpaired. Session files deleted.`); + await showMenu(ctx, unpairDoneMenu(account.label)); } -export async function handleUnpairCancelCallback(ctx: Context): Promise { - await ctx.answerCallbackQuery({ text: "Cancelled." }); - await ctx.editMessageText("Cancelled."); -} - -export async function handleMenuPairCallback(ctx: Context): Promise { +export async function showPairPrompt(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