Skip to content

Commit beabbe9

Browse files
authored
Merge pull request #67 from grinev/fix/markdown-parse-failed
fix(bot): fix markdown parse failed errors
2 parents 78034d4 + d888b16 commit beabbe9

14 files changed

Lines changed: 685 additions & 69 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ OPENCODE_MODEL_ID=big-pickle
4646
# Maximum number of scheduled tasks allowed at once (default: 10)
4747
# TASK_LIMIT=10
4848

49+
# Stream update throttle in milliseconds for assistant/tool message edits (default: 500)
50+
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
51+
# RESPONSE_STREAM_THROTTLE_MS=500
52+
4953
# Bot locale: supported locale code (default: en)
5054
# Supported locales: en, de, es, fr, ru, zh
5155
# BOT_LOCALE=en

README.md

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -147,30 +147,31 @@ When installed via npm, the configuration wizard handles the initial setup. The
147147
- **Windows:** `%APPDATA%\opencode-telegram-bot\.env`
148148
- **Linux:** `~/.config/opencode-telegram-bot/.env`
149149

150-
| Variable | Description | Required | Default |
151-
| -------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
152-
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes ||
153-
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes ||
154-
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No ||
155-
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
156-
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
157-
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No ||
158-
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
159-
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
160-
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
161-
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
162-
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
163-
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
164-
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
165-
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
166-
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
167-
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
168-
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
169-
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No ||
170-
| `STT_API_KEY` | API key for your STT provider | No ||
171-
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
172-
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No ||
173-
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
150+
| Variable | Description | Required | Default |
151+
| ----------------------------- | -------------------------------------------------------------------------------- | :------: | ------------------------ |
152+
| `TELEGRAM_BOT_TOKEN` | Bot token from @BotFather | Yes ||
153+
| `TELEGRAM_ALLOWED_USER_ID` | Your numeric Telegram user ID | Yes ||
154+
| `TELEGRAM_PROXY_URL` | Proxy URL for Telegram API (SOCKS5/HTTP) | No ||
155+
| `OPENCODE_API_URL` | OpenCode server URL | No | `http://localhost:4096` |
156+
| `OPENCODE_SERVER_USERNAME` | Server auth username | No | `opencode` |
157+
| `OPENCODE_SERVER_PASSWORD` | Server auth password | No ||
158+
| `OPENCODE_MODEL_PROVIDER` | Default model provider | Yes | `opencode` |
159+
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
160+
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
161+
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
162+
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
163+
| `COMMANDS_LIST_LIMIT` | Commands per page in `/commands` | No | `10` |
164+
| `TASK_LIMIT` | Maximum number of scheduled tasks that can exist at once | No | `10` |
165+
| `RESPONSE_STREAM_THROTTLE_MS` | Stream edit throttle (ms) for assistant and tool updates | No | `500` |
166+
| `HIDE_THINKING_MESSAGES` | Hide `💭 Thinking...` service messages | No | `false` |
167+
| `HIDE_TOOL_CALL_MESSAGES` | Hide tool-call service messages (`💻 bash ...`, `📖 read ...`, etc.) | No | `false` |
168+
| `MESSAGE_FORMAT_MODE` | Assistant reply formatting mode: `markdown` (Telegram MarkdownV2) or `raw` | No | `markdown` |
169+
| `CODE_FILE_MAX_SIZE_KB` | Max file size (KB) to send as document | No | `100` |
170+
| `STT_API_URL` | Whisper-compatible API base URL (enables voice/audio transcription) | No ||
171+
| `STT_API_KEY` | API key for your STT provider | No ||
172+
| `STT_MODEL` | STT model name passed to `/audio/transcriptions` | No | `whisper-large-v3-turbo` |
173+
| `STT_LANGUAGE` | Optional language hint (empty = provider auto-detect) | No ||
174+
| `LOG_LEVEL` | Log level (`debug`, `info`, `warn`, `error`) | No | `info` |
174175

