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
This commit is contained in:
yiekheng 2026-05-09 17:49:00 +08:00
parent a5bbf3a25d
commit 6a221fe043
2 changed files with 26 additions and 10 deletions

View File

@ -52,16 +52,19 @@ async function findOperator(ctx: Context) {
// reply if the previous message can't be edited (e.g. a photo message -- Telegram // 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). // won't let us turn it back into a text message).
async function showMenu(ctx: Context, view: MenuView): Promise<void> { 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 { try {
await ctx.editMessageText(view.text, { await ctx.editMessageText(view.text, {
reply_markup: view.keyboard, reply_markup: view.keyboard,
parse_mode: "Markdown", parse_mode: parseMode,
}); });
} catch (err) { } catch (err) {
logger.debug({ err }, "showMenu: edit failed, sending fresh message"); logger.debug({ err }, "showMenu: edit failed, sending fresh message");
await ctx.reply(view.text, { await ctx.reply(view.text, {
reply_markup: view.keyboard, reply_markup: view.keyboard,
parse_mode: "Markdown", parse_mode: parseMode,
}); });
} }
} }
@ -457,7 +460,7 @@ export async function showWizardConfirm(ctx: Context): Promise<void> {
} }
const tz = op.defaultTimezone ?? DEFAULT_TIMEZONE; const tz = op.defaultTimezone ?? DEFAULT_TIMEZONE;
const whenLocal = DateTime.fromJSDate(w.scheduledAt).setZone(tz).toFormat("yyyy-MM-dd HH:mm"); 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)"); const body = w.text || (w.mediaId ? `(media${w.caption ? ` ${w.caption}` : ""})` : "(empty)");
await showMenu(ctx, reminderConfirmMenu({ await showMenu(ctx, reminderConfirmMenu({
accountLabel: account.label, accountLabel: account.label,
groupName: group.name, groupName: group.name,

View File

@ -21,6 +21,12 @@ import { DateTime } from "luxon";
export type MenuView = { export type MenuView = {
text: string; text: string;
keyboard: InlineKeyboard; keyboard: InlineKeyboard;
/**
* Telegram parse mode. Defaults to "Markdown" for legacy formatting.
* Set to undefined when the text includes user-supplied content that
* could contain `*`, `_`, `` ` ``, `[`, `]` etc. and break the parser.
*/
parseMode?: "Markdown" | "MarkdownV2" | undefined;
}; };
export function mainMenu(): MenuView { export function mainMenu(): MenuView {
@ -283,7 +289,7 @@ export async function reminderDetailMenu(
? DateTime.fromJSDate(rem.scheduledAt).setZone(operatorTimezone).toFormat("yyyy-MM-dd HH:mm") ? DateTime.fromJSDate(rem.scheduledAt).setZone(operatorTimezone).toFormat("yyyy-MM-dd HH:mm")
: "—"; : "—";
const messagePreview = rem.messages const messagePreview = rem.messages
.map((m) => (m.kind === "text" ? m.textContent : `[${m.kind}]`)) .map((m) => (m.kind === "text" ? m.textContent : `(${m.kind})`))
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
@ -293,14 +299,17 @@ export async function reminderDetailMenu(
.text("⬅ Reminders", "m:reminders") .text("⬅ Reminders", "m:reminders")
.text("⬅ Main Menu", "m:main"); .text("⬅ Main Menu", "m:main");
// User-supplied content (rem.name, messagePreview) — render as plain text
// so stray `*` / `_` / `[` don't break Telegram's Markdown parser.
return { return {
text: text:
`📅 *${rem.name}*\n\n` + `📅 ${rem.name}\n\n` +
`When: \`${when}\` (${rem.timezone})\n` + `When: ${when} (${rem.timezone})\n` +
`Status: \`${rem.status}\`\n` + `Status: ${rem.status}\n` +
`Targets: ${rem.targets.length}\n\n` + `Targets: ${rem.targets.length}\n\n` +
`*Body:*\n${messagePreview || "(empty)"}`, `Body:\n${messagePreview || "(empty)"}`,
keyboard, keyboard,
parseMode: undefined,
}; };
} }
@ -455,13 +464,17 @@ export function reminderConfirmMenu(summary: {
const keyboard = new InlineKeyboard() const keyboard = new InlineKeyboard()
.text("✅ Schedule", "rm_save") .text("✅ Schedule", "rm_save")
.text("⬅ Cancel", "m:reminders"); .text("⬅ Cancel", "m:reminders");
// Body is user-supplied — disable Markdown parsing to avoid stray `*`, `_`,
// backticks, or `[` breaking the message. The trade-off is that the labels
// ("Review", "Body") render as plain text, but that's fine for a confirm step.
return { return {
text: text:
"*Review*\n\n" + "Review\n\n" +
`Account: ${summary.accountLabel}\n` + `Account: ${summary.accountLabel}\n` +
`Group: ${summary.groupName}\n` + `Group: ${summary.groupName}\n` +
`When: ${summary.whenLocal}\n\n` + `When: ${summary.whenLocal}\n\n` +
`*Body:*\n${summary.body}`, `Body:\n${summary.body}`,
keyboard, keyboard,
parseMode: undefined,
}; };
} }