yiekheng 6a221fe043 fix(bot): render Review screen as plain text to avoid Markdown parsing errors
The reminder confirm screen was failing with 'can't parse entities' (400)
because the body string included `[media...]` which Telegram's legacy
Markdown mode tries to interpret as a link `[text](url)` and rejects when
the closing `(url)` isn't present. Same risk for any user-typed body
containing `*`, `_`, backticks, or `[`.

Two fixes:
- Add optional parseMode field to MenuView; showMenu honors it
- reminderConfirmMenu and reminderDetailMenu render as plain text
  (parseMode: undefined) since both include user-supplied content
- Replace `[media...]` brackets with `(media...)` parens in the wizard
  body preview so the placeholder itself can't trigger link parsing
2026-05-09 17:49:00 +08:00

506 lines
17 KiB
TypeScript

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 { logger } from "../logger.js";
import { sessionManager } from "../whatsapp/session-manager.js";
import { writeAuditLog } from "../audit.js";
import { setPendingPairLabel, setPendingSendToGroup, startWizard, getWizard, updateWizard, clearWizard } from "./state.js";
import { sendTextToGroup } from "../whatsapp/sender.js";
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
import {
mainMenu,
helpMenu,
pairPromptMenu,
accountsMenu,
accountDetailMenu,
groupsListMenu,
groupDetailMenu,
sendTestPromptMenu,
sendTestDoneMenu,
unpairConfirmMenu,
unpairDoneMenu,
remindersMenu,
reminderDetailMenu,
reminderPickAccountMenu,
reminderPickGroupMenu,
reminderComposeMenu,
reminderTimeMenu,
reminderConfirmMenu,
type MenuView,
} from "./menus.js";
import { createReminder, deleteReminder, getReminderWithDetails } from "../reminders/crud.js";
import { quickToDate, type Quick } from "../reminders/time-parsing.js";
import { scheduleReminderFire, cancelReminderFire } from "../scheduler/reminder-jobs.js";
import { getBoss } from "../scheduler/pgboss-client.js";
import { DEFAULT_TIMEZONE } from "@cmbot/shared";
import { DateTime } from "luxon";
async function findOperator(ctx: Context) {
const tgId = ctx.from?.id;
if (!tgId) return null;
return db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, tgId),
});
}
// 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> {
// Default to Markdown parse mode unless the menu explicitly opts out.
// Views with user-supplied content set `parseMode: undefined` to render plain.
const parseMode = "parseMode" in view ? view.parseMode : "Markdown";
try {
await ctx.editMessageText(view.text, {
reply_markup: view.keyboard,
parse_mode: parseMode,
});
} catch (err) {
logger.debug({ err }, "showMenu: edit failed, sending fresh message");
await ctx.reply(view.text, {
reply_markup: view.keyboard,
parse_mode: parseMode,
});
}
}
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 refreshGroupsList(ctx: Context, accountId: string): Promise<void> {
const op = await findOperator(ctx);
if (!op) {
await ctx.answerCallbackQuery();
return;
}
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
const session = sessionManager.getSession(accountId);
if (!session) {
await ctx.answerCallbackQuery({
text: "Account not connected. Re-pair first.",
show_alert: true,
});
return;
}
await ctx.answerCallbackQuery({ text: "Refreshing…" });
try {
const result = await syncGroupsForAccount(accountId, session.socket);
logger.info({ accountId, count: result.synced }, "refreshGroupsList: ok");
} catch (err) {
logger.error({ err, accountId }, "refreshGroupsList: failed");
}
const view = await groupsListMenu(op.id, accountId);
if (view) 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({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) {
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: op.id,
source: "telegram",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: accountId,
payload: { label: account.label, via: "menu" },
});
await ctx.answerCallbackQuery({ text: "Unpaired." });
await showMenu(ctx, unpairDoneMenu(account.label));
}
export async function showPairPrompt(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
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" });
}
}
export async function showRemindersMenu(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await remindersMenu(op.id, op.defaultTimezone ?? DEFAULT_TIMEZONE);
await showMenu(ctx, view);
}
export async function showReminderDetail(ctx: Context, reminderId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await reminderDetailMenu(reminderId, op.defaultTimezone ?? DEFAULT_TIMEZONE);
if (!view) {
await ctx.answerCallbackQuery({ text: "Reminder not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function deleteReminderCallback(ctx: Context, reminderId: string): Promise<void> {
const op = await findOperator(ctx);
if (!op) {
await ctx.answerCallbackQuery();
return;
}
const rem = await getReminderWithDetails(reminderId);
if (!rem) {
await ctx.answerCallbackQuery({ text: "Reminder not found.", show_alert: true });
return;
}
await deleteReminder(reminderId);
await cancelReminderFire(getBoss(), reminderId);
await writeAuditLog(db, {
operatorId: op.id,
source: "telegram",
action: "reminder.deleted",
targetType: "reminder",
targetId: reminderId,
payload: { name: rem.name },
});
await ctx.answerCallbackQuery({ text: "Deleted." });
await showRemindersMenu(ctx);
}
export async function startReminderWizard(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const userId = ctx.from?.id;
if (!userId) return;
startWizard(userId);
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, op.id),
orderBy: (a, { asc }) => [asc(a.label)],
});
if (accounts.length === 0) {
await showMenu(ctx, {
text: "You need to pair an account before scheduling a reminder.",
keyboard: new InlineKeyboard().text("⬅ Reminders", "m:reminders"),
});
return;
}
await showMenu(ctx, reminderPickAccountMenu(accounts));
}
export async function wizardPickAccount(ctx: Context, accountId: string): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (!userId) return;
updateWizard(userId, { step: "pick_group", accountId });
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq }) => eq(g.accountId, accountId),
orderBy: (g, { asc }) => [asc(g.name)],
});
await showMenu(ctx, reminderPickGroupMenu(groups));
}
export async function wizardPickGroup(ctx: Context, groupId: string): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (!userId) return;
updateWizard(userId, { step: "compose", groupId });
await showMenu(ctx, reminderComposeMenu());
}
export async function wizardSetTimeQuick(ctx: Context, quick: Quick): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (!userId) return;
const w = getWizard(userId);
if (!w) {
await ctx.reply("Wizard expired. Tap /menu to start again.");
return;
}
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const date = quickToDate(quick, tz);
updateWizard(userId, { step: "confirm", scheduledAt: date });
await showWizardConfirm(ctx);
}
export async function wizardSetTimeCustomPrompt(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (!userId) return;
updateWizard(userId, { step: "set_time" });
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { reminderPickDayMenu } = await import("./menus.js");
await showMenu(ctx, reminderPickDayMenu(tz));
}
export async function wizardBackToTimeMenu(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const { reminderTimeMenu } = await import("./menus.js");
await showMenu(ctx, reminderTimeMenu());
}
export async function wizardPickDay(ctx: Context, dayOffset: number): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (!userId) return;
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { reminderPickHourMenu } = await import("./menus.js");
const { formatCustomDay } = await import("../reminders/time-parsing.js");
await showMenu(ctx, reminderPickHourMenu(formatCustomDay(dayOffset, tz), dayOffset));
}
export async function wizardPickHour(
ctx: Context,
dayOffset: number,
hour: number,
): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (!userId) return;
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { reminderPickMinuteMenu } = await import("./menus.js");
const { formatCustomDay } = await import("../reminders/time-parsing.js");
await showMenu(ctx, reminderPickMinuteMenu(formatCustomDay(dayOffset, tz), dayOffset, hour));
}
export async function wizardPickMinute(
ctx: Context,
dayOffset: number,
hour: number,
minute: number,
): Promise<void> {
const userId = ctx.from?.id;
if (!userId) {
await ctx.answerCallbackQuery();
return;
}
const op = await findOperator(ctx);
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
const { buildCustomDate } = await import("../reminders/time-parsing.js");
const result = buildCustomDate(dayOffset, hour, minute, tz);
if (!result.ok) {
await ctx.answerCallbackQuery({ text: result.reason, show_alert: true });
return;
}
await ctx.answerCallbackQuery();
updateWizard(userId, { step: "confirm", scheduledAt: result.date });
await showWizardConfirm(ctx);
}
export async function showWizardConfirm(ctx: Context): Promise<void> {
const userId = ctx.from?.id;
if (!userId) return;
const w = getWizard(userId);
if (!w || !w.accountId || !w.groupId || !w.scheduledAt) {
await ctx.reply("Wizard incomplete. Tap /menu and try again.");
return;
}
const op = await findOperator(ctx);
if (!op) return;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, w.accountId!), eq(a.operatorId, op.id)),
});
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, w.groupId!),
});
if (!account || !group) {
await ctx.reply("Account or group missing. Tap /menu and try again.");
return;
}
const tz = op.defaultTimezone ?? DEFAULT_TIMEZONE;
const whenLocal = DateTime.fromJSDate(w.scheduledAt).setZone(tz).toFormat("yyyy-MM-dd HH:mm");
const body = w.text || (w.mediaId ? `(media${w.caption ? `${w.caption}` : ""})` : "(empty)");
await showMenu(ctx, reminderConfirmMenu({
accountLabel: account.label,
groupName: group.name,
body,
whenLocal: `${whenLocal} (${tz})`,
}));
}
export async function wizardSave(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (!userId) return;
const w = getWizard(userId);
if (!w || !w.accountId || !w.groupId || !w.scheduledAt) {
await ctx.reply("Wizard incomplete. Tap /menu and try again.");
return;
}
const op = await findOperator(ctx);
if (!op) return;
const reminderId = await createReminder({
accountId: w.accountId,
groupId: w.groupId,
name: (w.text ?? w.caption ?? "Reminder").slice(0, 50),
scheduledAt: w.scheduledAt,
text: w.text ?? null,
mediaId: w.mediaId ?? null,
caption: w.caption ?? null,
createdBy: op.id,
timezone: op.defaultTimezone ?? DEFAULT_TIMEZONE,
});
await scheduleReminderFire(getBoss(), reminderId, w.scheduledAt);
await writeAuditLog(db, {
operatorId: op.id,
source: "telegram",
action: "reminder.created",
targetType: "reminder",
targetId: reminderId,
payload: { scheduledAt: w.scheduledAt.toISOString() },
});
clearWizard(userId);
await ctx.reply(`✅ Scheduled. Tap /menu → 📅 Reminders to view.`);
}