175176
> **Keep your `.env` file private.** It contains your bot token. Never commit it to version control.
176177

src/bot/index.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { getCurrentSession } from "../session/manager.js";
6060
import { ingestSessionInfoForCache } from "../session/cache-manager.js";
6161
import { logger } from "../utils/logger.js";
6262
import { safeBackgroundTask } from "../utils/safe-background-task.js";
63+
import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
6364
import { pinnedMessageManager } from "../pinned/manager.js";
6465
import { t } from "../i18n/index.js";
6566
import { processUserPrompt } from "./handlers/prompt.js";
@@ -87,7 +88,7 @@ let chatIdInstance: number | null = null;
8788
let commandsInitialized = false;
8889

8990
const TELEGRAM_DOCUMENT_CAPTION_MAX_LENGTH = 1024;
90-
const RESPONSE_STREAM_THROTTLE_MS = 200;
91+
const RESPONSE_STREAM_THROTTLE_MS = config.bot.responseStreamThrottleMs;
9192
const RESPONSE_STREAM_TEXT_LIMIT = 3800;
9293
const SESSION_RETRY_PREFIX = "🔁";
9394
const SUBAGENT_STREAM_PREFIX = "🧩";
@@ -128,7 +129,7 @@ function prepareStreamingPayload(messageText: string): StreamingMessagePayload |
128129

129130
return {
130131
parts,
131-
format: config.bot.messageFormatMode === "markdown" ? "markdown_v2" : "raw",
132+
format: "raw",
132133
};
133134
}
134135

@@ -409,13 +410,15 @@ async function ensureEventSubscription(directory: string): Promise<void> {
409410
]).then(() => undefined),
410411
prepareStreamingPayload,
411412
formatSummary,
413+
formatRawSummary: (text) => formatSummaryWithMode(text, "raw"),
412414
resolveFormat: () => (getAssistantParseMode() === "MarkdownV2" ? "markdown_v2" : "raw"),
413415
getReplyKeyboard: getCurrentReplyKeyboard,
414-
sendText: async (text, options, format) => {
416+
sendText: async (text, rawFallbackText, options, format) => {
415417
await sendBotText({
416418
api: botApi,
417419
chatId,
418420
text,
421+
rawFallbackText,
419422
options: options as Parameters<typeof sendBotText>[0]["options"],
420423
format,
421424
});
@@ -828,10 +831,22 @@ export function createBot(): Bot<Context> {
828831
const timeSinceLast = now - lastGetUpdatesTime;
829832
logger.debug(`[Bot API] getUpdates called (${timeSinceLast}ms since last)`);
830833
lastGetUpdatesTime = now;
831-
} else if (method === "sendMessage") {
834+
return prev(method, payload, signal);
835+
}
836+
837+
if (method === "sendMessage") {
832838
logger.debug(`[Bot API] sendMessage to chat ${(payload as { chat_id?: number }).chat_id}`);
833839
}
834-
return prev(method, payload, signal);
840+
841+
return withTelegramRateLimitRetry(() => prev(method, payload, signal), {
842+
maxRetries: 5,
843+
onRetry: ({ attempt, retryAfterMs, error }) => {
844+
logger.warn(
845+
`[Bot API] Telegram rate limit on ${method}, retrying in ${retryAfterMs}ms (attempt=${attempt})`,
846+
error,
847+
);
848+
},
849+
});
835850
});
836851

