Skip to content

Commit d888b16

Browse files
committed
fix(bot): stream assistant responses as raw and prevent MarkdownV2 double-escaping
1 parent dae0f82 commit d888b16

7 files changed

Lines changed: 221 additions & 12 deletions

src/bot/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ function prepareStreamingPayload(messageText: string): StreamingMessagePayload |
129129

130130
return {
131131
parts,
132-
format: config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw",
132+
format: "raw",
133133
};
134134
}
135135

@@ -410,13 +410,15 @@ async function ensureEventSubscription(directory: string): Promise<void> {
410410
]).then(() => undefined),
411411
prepareStreamingPayload,
412412
formatSummary,
413+
formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
413414
resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
414415
getReplyKeyboard: getCurrentReplyKeyboard,
415-
sendText: async (text, options, format) => {
416+
sendText: async (text, rawFallbackText, options, format) => {
416417
await sendBotText({
417418
api: botApi,
418419
chatId,
419420
text,
421+
rawFallbackText,
420422
options: options as Parameters<typeof sendBotText>[0]["options"],
421423
format,
422424
});

src/bot/utils/finalize-assistant-response.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ interface FinalizeAssistantResponseOptions {
1010
flushPendingServiceMessages: () => Promise<void>;
1111
prepareStreamingPayload: (messageText: string) => StreamingMessagePayload | null;
1212
formatSummary: (messageText: string) => string[];
13+
formatRawSummary: (messageText: string) => string[];
1314
resolveFormat: () => TelegramTextFormat;
1415
getReplyKeyboard: () => unknown;
1516
sendText: (
1617
text: string,
18+
rawFallbackText: string | undefined,
1719
options: { reply_markup: unknown } | undefined,
1820
format: TelegramTextFormat,
1921
) => Promise<void>;
@@ -28,6 +30,7 @@ export async function finalizeAssistantResponse({
2830
flushPendingServiceMessages,
2931
prepareStreamingPayload,
3032
formatSummary,
33+
formatRawSummary,
3134
resolveFormat,
3235
getReplyKeyboard,
3336
sendText,
@@ -67,12 +70,15 @@ export async function finalizeAssistantResponse({
6770
}
6871

6972
const parts = formatSummary(messageText);
73+
const rawParts = formatRawSummary(messageText);
7074
const format = resolveFormat();
7175

72-
for (const part of parts) {
76+
for (let partIndex = 0; partIndex < parts.length; partIndex++) {
77+
const part = parts[partIndex];
78+
const rawFallbackText = rawParts[partIndex];
7379
const keyboard = getReplyKeyboard();
7480
const options = keyboard ? { reply_markup: keyboard } : undefined;
75-
await sendText(part, options, format);
81+
await sendText(part, rawFallbackText, options, format);
7682
}
7783

7884
return false;

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

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface SendMessageWithMarkdownFallbackParams {
1010
api: SendMessageApi;
1111
chatId: Parameters<SendMessageApi["sendMessage"]>[0];
1212
text: string;
13+
rawFallbackText?: string;
1314
options?: TelegramSendMessageOptions;
1415
parseMode?: "Markdown" | "MarkdownV2";
1516
}
@@ -19,6 +20,7 @@ interface EditMessageWithMarkdownFallbackParams {
1920
chatId: Parameters<EditMessageApi["editMessageText"]>[0];
2021
messageId: Parameters<EditMessageApi["editMessageText"]>[1];
2122
text: string;
23+
rawFallbackText?: string;
2224
options?: TelegramEditMessageOptions;
2325
parseMode?: "Markdown" | "MarkdownV2";
2426
}
@@ -31,10 +33,56 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
3133
"bad request: can't parse",
3234
];
3335

34-
const MARKDOWN_V2_RESERVED_CHARS = /([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
36+
const MARKDOWN_V2_RESERVED_CHARS = new Set([
37+
"_",
38+
"*",
39+
"[",
40+
"]",
41+
"(",
42+
")",
43+
"~",
44+
"`",
45+
">",
46+
"#",
47+
"+",
48+
"-",
49+
"=",
50+
"|",
51+
"{",
52+
"}",
53+
".",
54+
"!",
55+
"\\",
56+
]);
57+
const MARKDOWN_V2_ESCAPED_CHAR = /\\([_\*\[\]\(\)~`>#+\-=|{}.!\\])/g;
3558

3659
function escapeTelegramMarkdownV2(text: string): string {
37-
return text.replace(MARKDOWN_V2_RESERVED_CHARS, "\\$1");
60+
let result = "";
61+
let trailingBackslashes = 0;
62+
63+
for (const char of text) {
64+
if (char === "\\") {
65+
result += char;
66+
trailingBackslashes += 1;
67+
continue;
68+
}
69+
70+
const isEscaped = trailingBackslashes % 2 === 1;
71+
trailingBackslashes = 0;
72+
73+
if (MARKDOWN_V2_RESERVED_CHARS.has(char) && !isEscaped) {
74+
result += `\\${char}`;
75+
continue;
76+
}
77+
78+
result += char;
79+
}
80+
81+
return result;
82+
}
83+
84+
function unescapeTelegramMarkdownV2(text: string): string {
85+
return text.replace(MARKDOWN_V2_ESCAPED_CHAR, "$1");
3886
}
3987

4088
function stripMarkdownFormattingOptions<
@@ -100,6 +148,7 @@ export async function sendMessageWithMarkdownFallback({
100148
api,
101149
chatId,
102150
text,
151+
rawFallbackText,
103152
options,
104153
parseMode,
105154
}: SendMessageWithMarkdownFallbackParams): Promise<
@@ -114,6 +163,9 @@ export async function sendMessageWithMarkdownFallback({
114163
parse_mode: parseMode,
115164
};
116165

166+
const fallbackText =
167+
rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
168+
117169
try {
118170
return await api.sendMessage(chatId, text, markdownOptions);
119171
} catch (error) {
@@ -140,13 +192,13 @@ export async function sendMessageWithMarkdownFallback({
140192
"[Bot] Escaped Markdown parse failed, retrying assistant message in raw mode",
141193
escapedError,
142194
);
143-
return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
195+
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
144196
}
145197
}
146198
}
147199

148200
logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
149-
return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
201+
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
150202
}
151203
}
152204

@@ -155,6 +207,7 @@ export async function editMessageWithMarkdownFallback({
155207
chatId,
156208
messageId,
157209
text,
210+
rawFallbackText,
158211
options,
159212
parseMode,
160213
}: EditMessageWithMarkdownFallbackParams): Promise<
@@ -169,6 +222,9 @@ export async function editMessageWithMarkdownFallback({
169222
parse_mode: parseMode,
170223
};
171224

225+
const fallbackText =
226+
rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
227+
172228
try {
173229
return await api.editMessageText(chatId, messageId, text, markdownOptions);
174230
} catch (error) {
@@ -198,14 +254,19 @@ export async function editMessageWithMarkdownFallback({
198254
return api.editMessageText(
199255
chatId,
200256
messageId,
201-
text,
257+
fallbackText,
202258
stripMarkdownFormattingOptions(options),
203259
);
204260
}
205261
}
206262
}
207263

208264
logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
209-
return api.editMessageText(chatId, messageId, text, stripMarkdownFormattingOptions(options));
265+
return api.editMessageText(
266+
chatId,
267+
messageId,
268+
fallbackText,
269+
stripMarkdownFormattingOptions(options),
270+
);
210271
}
211272
}

src/bot/utils/telegram-text.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface SendBotTextParams {
1616
api: SendMessageApi;
1717
chatId: Parameters<SendMessageApi["sendMessage"]>[0];
1818
text: string;
19+
rawFallbackText?: string;
1920
options?: TelegramSendMessageOptions;
2021
format?: TelegramTextFormat;
2122
}
@@ -25,6 +26,7 @@ interface EditBotTextParams {
2526
chatId: Parameters<EditMessageApi["editMessageText"]>[0];
2627
messageId: Parameters<EditMessageApi["editMessageText"]>[1];
2728
text: string;
29+
rawFallbackText?: string;
2830
options?: TelegramEditMessageOptions;
2931
format?: TelegramTextFormat;
3032
}
@@ -41,13 +43,15 @@ export async function sendBotText({
4143
api,
4244
chatId,
4345
text,
46+
rawFallbackText,
4447
options,
4548
format = "raw",
4649
}: SendBotTextParams): Promise<void> {
4750
await sendMessageWithMarkdownFallback({
4851
api,
4952
chatId,
5053
text,
54+
rawFallbackText,
5155
options,
5256
parseMode: resolveParseMode(format),
5357
});
@@ -58,6 +62,7 @@ export async function editBotText({
5862
chatId,
5963
messageId,
6064
text,
65+
rawFallbackText,
6166
options,
6267
format = "raw",
6368
}: EditBotTextParams): Promise<void> {
@@ -66,6 +71,7 @@ export async function editBotText({
6671
chatId,
6772
messageId,
6873
text,
74+
rawFallbackText,
6975
options,
7076
parseMode: resolveParseMode(format),
7177
});

tests/bot/utils/finalize-assistant-response.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe("bot/utils/finalize-assistant-response", () => {
1818
flushPendingServiceMessages,
1919
prepareStreamingPayload: vi.fn(() => ({ parts: ["final reply"], format: "raw" as const })),
2020
formatSummary: vi.fn(() => ["part 1", "part 2"]),
21+
formatRawSummary: vi.fn(() => ["part 1", "part 2"]),
2122
resolveFormat: vi.fn(() => "markdown_v2" as const),
2223
getReplyKeyboard: vi.fn(() => ({ keyboard: [[{ text: "A" }]] })),
2324
sendText,
@@ -36,12 +37,14 @@ describe("bot/utils/finalize-assistant-response", () => {
3637
expect(sendText).toHaveBeenNthCalledWith(
3738
1,
3839
"part 1",
40+
"part 1",
3941
{ reply_markup: { keyboard: [[{ text: "A" }]] } },
4042
"markdown_v2",
4143
);
4244
expect(sendText).toHaveBeenNthCalledWith(
4345
2,
4446
"part 2",
47+
"part 2",
4548
{ reply_markup: { keyboard: [[{ text: "A" }]] } },
4649
"markdown_v2",
4750
);
@@ -65,6 +68,7 @@ describe("bot/utils/finalize-assistant-response", () => {
6568
flushPendingServiceMessages,
6669
prepareStreamingPayload,
6770
formatSummary: vi.fn(() => ["reply"]),
71+
formatRawSummary: vi.fn(() => ["reply"]),
6872
resolveFormat: vi.fn(() => "raw" as const),
6973
getReplyKeyboard: vi.fn(() => keyboard),
7074
sendText,
@@ -80,7 +84,7 @@ describe("bot/utils/finalize-assistant-response", () => {
8084
expect(flushPendingServiceMessages).toHaveBeenCalledTimes(1);
8185
expect(deleteMessages).toHaveBeenCalledWith([101]);
8286
expect(sendText).toHaveBeenCalledTimes(1);
83-
expect(sendText).toHaveBeenCalledWith("reply", { reply_markup: keyboard }, "raw");
87+
expect(sendText).toHaveBeenCalledWith("reply", "reply", { reply_markup: keyboard }, "raw");
8488
});
8589

8690
it("still sends with keyboard when streamer reports not streamed", async () => {
@@ -100,6 +104,7 @@ describe("bot/utils/finalize-assistant-response", () => {
100104
flushPendingServiceMessages,
101105
prepareStreamingPayload,
102106
formatSummary: vi.fn(() => ["reply"]),
107+
formatRawSummary: vi.fn(() => ["reply"]),
103108
resolveFormat: vi.fn(() => "raw" as const),
104109
getReplyKeyboard: vi.fn(() => undefined),
105110
sendText,
@@ -108,6 +113,6 @@ describe("bot/utils/finalize-assistant-response", () => {
108113

109114
expect(deleteMessages).not.toHaveBeenCalled();
110115
expect(sendText).toHaveBeenCalledTimes(1);
111-
expect(sendText).toHaveBeenCalledWith("reply", undefined, "raw");
116+
expect(sendText).toHaveBeenCalledWith("reply", "reply", undefined, "raw");
112117
});
113118
});

0 commit comments

Comments
 (0)