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:
parent
7b0c8c47e2
commit
3c4eedff03
@ -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.");
|
||||
});
|
||||
|
||||
|
||||
@ -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<void> {
|
||||
if (userId) setPendingPairLabel(userId);
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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<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;
|
||||
}
|
||||
|
||||
10
apps/bot/src/whatsapp/sender.ts
Normal file
10
apps/bot/src/whatsapp/sender.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user