diff --git a/apps/bot/src/telegram/callbacks.ts b/apps/bot/src/telegram/callbacks.ts index ef2535e..008d89b 100644 --- a/apps/bot/src/telegram/callbacks.ts +++ b/apps/bot/src/telegram/callbacks.ts @@ -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 { + // 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 { } 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, diff --git a/apps/bot/src/telegram/menus.ts b/apps/bot/src/telegram/menus.ts index 245b464..bf22081 100644 --- a/apps/bot/src/telegram/menus.ts +++ b/apps/bot/src/telegram/menus.ts @@ -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, }; }