837852
bot.use((ctx, next) => {

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: 122 additions & 2 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,6 +33,58 @@ const MARKDOWN_PARSE_ERROR_MARKERS = [
3133
"bad request: can't parse",
3234
];
3335

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;
58+
59+
function escapeTelegramMarkdownV2(text: string): string {
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");
86+
}
87+
3488
function stripMarkdownFormattingOptions<
3589
T extends TelegramSendMessageOptions | TelegramEditMessageOptions | undefined,
3690
>(options: T): T {
@@ -94,6 +148,7 @@ export async function sendMessageWithMarkdownFallback({
94148
api,
95149
chatId,
96150
text,
151+
rawFallbackText,
97152
options,
98153
parseMode,
99154
}: SendMessageWithMarkdownFallbackParams): Promise<
@@ -108,15 +163,42 @@ export async function sendMessageWithMarkdownFallback({
108163
parse_mode: parseMode,
109164
};
110165

166+
const fallbackText =
167+
rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
168+
111169
try {
112170
return await api.sendMessage(chatId, text, markdownOptions);
113171
} catch (error) {
114172
if (!isTelegramMarkdownParseError(error)) {
115173
throw error;
116174
}
117175

176+
if (parseMode === "MarkdownV2") {
177+
const escapedText = escapeTelegramMarkdownV2(text);
178+
if (escapedText !== text) {
179+
logger.warn(
180+
"[Bot] Markdown parse failed, retrying assistant message with escaped MarkdownV2",
181+
error,
182+
);
183+
184+
try {
185+
return await api.sendMessage(chatId, escapedText, markdownOptions);
186+
} catch (escapedError) {
187+
if (!isTelegramMarkdownParseError(escapedError)) {
188+
throw escapedError;
189+
}
190+
191+
logger.warn(
192+
"[Bot] Escaped Markdown parse failed, retrying assistant message in raw mode",
193+
escapedError,
194+
);
195+
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
196+
}
197+
}
198+
}
199+
118200
logger.warn("[Bot] Markdown parse failed, retrying assistant message in raw mode", error);
119-
return api.sendMessage(chatId, text, stripMarkdownFormattingOptions(options));
201+
return api.sendMessage(chatId, fallbackText, stripMarkdownFormattingOptions(options));
120202
}
121203
}
122204

@@ -125,6 +207,7 @@ export async function editMessageWithMarkdownFallback({
125207
chatId,
126208
messageId,
127209
text,
210+
rawFallbackText,
128211
options,
129212
parseMode,
130213
}: EditMessageWithMarkdownFallbackParams): Promise<
@@ -139,14 +222,51 @@ export async function editMessageWithMarkdownFallback({
139222
parse_mode: parseMode,
140223
};
141224

225+
const fallbackText =
226+
rawFallbackText ?? (parseMode === "MarkdownV2" ? unescapeTelegramMarkdownV2(text) : text);
227+
142228
try {
143229
return await api.editMessageText(chatId, messageId, text, markdownOptions);
144230
} catch (error) {
145231
if (!isTelegramMarkdownParseError(error)) {
146232
throw error;
147233
}
148234

235+
if (parseMode === "MarkdownV2") {
236+
const escapedText = escapeTelegramMarkdownV2(text);
237+
if (escapedText !== text) {
238+
logger.warn(
239+
"[Bot] Markdown parse failed, retrying edited message with escaped MarkdownV2",
240+
error,
241+
);
242+
243+
try {
244+
return await api.editMessageText(chatId, messageId, escapedText, markdownOptions);
245+
} catch (escapedError) {
246+
if (!isTelegramMarkdownParseError(escapedError)) {
247+
throw escapedError;
248+
}
249+
250+
logger.warn(
251+
"[Bot] Escaped Markdown parse failed, retrying edited message in raw mode",
252+
escapedError,
253+
);
254+
return api.editMessageText(
255+
chatId,
256+
messageId,
257+
fallbackText,
258+
stripMarkdownFormattingOptions(options),
259+
);
260+
}
261+
}
262+
}
263+
149264
logger.warn("[Bot] Markdown parse failed, retrying edited message in raw mode", error);
150-
return api.editMessageText(chatId, messageId, text, stripMarkdownFormattingOptions(options));
265+
return api.editMessageText(
266+
chatId,
267+
messageId,
268+
fallbackText,
269+
stripMarkdownFormattingOptions(options),
270+
);
151271
}
152272
}

0 commit comments

Comments
 (0)