feat(bot): inline keyboards + Telegram slash menu
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.
This commit is contained in:
parent
ee1113280d
commit
56fd71a6a0
@ -9,6 +9,14 @@ import { handlePair } from "./commands/pair.js";
|
|||||||
import { handleUnpair } from "./commands/unpair.js";
|
import { handleUnpair } from "./commands/unpair.js";
|
||||||
import { handleAccounts } from "./commands/accounts.js";
|
import { handleAccounts } from "./commands/accounts.js";
|
||||||
import { handleGroups } from "./commands/groups.js";
|
import { handleGroups } from "./commands/groups.js";
|
||||||
|
import {
|
||||||
|
handleGroupsCallback,
|
||||||
|
handleUnpairPromptCallback,
|
||||||
|
handleUnpairConfirmCallback,
|
||||||
|
handleUnpairCancelCallback,
|
||||||
|
handleMenuPairCallback,
|
||||||
|
handleMenuHelpCallback,
|
||||||
|
} from "./callbacks.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);
|
||||||
@ -23,9 +31,40 @@ export function createTelegramBot(): Bot {
|
|||||||
bot.command("accounts", handleAccounts);
|
bot.command("accounts", handleAccounts);
|
||||||
bot.command("groups", handleGroups);
|
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) => {
|
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
|
||||||
|
// 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;
|
return bot;
|
||||||
}
|
}
|
||||||
|
|||||||
119
apps/bot/src/telegram/callbacks.ts
Normal file
119
apps/bot/src/telegram/callbacks.ts
Normal file
@ -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:<uuid> → list groups for this account
|
||||||
|
// u:<uuid> → ask for unpair confirmation
|
||||||
|
// uc:<uuid> → 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<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 });
|
||||||
|
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<void> {
|
||||||
|
await ctx.answerCallbackQuery({ text: "Cancelled." });
|
||||||
|
await ctx.editMessageText("Cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleMenuPairCallback(ctx: Context): Promise<void> {
|
||||||
|
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<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",
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Context } from "grammy";
|
import type { Context } from "grammy";
|
||||||
|
import { InlineKeyboard } from "grammy";
|
||||||
import { db } from "../../db.js";
|
import { db } from "../../db.js";
|
||||||
import { sessionManager } from "../../whatsapp/session-manager.js";
|
import { sessionManager } from "../../whatsapp/session-manager.js";
|
||||||
|
|
||||||
@ -17,14 +18,19 @@ export async function handleAccounts(ctx: Context): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) {
|
||||||
await ctx.reply('No accounts paired yet. Use /pair "Label" to add one.');
|
await ctx.reply('No accounts paired yet. Send /pair YourLabel to add one.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = accounts.map((a) => {
|
// One message per account so each gets its own action buttons. Keeps
|
||||||
|
// callback_data short and avoids hitting Telegram's per-message limits.
|
||||||
|
for (const a of accounts) {
|
||||||
const live = sessionManager.getState(a.id);
|
const live = sessionManager.getState(a.id);
|
||||||
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
|
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
|
||||||
return `• ${a.label}${phone} — db:${a.status} live:${live}`;
|
const text = `📒 ${a.label}${phone}\nstatus: ${a.status} (live: ${live})`;
|
||||||
});
|
const kb = new InlineKeyboard()
|
||||||
await ctx.reply(`📒 Paired accounts:\n${lines.join("\n")}`);
|
.text("📂 Groups", `g:${a.id}`)
|
||||||
|
.text("🗑 Unpair", `u:${a.id}`);
|
||||||
|
await ctx.reply(text, { reply_markup: kb });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import type { Context } from "grammy";
|
import type { Context } from "grammy";
|
||||||
|
import { InlineKeyboard } from "grammy";
|
||||||
|
|
||||||
export async function handleStart(ctx: Context): Promise<void> {
|
export async function handleStart(ctx: Context): Promise<void> {
|
||||||
await ctx.reply(
|
const kb = new InlineKeyboard()
|
||||||
"👋 cm WhatsApp Reminder Bot is online.\n\n" +
|
.text("📒 Accounts", "m:accounts")
|
||||||
"Type /help to see available commands.",
|
.text("📡 How to Pair", "m:pair")
|
||||||
);
|
.row()
|
||||||
|
.text("❓ Help", "m:help");
|
||||||
|
await ctx.reply("👋 cm WhatsApp Reminder Bot is online.\n\nWhat would you like to do?", {
|
||||||
|
reply_markup: kb,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user