Skip to content

Commit 5632f2f

Browse files
committed
fix(bot): retry MarkdownV2 send/edit with escaped reserved characters
1 parent 78034d4 commit 5632f2f

2 files changed

Lines changed: 107 additions & 6 deletions

File tree

src/bot/utils/send-with-markdown-fallback.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
3131
"bad request: can't parse",
3232
];
3333

34+
const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
35+
36+
function escapeTelegramMarkdownV2(text: string): string {
37+
return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1");
38+
}
39+
3440
function stripMarkdownFormattingOptions<
3541
T extends TelegramSendMessageOptions | TelegramEditMessageOptions | undefined,
3642
>(options: T): T {
@@ -115,6 +121,30 @@ export async function sendMessageWithMarkdownFallback({
115121
throw error;
116122
}
117123

124+
if (parseMode === "MarkdownV2") {
125+
const escapedText = escapeTelegramMarkdownV2(text);
126+
if (escapedText !== text) {
127+
logger.warn(
128+
"[Bot] Markdown parse failed, retrying assistant message with escaped MarkdownV2",
129+
error,
130+
);
131+
132+
try {
133+
return await api.sendMessage(chatId, escapedText, markdownOptions);
134+
} catch (escapedError) {
135+
if (!isTelegramMarkdownParseError(escapedError)) {
136+
throw escapedError;
137+
}
138+
139+
logger.warn(
140+
"[Bot] Escaped Markdown parse failed, retrying assistant message in raw mode",
141+
escapedError,
142+
);
143+
return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
144+
}
145+
}
146+
}
147+
118148
logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
119149
return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
120150
}
@@ -146,6 +176,35 @@ export async function editMessageWithMarkdownFallback({
146176
throw error;
147177
}
148178

179+
if (parseMode === "MarkdownV2") {
180+
const escapedText = escapeTelegramMarkdownV2(text);
181+
if (escapedText !== text) {
182+
logger.warn(
183+
"[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2",
184+
error,
185+
);
186+
187+
try {
188+
return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
189+
} catch (escapedError) {
190+
if (!isTelegramMarkdownParseError(escapedError)) {
191+
throw escapedError;
192+
}
193+
194+
logger.warn(
195+
"[Bot] Escaped Markdown parse failed, retrying edited message in raw mode",
196+
escapedError,
197+
);
198+
return api.editMessageText(
199+
chatId,
200+
messageId,
201+
text,
202+
stripMarkdownFormattingOptions(options),
203+
);
204+
}
205+
}
206+
}
207+
149208
logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
150209
return api.editMessageText(chatId, messageId, text, stripMarkdownFormattingOptions(options));
151210
}

tests/bot/utils/send-with-markdown-fallback.test.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ describe("bot/utils/send-with-markdown-fallback", () => {
4444
reply_markup: { keyboard: [] },
4545
parse_mode: "MarkdownV2",
4646
});
47-
expect(sendMessage).toHaveBeenNthCalledWith(2, 123, "<broken>", {
47+
expect(sendMessage).toHaveBeenNthCalledWith(2, 123, "<broken\\>", {
4848
reply_markup: { keyboard: [] },
49+
parse_mode: "MarkdownV2",
4950
});
5051
});
5152

@@ -55,6 +56,9 @@ describe("bot/utils/send-with-markdown-fallback", () => {
5556
.mockRejectedValueOnce(
5657
new Error("Bad Request: can't parse entities: Character '+' is reserved"),
5758
)
59+
.mockRejectedValueOnce(
60+
new Error("Bad Request: can't parse entities: Character '+' is reserved"),
61+
)
5862
.mockResolvedValueOnce(undefined);
5963

6064
await sendMessageWithMarkdownFallback({
@@ -69,17 +73,46 @@ describe("bot/utils/send-with-markdown-fallback", () => {
6973
parseMode: "MarkdownV2",
7074
});
7175

72-
expect(sendMessage).toHaveBeenCalledTimes(2);
76+
expect(sendMessage).toHaveBeenCalledTimes(3);
7377
expect(sendMessage).toHaveBeenNthCalledWith(1, 777, "a+b", {
7478
reply_markup: { keyboard: [] },
7579
parse_mode: "MarkdownV2",
7680
entities: [],
7781
});
78-
expect(sendMessage).toHaveBeenNthCalledWith(2, 777, "a+b", {
82+
expect(sendMessage).toHaveBeenNthCalledWith(2, 777, "a\\+b", {
83+
reply_markup: { keyboard: [] },
84+
parse_mode: "MarkdownV2",
85+
entities: [],
86+
});
87+
expect(sendMessage).toHaveBeenNthCalledWith(3, 777, "a+b", {
7988
reply_markup: { keyboard: [] },
8089
});
8190
});
8291

92+
it("retries with escaped MarkdownV2 for reserved parentheses", async () => {
93+
const sendMessage = vi
94+
.fn()
95+
.mockRejectedValueOnce(
96+
new Error("Bad Request: can't parse entities: Character '(' is reserved"),
97+
)
98+
.mockResolvedValueOnce(undefined);
99+
100+
await sendMessageWithMarkdownFallback({
101+
api: { sendMessage },
102+
chatId: 888,
103+
text: "Cost (USD)",
104+
parseMode: "MarkdownV2",
105+
});
106+
107+
expect(sendMessage).toHaveBeenCalledTimes(2);
108+
expect(sendMessage).toHaveBeenNthCalledWith(1, 888, "Cost (USD)", {
109+
parse_mode: "MarkdownV2",
110+
});
111+
expect(sendMessage).toHaveBeenNthCalledWith(2, 888, "Cost \\(USD\\)", {
112+
parse_mode: "MarkdownV2",
113+
});
114+
});
115+
83116
it("does not swallow non-markdown Telegram errors", async () => {
84117
const sendMessage = vi
85118
.fn()
@@ -167,8 +200,9 @@ describe("bot/utils/send-with-markdown-fallback", () => {
167200
reply_markup: { inline_keyboard: [] },
168201
parse_mode: "MarkdownV2",
169202
});
170-
expect(editMessageText).toHaveBeenNthCalledWith(2, 42, 8, "<broken>", {
203+
expect(editMessageText).toHaveBeenNthCalledWith(2, 42, 8, "<broken\\>", {
171204
reply_markup: { inline_keyboard: [] },
205+
parse_mode: "MarkdownV2",
172206
});
173207
});
174208

@@ -178,6 +212,9 @@ describe("bot/utils/send-with-markdown-fallback", () => {
178212
.mockRejectedValueOnce(
179213
new Error("Bad Request: can't parse entities: Character '+' is reserved"),
180214
)
215+
.mockRejectedValueOnce(
216+
new Error("Bad Request: can't parse entities: Character '+' is reserved"),
217+
)
181218
.mockResolvedValueOnce(undefined);
182219

183220
await editMessageWithMarkdownFallback({
@@ -193,13 +230,18 @@ describe("bot/utils/send-with-markdown-fallback", () => {
193230
parseMode: "MarkdownV2",
194231
});
195232

196-
expect(editMessageText).toHaveBeenCalledTimes(2);
233+
expect(editMessageText).toHaveBeenCalledTimes(3);
197234
expect(editMessageText).toHaveBeenNthCalledWith(1, 501, 902, "a+b", {
198235
reply_markup: { inline_keyboard: [] },
199236
parse_mode: "MarkdownV2",
200237
entities: [],
201238
});
202-
expect(editMessageText).toHaveBeenNthCalledWith(2, 501, 902, "a+b", {
239+
expect(editMessageText).toHaveBeenNthCalledWith(2, 501, 902, "a\\+b", {
240+
reply_markup: { inline_keyboard: [] },
241+
parse_mode: "MarkdownV2",
242+
entities: [],
243+
});
244+
expect(editMessageText).toHaveBeenNthCalledWith(3, 501, 902, "a+b", {
203245
reply_markup: { inline_keyboard: [] },
204246
});
205247
});

0 commit comments

Comments
 (0)