feat(bot): BotFather-style menu navigation
All flows are now reachable from /menu (alias for /start). Single message
edits in place via editMessageText for hierarchical navigation, every leaf
has ⬅ Back / ⬅ Main Menu buttons.
Menu hierarchy:
/menu → main menu
📒 Accounts → list (each account is a button)
📒 <Account> → detail (📂 Groups | 🗑 Unpair | ⬅ back)
📂 Groups → groups list (⬅ back to account, ⬅ main menu)
🗑 Unpair → confirm (✅ yes | ⬅ cancel) → done
📡 Pair New → prompt for label, operator replies as plain message
❓ Help → help text + ⬅ Main Menu
Implementation notes:
- New menus.ts module with pure render functions for each view
- New state.ts tracks pending "awaiting pair label" per Telegram user
- bot.on("message:text") consumes the pending label after Pair New
- /pair, /unpair, /groups commands still work for power users; they reuse
the same handlers behind the scenes (executePairFlow extracted from
handlePair so the menu and the command share one path)
This commit is contained in:
parent
56fd71a6a0
commit
7b0c8c47e2
@ -3,20 +3,21 @@ import { env } from "../env.js";
|
|||||||
import { logger } from "../logger.js";
|
import { logger } from "../logger.js";
|
||||||
import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
|
import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
|
||||||
import { auditMiddleware } from "./middleware/audit.js";
|
import { auditMiddleware } from "./middleware/audit.js";
|
||||||
import { handleStart } from "./commands/start.js";
|
|
||||||
import { handleHelp } from "./commands/help.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 { handleUnpair } from "./commands/unpair.js";
|
||||||
import { handleAccounts } from "./commands/accounts.js";
|
|
||||||
import { handleGroups } from "./commands/groups.js";
|
import { handleGroups } from "./commands/groups.js";
|
||||||
import {
|
import {
|
||||||
handleGroupsCallback,
|
showMainMenu,
|
||||||
handleUnpairPromptCallback,
|
showHelpMenu,
|
||||||
handleUnpairConfirmCallback,
|
showAccountsMenu,
|
||||||
handleUnpairCancelCallback,
|
showAccountDetail,
|
||||||
handleMenuPairCallback,
|
showGroupsList,
|
||||||
handleMenuHelpCallback,
|
showUnpairConfirm,
|
||||||
|
executeUnpair,
|
||||||
|
showPairPrompt,
|
||||||
} from "./callbacks.js";
|
} from "./callbacks.js";
|
||||||
|
import { consumePendingPairLabel, clearPendingPairLabel } from "./state.js";
|
||||||
|
|
||||||
export function createTelegramBot(): Bot {
|
export function createTelegramBot(): Bot {
|
||||||
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
|
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(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST));
|
||||||
bot.use(auditMiddleware);
|
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("help", handleHelp);
|
||||||
bot.command("pair", handlePair);
|
bot.command("pair", handlePair);
|
||||||
bot.command("unpair", handleUnpair);
|
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);
|
bot.command("groups", handleGroups);
|
||||||
|
|
||||||
// Inline keyboard callbacks. Prefixes keep callback_data well under 64 bytes.
|
// 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) => {
|
bot.callbackQuery(/^g:(.+)$/, async (ctx) => {
|
||||||
await handleGroupsCallback(ctx, ctx.match[1]!);
|
await showGroupsList(ctx, ctx.match[1]!);
|
||||||
});
|
});
|
||||||
bot.callbackQuery(/^u:(.+)$/, async (ctx) => {
|
bot.callbackQuery(/^u:(.+)$/, async (ctx) => {
|
||||||
await handleUnpairPromptCallback(ctx, ctx.match[1]!);
|
await showUnpairConfirm(ctx, ctx.match[1]!);
|
||||||
});
|
});
|
||||||
bot.callbackQuery(/^uc:(.+)$/, async (ctx) => {
|
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) => {
|
// Plain-text messages: if the operator is in the "pending pair label" state
|
||||||
await ctx.answerCallbackQuery();
|
// (because they tapped 📡 Pair New), treat their next non-command message as
|
||||||
await handleAccounts(ctx);
|
// 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) => {
|
bot.catch((err) => {
|
||||||
logger.error({ err }, "telegram error");
|
logger.error({ err }, "telegram error");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Populate Telegram's slash menu with our commands. Run async so it doesn't
|
// Populate Telegram's slash menu with our commands.
|
||||||
// block bot startup; errors are logged but not fatal.
|
|
||||||
void bot.api
|
void bot.api
|
||||||
.setMyCommands([
|
.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: "accounts", description: "List paired WhatsApp accounts" },
|
||||||
{ command: "pair", description: "Pair a new WhatsApp account (usage: /pair Label)" },
|
{ command: "pair", description: "Pair a new account (usage: /pair Label)" },
|
||||||
{ command: "unpair", description: "Unpair a WhatsApp account (usage: /unpair Label)" },
|
{ command: "unpair", description: "Unpair an account (usage: /unpair Label)" },
|
||||||
{ command: "groups", description: "List groups for an account (usage: /groups Label)" },
|
{ command: "groups", description: "List groups for an account (usage: /groups Label)" },
|
||||||
{ command: "help", description: "Show command help" },
|
{ command: "help", description: "Show command help" },
|
||||||
])
|
])
|
||||||
|
|||||||
@ -1,74 +1,115 @@
|
|||||||
import type { Context } from "grammy";
|
import type { Context } from "grammy";
|
||||||
import { InlineKeyboard } from "grammy";
|
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { whatsappAccounts } from "@cmbot/db";
|
import { whatsappAccounts } from "@cmbot/db";
|
||||||
import { db } from "../db.js";
|
import { db } from "../db.js";
|
||||||
import { env } from "../env.js";
|
import { env } from "../env.js";
|
||||||
|
import { logger } from "../logger.js";
|
||||||
import { sessionManager } from "../whatsapp/session-manager.js";
|
import { sessionManager } from "../whatsapp/session-manager.js";
|
||||||
import { writeAuditLog } from "../audit.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
|
async function findOperator(ctx: Context) {
|
||||||
// g:<uuid> → list groups for this account
|
const tgId = ctx.from?.id;
|
||||||
// u:<uuid> → ask for unpair confirmation
|
if (!tgId) return null;
|
||||||
// uc:<uuid> → confirm unpair (proceed)
|
return db.query.operators.findFirst({
|
||||||
// ux → cancel unpair
|
where: (o, { eq }) => eq(o.telegramUserId, tgId),
|
||||||
// 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;
|
}
|
||||||
|
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
await showMenu(ctx, mainMenu());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showHelpMenu(ctx: Context): Promise<void> {
|
||||||
|
await ctx.answerCallbackQuery();
|
||||||
|
await showMenu(ctx, helpMenu());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showAccountsMenu(ctx: Context): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const op = await findOperator(ctx);
|
||||||
|
if (!op) {
|
||||||
|
await ctx.answerCallbackQuery();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const account = await db.query.whatsappAccounts.findFirst({
|
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;
|
if (!account) {
|
||||||
return { account, operatorRow };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleGroupsCallback(ctx: Context, accountId: string): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
const found = await findAccountForOperator(ctx, accountId);
|
|
||||||
if (!found) {
|
|
||||||
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
|
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -79,41 +120,20 @@ export async function handleUnpairConfirmCallback(ctx: Context, accountId: strin
|
|||||||
.set({ status: "logged_out", phoneNumber: null })
|
.set({ status: "logged_out", phoneNumber: null })
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
await writeAuditLog(db, {
|
await writeAuditLog(db, {
|
||||||
operatorId: found.operatorRow.id,
|
operatorId: op.id,
|
||||||
source: "telegram",
|
source: "telegram",
|
||||||
action: "account.unpaired",
|
action: "account.unpaired",
|
||||||
targetType: "whatsapp_account",
|
targetType: "whatsapp_account",
|
||||||
targetId: accountId,
|
targetId: accountId,
|
||||||
payload: { label: found.account.label, via: "callback" },
|
payload: { label: account.label, via: "menu" },
|
||||||
});
|
});
|
||||||
await ctx.answerCallbackQuery({ text: "Unpaired." });
|
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<void> {
|
export async function showPairPrompt(ctx: Context): Promise<void> {
|
||||||
await ctx.answerCallbackQuery({ text: "Cancelled." });
|
|
||||||
await ctx.editMessageText("Cancelled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleMenuPairCallback(ctx: Context): Promise<void> {
|
|
||||||
await ctx.answerCallbackQuery();
|
await ctx.answerCallbackQuery();
|
||||||
await ctx.reply(
|
const userId = ctx.from?.id;
|
||||||
"📡 To pair a new WhatsApp account:\n\n" +
|
if (userId) setPendingPairLabel(userId);
|
||||||
"Send `/pair YourLabel` (e.g. `/pair Sales 1`).\n\n" +
|
await showMenu(ctx, pairPromptMenu());
|
||||||
"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<void> {
|
|
||||||
await ctx.answerCallbackQuery();
|
|
||||||
await ctx.reply(
|
|
||||||
"Available commands:\n\n" +
|
|
||||||
"/start — show the welcome menu\n" +
|
|
||||||
"/help — show this help\n" +
|
|
||||||
"/pair <label> — pair a new WhatsApp account\n" +
|
|
||||||
"/unpair <label> — disconnect and forget a paired account\n" +
|
|
||||||
"/accounts — list paired accounts (with action buttons)\n" +
|
|
||||||
"/groups <label> — list groups for a given account",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import { sessionManager } from "../../whatsapp/session-manager.js";
|
|||||||
import { renderQrPng } from "../../whatsapp/qr-renderer.js";
|
import { renderQrPng } from "../../whatsapp/qr-renderer.js";
|
||||||
import { syncGroupsForAccount } from "../../whatsapp/group-sync.js";
|
import { syncGroupsForAccount } from "../../whatsapp/group-sync.js";
|
||||||
import { writeAuditLog } from "../../audit.js";
|
import { writeAuditLog } from "../../audit.js";
|
||||||
|
import { setPendingPairLabel } from "../state.js";
|
||||||
|
import { InlineKeyboard } from "grammy";
|
||||||
|
|
||||||
// Per-account state for the pairing flow. Re-running /pair for the same
|
// Per-account state for the pairing flow. Re-running /pair for the same
|
||||||
// account tears down the previous flow before starting a new one so we never
|
// account tears down the previous flow before starting a new one so we never
|
||||||
@ -40,10 +42,23 @@ export async function handlePair(ctx: Context): Promise<void> {
|
|||||||
.trim()
|
.trim()
|
||||||
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
|
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
|
||||||
if (!label) {
|
if (!label) {
|
||||||
await ctx.reply('Usage: /pair "Account Label"');
|
// No label after /pair — set pending state and prompt the operator to
|
||||||
|
// reply with a label as a regular message.
|
||||||
|
const tgId = ctx.from?.id;
|
||||||
|
if (tgId !== undefined) setPendingPairLabel(tgId);
|
||||||
|
const kb = new InlineKeyboard().text("⬅ Cancel", "m:main");
|
||||||
|
await ctx.reply(
|
||||||
|
"📡 *Pair a new account*\n\n" +
|
||||||
|
"What name should I give this WhatsApp account?\n\n" +
|
||||||
|
"Reply to this message with a short label, e.g. `Sales 1`.",
|
||||||
|
{ reply_markup: kb, parse_mode: "Markdown" },
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await executePairFlow(ctx, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executePairFlow(ctx: Context, label: string): Promise<void> {
|
||||||
const operatorId = ctx.from?.id;
|
const operatorId = ctx.from?.id;
|
||||||
if (!operatorId) return;
|
if (!operatorId) return;
|
||||||
|
|
||||||
@ -114,9 +129,6 @@ export async function handlePair(ctx: Context): Promise<void> {
|
|||||||
qrMessageIdByAccount.delete(id);
|
qrMessageIdByAccount.delete(id);
|
||||||
lastQrPayloadByAccount.delete(id);
|
lastQrPayloadByAccount.delete(id);
|
||||||
offByAccount.delete(id);
|
offByAccount.delete(id);
|
||||||
await ctx.reply(
|
|
||||||
`✅ "${label}" connected${event.phoneNumber ? ` as +${event.phoneNumber}` : ""}.`,
|
|
||||||
);
|
|
||||||
await writeAuditLog(db, {
|
await writeAuditLog(db, {
|
||||||
operatorId: operatorRow.id,
|
operatorId: operatorRow.id,
|
||||||
source: "telegram",
|
source: "telegram",
|
||||||
@ -126,16 +138,27 @@ export async function handlePair(ctx: Context): Promise<void> {
|
|||||||
payload: { label },
|
payload: { label },
|
||||||
});
|
});
|
||||||
const session = sessionManager.getSession(id);
|
const session = sessionManager.getSession(id);
|
||||||
|
let syncedCount = 0;
|
||||||
if (session) {
|
if (session) {
|
||||||
const result = await syncGroupsForAccount(id, session.socket);
|
const result = await syncGroupsForAccount(id, session.socket);
|
||||||
await ctx.reply(`Synced ${result.synced} groups. Ready to send reminders.`);
|
syncedCount = result.synced;
|
||||||
}
|
}
|
||||||
|
const phoneText = event.phoneNumber ? ` as +${event.phoneNumber}` : "";
|
||||||
|
const kb = new InlineKeyboard()
|
||||||
|
.text("📂 View Groups", `g:${id}`)
|
||||||
|
.row()
|
||||||
|
.text("⬅ Main Menu", "m:main");
|
||||||
|
await ctx.reply(
|
||||||
|
`✅ *${label}* connected${phoneText}.\n\nSynced ${syncedCount} group${syncedCount === 1 ? "" : "s"}.`,
|
||||||
|
{ reply_markup: kb, parse_mode: "Markdown" },
|
||||||
|
);
|
||||||
off();
|
off();
|
||||||
} else if (event.type === "close" && event.loggedOut) {
|
} else if (event.type === "close" && event.loggedOut) {
|
||||||
qrMessageIdByAccount.delete(id);
|
qrMessageIdByAccount.delete(id);
|
||||||
lastQrPayloadByAccount.delete(id);
|
lastQrPayloadByAccount.delete(id);
|
||||||
offByAccount.delete(id);
|
offByAccount.delete(id);
|
||||||
await ctx.reply(`⚠️ Pairing failed (logged out).`);
|
const kb = new InlineKeyboard().text("⬅ Main Menu", "m:main");
|
||||||
|
await ctx.reply(`⚠️ Pairing failed (logged out).`, { reply_markup: kb });
|
||||||
off();
|
off();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
186
apps/bot/src/telegram/menus.ts
Normal file
186
apps/bot/src/telegram/menus.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { InlineKeyboard } from "grammy";
|
||||||
|
import { db } from "../db.js";
|
||||||
|
import { sessionManager } from "../whatsapp/session-manager.js";
|
||||||
|
|
||||||
|
// BotFather-style navigation: every leaf has a way home, every branch shows
|
||||||
|
// you where you are. All callbacks edit the same message.
|
||||||
|
|
||||||
|
// Callback data scheme (kept short to stay under Telegram's 64-byte limit):
|
||||||
|
// m:main — top-level menu
|
||||||
|
// m:accounts — accounts list
|
||||||
|
// m:help — help text
|
||||||
|
// m:pair — prompt for new account label
|
||||||
|
// acc:<id> — single account view
|
||||||
|
// g:<id> — groups list for account
|
||||||
|
// u:<id> — unpair confirm prompt
|
||||||
|
// uc:<id> — unpair execute
|
||||||
|
// ux:<id> — cancel unpair, go back to account view
|
||||||
|
|
||||||
|
export type MenuView = {
|
||||||
|
text: string;
|
||||||
|
keyboard: InlineKeyboard;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mainMenu(): MenuView {
|
||||||
|
const keyboard = new InlineKeyboard()
|
||||||
|
.text("📒 Accounts", "m:accounts")
|
||||||
|
.text("📡 Pair New", "m:pair")
|
||||||
|
.row()
|
||||||
|
.text("❓ Help", "m:help");
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
"👋 *cm WhatsApp Reminder Bot*\n\n" +
|
||||||
|
"What would you like to do?",
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function helpMenu(): MenuView {
|
||||||
|
const keyboard = new InlineKeyboard().text("⬅ Main Menu", "m:main");
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
"*Available actions:*\n\n" +
|
||||||
|
"📒 *Accounts* — list paired WhatsApp accounts and act on each one\n" +
|
||||||
|
"📡 *Pair New* — link a new WhatsApp account via QR code\n" +
|
||||||
|
"❓ *Help* — this screen\n\n" +
|
||||||
|
"Type /start or /menu anytime to come back here.",
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pairPromptMenu(): MenuView {
|
||||||
|
const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:main");
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
"📡 *Pair a new account*\n\n" +
|
||||||
|
"What name should I give this WhatsApp account?\n\n" +
|
||||||
|
"Reply to this message with a short label, e.g. `Sales 1`.\n\n" +
|
||||||
|
"(Or tap *Cancel* to go back.)",
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accountsMenu(operatorId: string): Promise<MenuView> {
|
||||||
|
const accounts = await db.query.whatsappAccounts.findMany({
|
||||||
|
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||||
|
orderBy: (a, { asc }) => [asc(a.label)],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
const keyboard = new InlineKeyboard()
|
||||||
|
.text("📡 Pair New", "m:pair")
|
||||||
|
.row()
|
||||||
|
.text("⬅ Main Menu", "m:main");
|
||||||
|
return {
|
||||||
|
text: "📒 *Accounts*\n\nNo accounts paired yet.",
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyboard = new InlineKeyboard();
|
||||||
|
for (const a of accounts) {
|
||||||
|
keyboard.text(`📒 ${a.label}`, `acc:${a.id}`).row();
|
||||||
|
}
|
||||||
|
keyboard.text("📡 Pair New", "m:pair").row().text("⬅ Main Menu", "m:main");
|
||||||
|
|
||||||
|
const lines = accounts.map((a) => {
|
||||||
|
const live = sessionManager.getState(a.id);
|
||||||
|
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
|
||||||
|
return `• *${a.label}*${phone} — ${a.status} (live: ${live})`;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
text: `📒 *Paired accounts:*\n\n${lines.join("\n")}\n\nTap an account to view its actions.`,
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function accountDetailMenu(
|
||||||
|
operatorId: string,
|
||||||
|
accountId: string,
|
||||||
|
): Promise<MenuView | null> {
|
||||||
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
|
||||||
|
});
|
||||||
|
if (!account) return null;
|
||||||
|
const live = sessionManager.getState(accountId);
|
||||||
|
const phone = account.phoneNumber ? ` (+${account.phoneNumber})` : "";
|
||||||
|
|
||||||
|
const keyboard = new InlineKeyboard()
|
||||||
|
.text("📂 Groups", `g:${accountId}`)
|
||||||
|
.text("🗑 Unpair", `u:${accountId}`)
|
||||||
|
.row()
|
||||||
|
.text("⬅ Accounts", "m:accounts")
|
||||||
|
.text("⬅ Main Menu", "m:main");
|
||||||
|
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
`📒 *${account.label}*${phone}\n\n` +
|
||||||
|
`db status: \`${account.status}\`\n` +
|
||||||
|
`live status: \`${live}\`\n\n` +
|
||||||
|
"What would you like to do?",
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function groupsListMenu(
|
||||||
|
operatorId: string,
|
||||||
|
accountId: string,
|
||||||
|
): Promise<MenuView | null> {
|
||||||
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
|
||||||
|
});
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
const groups = await db.query.whatsappGroups.findMany({
|
||||||
|
where: (g, { eq }) => eq(g.accountId, accountId),
|
||||||
|
orderBy: (g, { asc }) => [asc(g.name)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const keyboard = new InlineKeyboard()
|
||||||
|
.text("⬅ Account", `acc:${accountId}`)
|
||||||
|
.text("⬅ Main Menu", "m:main");
|
||||||
|
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return {
|
||||||
|
text: `👥 *Groups in ${account.label}*\n\nNo groups synced yet.`,
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const lines = groups
|
||||||
|
.slice(0, 50)
|
||||||
|
.map((g) => `• ${g.name} (${g.participantCount})`);
|
||||||
|
const overflow = groups.length > 50 ? `\n…and ${groups.length - 50} more` : "";
|
||||||
|
return {
|
||||||
|
text: `👥 *Groups in ${account.label}*\n\n${lines.join("\n")}${overflow}`,
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unpairConfirmMenu(
|
||||||
|
operatorId: string,
|
||||||
|
accountId: string,
|
||||||
|
): Promise<MenuView | null> {
|
||||||
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
|
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
|
||||||
|
});
|
||||||
|
if (!account) return null;
|
||||||
|
const keyboard = new InlineKeyboard()
|
||||||
|
.text("✅ Yes, unpair", `uc:${accountId}`)
|
||||||
|
.text("⬅ Cancel", `acc:${accountId}`);
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
`🗑 *Unpair ${account.label}?*\n\n` +
|
||||||
|
"The session files will be deleted and you'll need to re-scan a QR code if you want this account back.",
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unpairDoneMenu(label: string): MenuView {
|
||||||
|
const keyboard = new InlineKeyboard()
|
||||||
|
.text("⬅ Accounts", "m:accounts")
|
||||||
|
.text("⬅ Main Menu", "m:main");
|
||||||
|
return {
|
||||||
|
text: `🗑 *${label}* unpaired. Session files deleted.`,
|
||||||
|
keyboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
apps/bot/src/telegram/state.ts
Normal file
23
apps/bot/src/telegram/state.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
// Per-user conversation state for menu-driven flows.
|
||||||
|
// Currently tracks: "operator clicked Pair New, waiting for them to type the label".
|
||||||
|
// In-memory only — fine for a single-instance bot. If we ever scale horizontally,
|
||||||
|
// move this to Postgres.
|
||||||
|
|
||||||
|
const PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
const pendingPairLabel = new Map<number, number>(); // userId → expires_at_ms
|
||||||
|
|
||||||
|
export function setPendingPairLabel(userId: number): void {
|
||||||
|
pendingPairLabel.set(userId, Date.now() + PENDING_TTL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingPairLabel(userId: number): void {
|
||||||
|
pendingPairLabel.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePendingPairLabel(userId: number): boolean {
|
||||||
|
const expiresAt = pendingPairLabel.get(userId);
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
pendingPairLabel.delete(userId);
|
||||||
|
return Date.now() < expiresAt;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user