Skip to content

Commit 6ff8fa1

Browse files
committed
fix(bot): avoid MarkdownV2 parse errors on escaped chunk boundaries
1 parent b6efc31 commit 6ff8fa1

4 files changed

Lines changed: 147 additions & 13 deletions

File tree

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

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

34+
function stripMarkdownFormattingOptions<
35+
T extends TelegramSendMessageOptions | TelegramEditMessageOptions | undefined,
36+
>(options: T): T {
37+
if (!options) {
38+
return options;
39+
}
40+
41+
const rawOptions = {
42+
...options,
43+
} as NonNullable<T> & {
44+
parse_mode?: unknown;
45+
entities?: unknown;
46+
};
47+
48+
delete rawOptions.parse_mode;
49+
delete rawOptions.entities;
50+
51+
return rawOptions as T;
52+
}
53+
3454
function getErrorText(error: unknown): string {
3555
const parts: string[] = [];
3656

@@ -96,7 +116,7 @@ export async function sendMessageWithMarkdownFallback({
96116
}
97117

98118
logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
99-
return api.sendMessage(chatId, text, options);
119+
return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
100120
}
101121
}
102122

@@ -127,6 +147,6 @@ export async function editMessageWithMarkdownFallback({
127147
}
128148

129149
logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
130-
return api.editMessageText(chatId, messageId, text, options);
150+
return api.editMessageText(chatId, messageId, text, stripMarkdownFormattingOptions(options));
131151
}
132152
}

src/summary/formatter.ts

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,63 @@ import { getCurrentProject } from "../settings/manager.js";
1010
const TELEGRAM_MESSAGE_LIMIT = 4096;
1111
const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
1212

