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
// 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: "Markdown",
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: "Markdown",
parse_mode: parseMode,
});
}
}
@ -457,7 +460,7 @@ export async function showWizardConfirm(ctx: Context): Promise<void> {
}
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)");
const body = w.text || (w.mediaId ? `(media${w.caption ? ` ${w.caption}` : ""})` : "(empty)");
await showMenu(ctx, reminderConfirmMenu({
accountLabel: account.label,
groupName: group.name,

View File

@ -21,6 +21,12 @@ import { DateTime } from "luxon";
export type MenuView = {
text: string;
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 {
@ -283,7 +289,7 @@ export async function reminderDetailMenu(
? DateTime.fromJSDate(rem.scheduledAt).setZone(operatorTimezone).toFormat("yyyy-MM-dd HH:mm")
: "—";
const messagePreview = rem.messages
.map((m) => (m.kind === "text" ? m.textContent : `[${m.kind}]`))
.map((m) => (m.kind === "text" ? m.textContent : `(${m.kind})`))
.filter(Boolean)
.join("\n");
@ -293,14 +299,17 @@ export async function reminderDetailMenu(
.text("⬅ Reminders", "m:reminders")
.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 {
text:
`📅 *${rem.name}*\n\n` +
`When: \`${when}\` (${rem.timezone})\n` +
`Status: \`${rem.status}\`\n` +
`📅 ${rem.name}\n\n` +
`When: ${when} (${rem.timezone})\n` +
`Status: ${rem.status}\n` +
`Targets: ${rem.targets.length}\n\n` +
`*Body:*\n${messagePreview || "(empty)"}`,
`Body:\n${messagePreview || "(empty)"}`,
keyboard,
parseMode: undefined,
};
}
@ -455,13 +464,17 @@ export function reminderConfirmMenu(summary: {
const keyboard = new InlineKeyboard()
.text("✅ Schedule", "rm_save")
.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 {
text:
"*Review*\n\n" +
"Review\n\n" +
`Account: ${summary.accountLabel}\n` +
`Group: ${summary.groupName}\n` +
`When: ${summary.whenLocal}\n\n` +
`*Body:*\n${summary.body}`,
`Body:\n${summary.body}`,
keyboard,
parseMode: undefined,
};
}