From 3c4eedff0335eec550f361fc7ff9360d7b2a6dab Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 16:46:22 +0800 Subject: [PATCH] feat(bot): tap-to-send test message from groups menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each entry in the groups list is now a button. Tapping shows a group detail view with [πŸ“ Send Test Text]. Operator replies with the message body and the bot sends it to the selected WhatsApp group via the live Baileys session, records the action in audit_log, and shows success/failure inline. This is a small forerunner of the full reminder send pipeline that plan 2 will build out (with media, scheduling, retries). Useful right now to validate the end-to-end Telegram-to-WhatsApp send path during pairing tests. --- apps/bot/src/telegram/bot.ts | 41 +++++++++++++-- apps/bot/src/telegram/callbacks.ts | 82 +++++++++++++++++++++++++++++- apps/bot/src/telegram/menus.ts | 74 ++++++++++++++++++++++++--- apps/bot/src/telegram/state.ts | 20 ++++++++ apps/bot/src/whatsapp/sender.ts | 10 ++++ 5 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 apps/bot/src/whatsapp/sender.ts diff --git a/apps/bot/src/telegram/bot.ts b/apps/bot/src/telegram/bot.ts index ce287bc..8ddae28 100644 --- a/apps/bot/src/telegram/bot.ts +++ b/apps/bot/src/telegram/bot.ts @@ -16,8 +16,16 @@ import { showUnpairConfirm, executeUnpair, showPairPrompt, + showGroupDetail, + showSendTestPrompt, + executeSendTest, } from "./callbacks.js"; -import { consumePendingPairLabel, clearPendingPairLabel } from "./state.js"; +import { + consumePendingPairLabel, + clearPendingPairLabel, + consumePendingSendToGroup, + clearPendingSendToGroup, +} from "./state.js"; export function createTelegramBot(): Bot { const bot = new Bot(env.TELEGRAM_BOT_TOKEN); @@ -28,7 +36,10 @@ export function createTelegramBot(): Bot { // 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); + if (tgId !== undefined) { + clearPendingPairLabel(tgId); + clearPendingSendToGroup(tgId); + } await showMainMenu(ctx); }); bot.command("help", handleHelp); @@ -43,7 +54,10 @@ export function createTelegramBot(): Bot { // 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); + if (tgId !== undefined) { + clearPendingPairLabel(tgId); + clearPendingSendToGroup(tgId); + } await ctx.answerCallbackQuery(); await showMainMenu(ctx); }); @@ -62,6 +76,12 @@ export function createTelegramBot(): Bot { bot.callbackQuery(/^uc:(.+)$/, async (ctx) => { await executeUnpair(ctx, ctx.match[1]!); }); + bot.callbackQuery(/^gr:(.+)$/, async (ctx) => { + await showGroupDetail(ctx, ctx.match[1]!); + }); + bot.callbackQuery(/^st:(.+)$/, async (ctx) => { + await showSendTestPrompt(ctx, ctx.match[1]!); + }); // 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 @@ -71,6 +91,8 @@ export function createTelegramBot(): Bot { if (text.startsWith("/")) return; // commands are handled above const tgId = ctx.from?.id; if (tgId === undefined) return; + + // Pending "Pair New" label if (consumePendingPairLabel(tgId)) { const label = text.trim().replace(/^["'β€œβ€β€˜β€™]|["'β€œβ€β€˜β€™]$/g, ""); if (!label) { @@ -80,6 +102,19 @@ export function createTelegramBot(): Bot { await executePairFlow(ctx, label); return; } + + // Pending "Send Test" message body + const pendingGroupId = consumePendingSendToGroup(tgId); + if (pendingGroupId) { + const body = text.trim(); + if (!body) { + await ctx.reply("Empty message. Tap /menu and try again."); + return; + } + await executeSendTest(ctx, pendingGroupId, body); + return; + } + await ctx.reply("Tap /menu to see what I can do."); }); diff --git a/apps/bot/src/telegram/callbacks.ts b/apps/bot/src/telegram/callbacks.ts index 28ec5a5..f527a82 100644 --- a/apps/bot/src/telegram/callbacks.ts +++ b/apps/bot/src/telegram/callbacks.ts @@ -8,7 +8,8 @@ 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 { setPendingPairLabel, setPendingSendToGroup } from "./state.js"; +import { sendTextToGroup } from "../whatsapp/sender.js"; import { mainMenu, helpMenu, @@ -16,6 +17,9 @@ import { accountsMenu, accountDetailMenu, groupsListMenu, + groupDetailMenu, + sendTestPromptMenu, + sendTestDoneMenu, unpairConfirmMenu, unpairDoneMenu, type MenuView, @@ -137,3 +141,79 @@ export async function showPairPrompt(ctx: Context): Promise { if (userId) setPendingPairLabel(userId); await showMenu(ctx, pairPromptMenu()); } + +export async function showGroupDetail(ctx: Context, groupId: string): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const view = await groupDetailMenu(op.id, groupId); + if (!view) { + await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true }); + return; + } + await showMenu(ctx, view); +} + +export async function showSendTestPrompt(ctx: Context, groupId: string): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + if (!op) return; + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, groupId), + }); + if (!group) { + await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true }); + return; + } + // Verify the group's account belongs to this operator before stashing state. + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, op.id)), + }); + if (!account) { + await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true }); + return; + } + const userId = ctx.from?.id; + if (userId) setPendingSendToGroup(userId, groupId); + await showMenu(ctx, sendTestPromptMenu(group.name)); +} + +export async function executeSendTest( + ctx: Context, + groupId: string, + text: string, +): Promise { + const op = await findOperator(ctx); + if (!op) return; + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, groupId), + }); + if (!group) { + await ctx.reply("Group not found."); + return; + } + const session = sessionManager.getSession(group.accountId); + if (!session) { + await ctx.reply("That account isn't currently connected. Re-pair it first.", { + reply_markup: sendTestDoneMenu(group.name, false, "session not connected").keyboard, + }); + return; + } + try { + const result = await sendTextToGroup(session.socket, group.waGroupJid, text); + await writeAuditLog(db, { + operatorId: op.id, + source: "telegram", + action: "group.send_test", + targetType: "whatsapp_group", + targetId: groupId, + payload: { groupName: group.name, length: text.length, waMessageId: result.messageId ?? null }, + }); + const view = sendTestDoneMenu(group.name, true); + await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); + } catch (err) { + logger.error({ err, groupId }, "send-test: failed"); + const view = sendTestDoneMenu(group.name, false, (err as Error).message); + await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); + } +} diff --git a/apps/bot/src/telegram/menus.ts b/apps/bot/src/telegram/menus.ts index edd451c..bfa43f7 100644 --- a/apps/bot/src/telegram/menus.ts +++ b/apps/bot/src/telegram/menus.ts @@ -136,9 +136,15 @@ export async function groupsListMenu( orderBy: (g, { asc }) => [asc(g.name)], }); - const keyboard = new InlineKeyboard() - .text("β¬… Account", `acc:${accountId}`) - .text("β¬… Main Menu", "m:main"); + const keyboard = new InlineKeyboard(); + // One button per group (truncate to 30 to stay under Telegram's 100-button + // ceiling and keep the message readable). Group name truncated to 32 chars. + const visible = groups.slice(0, 30); + for (const g of visible) { + const name = g.name.length > 32 ? `${g.name.slice(0, 31)}…` : g.name; + keyboard.text(`πŸ‘₯ ${name}`, `gr:${g.id}`).row(); + } + keyboard.text("β¬… Account", `acc:${accountId}`).text("β¬… Main Menu", "m:main"); if (groups.length === 0) { return { @@ -146,12 +152,64 @@ export async function groupsListMenu( keyboard, }; } - const lines = groups - .slice(0, 50) - .map((g) => `β€’ ${g.name} (${g.participantCount})`); - const overflow = groups.length > 50 ? `\n…and ${groups.length - 50} more` : ""; + + const overflow = groups.length > 30 ? `\n\n_…${groups.length - 30} more not shown_` : ""; return { - text: `πŸ‘₯ *Groups in ${account.label}*\n\n${lines.join("\n")}${overflow}`, + text: `πŸ‘₯ *Groups in ${account.label}*\n\nTap a group to send a test message.${overflow}`, + keyboard, + }; +} + +export async function groupDetailMenu( + operatorId: string, + groupId: string, +): Promise { + const group = await db.query.whatsappGroups.findFirst({ + where: (g, { eq }) => eq(g.id, groupId), + }); + if (!group) return null; + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, operatorId)), + }); + if (!account) return null; + + const keyboard = new InlineKeyboard() + .text("πŸ“ Send Test Text", `st:${groupId}`) + .row() + .text("β¬… Groups", `g:${group.accountId}`) + .text("β¬… Main Menu", "m:main"); + + return { + text: + `πŸ‘₯ *${group.name}*\n\n` + + `Account: ${account.label}\n` + + `Members: ${group.participantCount}\n\n` + + "What would you like to do?", + keyboard, + }; +} + +export function sendTestPromptMenu(groupName: string): MenuView { + const keyboard = new InlineKeyboard().text("β¬… Cancel", "m:main"); + return { + text: + `πŸ“ *Send a test message to ${groupName}*\n\n` + + "Reply to this message with the text you want to send.\n\n" + + "(Or tap *Cancel*.)", + keyboard, + }; +} + +export function sendTestDoneMenu(groupName: string, ok: boolean, errorMsg?: string): MenuView { + const keyboard = new InlineKeyboard().text("β¬… Main Menu", "m:main"); + if (ok) { + return { + text: `βœ… Test message sent to *${groupName}*.`, + keyboard, + }; + } + return { + text: `❌ Failed to send to *${groupName}*.\n\n\`${errorMsg ?? "unknown error"}\``, keyboard, }; } diff --git a/apps/bot/src/telegram/state.ts b/apps/bot/src/telegram/state.ts index 5f78a3f..e4ede55 100644 --- a/apps/bot/src/telegram/state.ts +++ b/apps/bot/src/telegram/state.ts @@ -21,3 +21,23 @@ export function consumePendingPairLabel(userId: number): boolean { pendingPairLabel.delete(userId); return Date.now() < expiresAt; } + +// "Send a test message to this WhatsApp group" pending state. +type PendingSend = { groupId: string; expiresAt: number }; +const pendingSendToGroup = new Map(); + +export function setPendingSendToGroup(userId: number, groupId: string): void { + pendingSendToGroup.set(userId, { groupId, expiresAt: Date.now() + PENDING_TTL_MS }); +} + +export function clearPendingSendToGroup(userId: number): void { + pendingSendToGroup.delete(userId); +} + +export function consumePendingSendToGroup(userId: number): string | null { + const pending = pendingSendToGroup.get(userId); + if (!pending) return null; + pendingSendToGroup.delete(userId); + if (Date.now() >= pending.expiresAt) return null; + return pending.groupId; +} diff --git a/apps/bot/src/whatsapp/sender.ts b/apps/bot/src/whatsapp/sender.ts new file mode 100644 index 0000000..a66ada0 --- /dev/null +++ b/apps/bot/src/whatsapp/sender.ts @@ -0,0 +1,10 @@ +import type { WASocket } from "@whiskeysockets/baileys"; + +export async function sendTextToGroup( + socket: WASocket, + groupJid: string, + text: string, +): Promise<{ messageId: string | undefined }> { + const result = await socket.sendMessage(groupJid, { text }); + return { messageId: result?.key?.id ?? undefined }; +}