13-
function splitText(text: string, maxLength: number): string[] {
14-
const parts: string[] = [];
15-
let currentIndex = 0;
13+
interface SplitTextOptions {
14+
avoidTrailingMarkdownEscape?: boolean;
15+
}
1616

17-
while (currentIndex < text.length) {
18-
let endIndex = currentIndex + maxLength;
17+
function endsWithOddTrailingBackslashes(text: string, start: number, end: number): boolean {
18+
let backslashCount = 0;
1919

20-
if (endIndex >= text.length) {
21-
parts.push(text.slice(currentIndex));
20+
for (let index = end - 1; index >= start; index--) {
21+
if (text[index] !== "\\") {
2222
break;
2323
}
24+
backslashCount += 1;
25+
}
26+
27+
return backslashCount % 2 === 1;
28+
}
29+
30+
function resolveSplitEndIndex(
31+
text: string,
32+
currentIndex: number,
33+
maxLength: number,
34+
options?: SplitTextOptions,
35+
): number {
36+
const hardLimit = Math.min(text.length, currentIndex + maxLength);
37+
if (hardLimit >= text.length) {
38+
return text.length;
39+
}
40+
41+
let endIndex = hardLimit;
42+
const breakPoint = text.lastIndexOf("\n", endIndex);
43+
if (breakPoint > currentIndex) {
44+
endIndex = breakPoint + 1;
45+
}
2446

25-
const breakPoint = text.lastIndexOf("\n", endIndex);
26-
if (breakPoint > currentIndex) {
27-
endIndex = breakPoint + 1;
47+
if (!options?.avoidTrailingMarkdownEscape) {
48+
return endIndex;
49+
}
50+
51+
while (endIndex > currentIndex && endsWithOddTrailingBackslashes(text, currentIndex, endIndex)) {
52+
endIndex -= 1;
53+
}
54+
55+
return endIndex > currentIndex ? endIndex : hardLimit;
56+
}
57+
58+
function splitText(text: string, maxLength: number, options?: SplitTextOptions): string[] {
59+
const parts: string[] = [];
60+
let currentIndex = 0;
61+
62+
while (currentIndex < text.length) {
63+
const endIndex = resolveSplitEndIndex(text, currentIndex, maxLength, options);
64+
65+
if (endIndex <= currentIndex) {
66+
const fallbackEnd = Math.min(text.length, currentIndex + 1);
67+
parts.push(text.slice(currentIndex, fallbackEnd));
68+
currentIndex = fallbackEnd;
69+
continue;
2870
}
2971

3072
parts.push(text.slice(currentIndex, endIndex));
@@ -252,7 +294,9 @@ export function formatSummaryWithMode(
252294

253295
if (mode === "markdown") {
254296
const converted = formatMarkdownForTelegram(trimmed);
255-
const convertedParts = splitText(converted, normalizedMaxLength);
297+
const convertedParts = splitText(converted, normalizedMaxLength, {
298+
avoidTrailingMarkdownEscape: true,
299+
});
256300

257301
for (const convertedPart of convertedParts) {
258302
const normalizedPart = convertedPart.trim();

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,37 @@ describe("bot/utils/send-with-markdown-fallback", () => {
4949
});
5050
});
5151

52+
it("drops markdown formatting options on send fallback", async () => {
53+
const sendMessage = vi
54+
.fn()
55+
.mockRejectedValueOnce(
56+
new Error("Bad Request: can't parse entities: Character '+' is reserved"),
57+
)
58+
.mockResolvedValueOnce(undefined);
59+
60+
await sendMessageWithMarkdownFallback({
61+
api: { sendMessage },
62+
chatId: 777,
63+
text: "a+b",
64+
options: {
65+
reply_markup: { keyboard: [] },
66+
parse_mode: "MarkdownV2",
67+
entities: [],
68+
},
69+
parseMode: "MarkdownV2",
70+
});
71+
72+
expect(sendMessage).toHaveBeenCalledTimes(2);
73+
expect(sendMessage).toHaveBeenNthCalledWith(1, 777, "a+b", {
74+
reply_markup: { keyboard: [] },
75+
parse_mode: "MarkdownV2",
76+
entities: [],
77+
});
78+
expect(sendMessage).toHaveBeenNthCalledWith(2, 777, "a+b", {
79+
reply_markup: { keyboard: [] },
80+
});
81+
});
82+
5283
it("does not swallow non-markdown Telegram errors", async () => {
5384
const sendMessage = vi
5485
.fn()
@@ -140,4 +171,36 @@ describe("bot/utils/send-with-markdown-fallback", () => {
140171
reply_markup: { inline_keyboard: [] },
141172
});
142173
});
174+
175+
it("drops markdown formatting options on edit fallback", async () => {
176+
const editMessageText = vi
177+
.fn()
178+
.mockRejectedValueOnce(
179+
new Error("Bad Request: can't parse entities: Character '+' is reserved"),
180+
)
181+
.mockResolvedValueOnce(undefined);
182+
183+
await editMessageWithMarkdownFallback({
184+
api: { editMessageText },
185+
chatId: 501,
186+
messageId: 902,
187+
text: "a+b",
188+
options: {
189+
reply_markup: { inline_keyboard: [] },
190+
parse_mode: "MarkdownV2",
191+
entities: [],
192+
},
193+
parseMode: "MarkdownV2",
194+
});
195+
196+
expect(editMessageText).toHaveBeenCalledTimes(2);
197+
expect(editMessageText).toHaveBeenNthCalledWith(1, 501, 902, "a+b", {
198+
reply_markup: { inline_keyboard: [] },
199+
parse_mode: "MarkdownV2",
200+
entities: [],
201+
});
202+
expect(editMessageText).toHaveBeenNthCalledWith(2, 501, 902, "a+b", {
203+
reply_markup: { inline_keyboard: [] },
204+
});
205+
});
143206
});

tests/summary/formatter.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ describe("summary/formatter", () => {
6060
expect(parts.every((part) => part.length <= 120)).toBe(true);
6161
});
6262

63+
it("does not split escaped markdown characters across parts", () => {
64+
const parts = formatSummaryWithMode("a+b", "markdown", 2);
65+
66+
expect(parts).toEqual(["a", "\\+", "b"]);
67+
expect(parts.some((part) => part.endsWith("\\"))).toBe(false);
68+
});
69+
6370
it("keeps raw code-block parts within the custom limit", () => {
6471
const parts = formatSummaryWithMode("a".repeat(300), "raw", 120);
6572

0 commit comments

Comments
 (0)