feat(bot): tap-to-send test message from groups menu

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.
This commit is contained in:
yiekheng 2026-05-09 16:46:22 +08:00
parent 7b0c8c47e2
commit 3c4eedff03
5 changed files with 215 additions and 12 deletions

View File

@ -16,8 +16,16 @@ import {
showUnpairConfirm, showUnpairConfirm,
executeUnpair, executeUnpair,
showPairPrompt, showPairPrompt,
showGroupDetail,
showSendTestPrompt,
executeSendTest,
} from "./callbacks.js"; } from "./callbacks.js";
import { consumePendingPairLabel, clearPendingPairLabel } from "./state.js"; import {
consumePendingPairLabel,
clearPendingPairLabel,
consumePendingSendToGroup,
clearPendingSendToGroup,
} 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);
@ -28,7 +36,10 @@ export function createTelegramBot(): Bot {
// Slash commands. /start and /menu both open the main menu. // Slash commands. /start and /menu both open the main menu.
bot.command(["start", "menu"], async (ctx) => { bot.command(["start", "menu"], async (ctx) => {
const tgId = ctx.from?.id; const tgId = ctx.from?.id;
if (tgId !== undefined) clearPendingPairLabel(tgId); if (tgId !== undefined) {
clearPendingPairLabel(tgId);
clearPendingSendToGroup(tgId);
}
await showMainMenu(ctx); await showMainMenu(ctx);
}); });
bot.command("help", handleHelp); bot.command("help", handleHelp);
@ -43,7 +54,10 @@ export function createTelegramBot(): Bot {
// 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) => { bot.callbackQuery("m:main", async (ctx) => {
const tgId = ctx.from?.id; const tgId = ctx.from?.id;
if (tgId !== undefined) clearPendingPairLabel(tgId); if (tgId !== undefined) {
clearPendingPairLabel(tgId);
clearPendingSendToGroup(tgId);
}
await ctx.answerCallbackQuery(); await ctx.answerCallbackQuery();
await showMainMenu(ctx); await showMainMenu(ctx);
}); });
@ -62,6 +76,12 @@ export function createTelegramBot(): Bot {
bot.callbackQuery(/^uc:(.+)$/, async (ctx) => { bot.callbackQuery(/^uc:(.+)$/, async (ctx) => {
await executeUnpair(ctx, ctx.match[1]!); 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 // 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 // (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 if (text.startsWith("/")) return; // commands are handled above
const tgId = ctx.from?.id; const tgId = ctx.from?.id;
if (tgId === undefined) return; if (tgId === undefined) return;
// Pending "Pair New" label
if (consumePendingPairLabel(tgId)) { if (consumePendingPairLabel(tgId)) {
const label = text.trim().replace(/^["'“”‘’]|["'“”‘’]$/g, ""); const label = text.trim().replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) { if (!label) {
@ -80,6 +102,19 @@ export function createTelegramBot(): Bot {
await executePairFlow(ctx, label); await executePairFlow(ctx, label);
return; 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."); await ctx.reply("Tap /menu to see what I can do.");
}); });

View File

@ -8,7 +8,8 @@ import { env } from "../env.js";
import { logger } from "../logger.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 { setPendingPairLabel, setPendingSendToGroup } from "./state.js";
import { sendTextToGroup } from "../whatsapp/sender.js";
import { import {
mainMenu, mainMenu,
helpMenu, helpMenu,
@ -16,6 +17,9 @@ import {
accountsMenu, accountsMenu,
accountDetailMenu, accountDetailMenu,
groupsListMenu, groupsListMenu,
groupDetailMenu,
sendTestPromptMenu,
sendTestDoneMenu,
unpairConfirmMenu, unpairConfirmMenu,
unpairDoneMenu, unpairDoneMenu,
type MenuView, type MenuView,
@ -137,3 +141,79 @@ export async function showPairPrompt(ctx: Context): Promise<void> {
if (userId) setPendingPairLabel(userId); if (userId) setPendingPairLabel(userId);
await showMenu(ctx, pairPromptMenu()); await showMenu(ctx, pairPromptMenu());
} }
export async function showGroupDetail(ctx: Context, groupId: string): Promise<void> {
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<void> {
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<void> {
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" });
}
}

View File

@ -136,9 +136,15 @@ export async function groupsListMenu(
orderBy: (g, { asc }) => [asc(g.name)], orderBy: (g, { asc }) => [asc(g.name)],
}); });
const keyboard = new InlineKeyboard() const keyboard = new InlineKeyboard();
.text("⬅ Account", `acc:${accountId}`) // One button per group (truncate to 30 to stay under Telegram's 100-button
.text("⬅ Main Menu", "m:main"); // 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) { if (groups.length === 0) {
return { return {
@ -146,12 +152,64 @@ export async function groupsListMenu(
keyboard, keyboard,
}; };
} }
const lines = groups
.slice(0, 50) const overflow = groups.length > 30 ? `\n\n_…${groups.length - 30} more not shown_` : "";
.map((g) => `${g.name} (${g.participantCount})`);
const overflow = groups.length > 50 ? `\n…and ${groups.length - 50} more` : "";
return { 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<MenuView | null> {
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, keyboard,
}; };
} }

View File

@ -21,3 +21,23 @@ export function consumePendingPairLabel(userId: number): boolean {
pendingPairLabel.delete(userId); pendingPairLabel.delete(userId);
return Date.now() < expiresAt; return Date.now() < expiresAt;
} }
// "Send a test message to this WhatsApp group" pending state.
type PendingSend = { groupId: string; expiresAt: number };
const pendingSendToGroup = new Map<number, PendingSend>();
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;
}

View File

@ -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 };
}