Skip to content

Commit e6e3a7b

Browse files
liuxiaopai-aisteipete
authored andcommitted
fix(telegram): retry DM thread sends without message_thread_id [AI-assisted]
1 parent ef90859 commit e6e3a7b

3 files changed

Lines changed: 181 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ Docs: https://docs.openclaw.ai
178178
- Security/External content marker folding: expand Unicode angle-bracket homoglyph normalization in marker sanitization so additional guillemet, double-angle, tortoise-shell, flattened-parenthesis, and ornamental variants are folded before boundary replacement. (#30951) Thanks @benediktjohannes.
179179
- Docs/Slack manifest scopes: add missing DM/group-DM bot scopes (`im:read`, `im:write`, `mpim:read`, `mpim:write`) to the Slack app manifest example so DM setup guidance is complete. (#29999) Thanks @JcMinarro.
180180
- Slack/Onboarding token help: update setup text to include the “From manifest” app-creation path and current install wording for obtaining the `xoxb-` bot token. (#30846) Thanks @yzhong52.
181+
- Telegram/Thread fallback safety: when Telegram returns `message thread not found`, retry without `message_thread_id` only for DM-thread sends (not forum topics), and suppress first-attempt danger logs when retry succeeds. Landed from contributor PR #30892 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
181182
- Slack/Bot attachment-only messages: when `allowBots: true`, bot messages with empty `text` now include non-forwarded attachment `text`/`fallback` content so webhook alerts are not silently dropped. (#27616) Thanks @lailoo.
182183
- Slack/Inbound media auth + HTML guard: keep Slack auth headers on forwarded shared attachment image downloads, and reject login/error HTML payloads (while allowing expected `.html` uploads) when resolving Slack media so auth failures do not silently pass as files. (#18642) Thanks @tumf.
183184
- Slack/Security ingress mismatch guard: drop slash-command and interaction payloads when app/team identifiers do not match the active Slack account context (including nested `team.id` interaction payloads), preventing cross-app or cross-workspace payload injection into system-event handling. (#29091) Thanks @Solvely-Colin.

src/telegram/bot/delivery.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ function createVoiceMessagesForbiddenError() {
7777
);
7878
}
7979

80+
function createThreadNotFoundError(operation = "sendMessage") {
81+
return new Error(
82+
`GrammyError: Call to '${operation}' failed! (400: Bad Request: message thread not found)`,
83+
);
84+
}
85+
8086
function createVoiceFailureHarness(params: {
8187
voiceError: Error;
8288
sendMessageResult?: { message_id: number; chat: { id: string } };
@@ -225,6 +231,82 @@ describe("deliverReplies", () => {
225231
);
226232
});
227233

234+
it("retries DM topic sends without message_thread_id when thread is missing", async () => {
235+
const runtime = createRuntime();
236+
const sendMessage = vi
237+
.fn()
238+
.mockRejectedValueOnce(createThreadNotFoundError("sendMessage"))
239+
.mockResolvedValueOnce({
240+
message_id: 7,
241+
chat: { id: "123" },
242+
});
243+
const bot = createBot({ sendMessage });
244+
245+
await deliverWith({
246+
replies: [{ text: "hello" }],
247+
runtime,
248+
bot,
249+
thread: { id: 42, scope: "dm" },
250+
});
251+
252+
expect(sendMessage).toHaveBeenCalledTimes(2);
253+
expect(sendMessage.mock.calls[0]?.[2]).toEqual(
254+
expect.objectContaining({
255+
message_thread_id: 42,
256+
}),
257+
);
258+
expect(sendMessage.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
259+
expect(runtime.error).not.toHaveBeenCalled();
260+
});
261+
262+
it("does not retry forum sends without message_thread_id", async () => {
263+
const runtime = createRuntime();
264+
const sendMessage = vi.fn().mockRejectedValue(createThreadNotFoundError("sendMessage"));
265+
const bot = createBot({ sendMessage });
266+
267+
await expect(
268+
deliverWith({
269+
replies: [{ text: "hello" }],
270+
runtime,
271+
bot,
272+
thread: { id: 42, scope: "forum" },
273+
}),
274+
).rejects.toThrow("message thread not found");
275+
276+
expect(sendMessage).toHaveBeenCalledTimes(1);
277+
expect(runtime.error).toHaveBeenCalledTimes(1);
278+
});
279+
280+
it("retries media sends without message_thread_id for DM topics", async () => {
281+
const runtime = createRuntime();
282+
const sendPhoto = vi
283+
.fn()
284+
.mockRejectedValueOnce(createThreadNotFoundError("sendPhoto"))
285+
.mockResolvedValueOnce({
286+
message_id: 8,
287+
chat: { id: "123" },
288+
});
289+
const bot = createBot({ sendPhoto });
290+
291+
mockMediaLoad("photo.jpg", "image/jpeg", "image");
292+
293+
await deliverWith({
294+
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "caption" }],
295+
runtime,
296+
bot,
297+
thread: { id: 42, scope: "dm" },
298+
});
299+
300+
expect(sendPhoto).toHaveBeenCalledTimes(2);
301+
expect(sendPhoto.mock.calls[0]?.[2]).toEqual(
302+
expect.objectContaining({
303+
message_thread_id: 42,
304+
}),
305+
);
306+
expect(sendPhoto.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
307+
expect(runtime.error).not.toHaveBeenCalled();
308+
});
309+
228310
it("does not include link_preview_options when linkPreview is true", async () => {
229311
const { runtime, sendMessage, bot } = createSendMessageHarness();
230312

src/telegram/bot/delivery.ts

Lines changed: 98 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const EMPTY_TEXT_ERR_RE = /message text is empty/i;
3737
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
3838
const CAPTION_TOO_LONG_RE = /caption is too long/i;
3939
const FILE_TOO_BIG_RE = /file is too big/i;
40+
const THREAD_NOT_FOUND_RE = /message thread not found/i;
4041
const TELEGRAM_MEDIA_SSRF_POLICY = {
4142
// Telegram file downloads should trust api.telegram.org even when DNS/proxy
4243
// resolution maps to private/internal ranges in restricted networks.
@@ -191,24 +192,30 @@ export async function deliverReplies(params: {
191192
}),
192193
};
193194
if (isGif) {
194-
await withTelegramApiErrorLogging({
195+
await sendTelegramWithThreadFallback({
195196
operation: "sendAnimation",
196197
runtime,
197-
fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }),
198+
thread,
199+
requestParams: mediaParams,
200+
send: (effectiveParams) => bot.api.sendAnimation(chatId, file, { ...effectiveParams }),
198201
});
199202
markDelivered();
200203
} else if (kind === "image") {
201-
await withTelegramApiErrorLogging({
204+
await sendTelegramWithThreadFallback({
202205
operation: "sendPhoto",
203206
runtime,
204-
fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }),
207+
thread,
208+
requestParams: mediaParams,
209+
send: (effectiveParams) => bot.api.sendPhoto(chatId, file, { ...effectiveParams }),
205210
});
206211
markDelivered();
207212
} else if (kind === "video") {
208-
await withTelegramApiErrorLogging({
213+
await sendTelegramWithThreadFallback({
209214
operation: "sendVideo",
210215
runtime,
211-
fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }),
216+
thread,
217+
requestParams: mediaParams,
218+
send: (effectiveParams) => bot.api.sendVideo(chatId, file, { ...effectiveParams }),
212219
});
213220
markDelivered();
214221
} else if (kind === "audio") {
@@ -223,11 +230,13 @@ export async function deliverReplies(params: {
223230
// Switch typing indicator to record_voice before sending.
224231
await params.onVoiceRecording?.();
225232
try {
226-
await withTelegramApiErrorLogging({
233+
await sendTelegramWithThreadFallback({
227234
operation: "sendVoice",
228235
runtime,
236+
thread,
237+
requestParams: mediaParams,
229238
shouldLog: (err) => !isVoiceMessagesForbidden(err),
230-
fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }),
239+
send: (effectiveParams) => bot.api.sendVoice(chatId, file, { ...effectiveParams }),
231240
});
232241
markDelivered();
233242
} catch (voiceErr) {
@@ -294,18 +303,22 @@ export async function deliverReplies(params: {
294303
}
295304
} else {
296305
// Audio file - displays with metadata (title, duration) - DEFAULT
297-
await withTelegramApiErrorLogging({
306+
await sendTelegramWithThreadFallback({
298307
operation: "sendAudio",
299308
runtime,
300-
fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
309+
thread,
310+
requestParams: mediaParams,
311+
send: (effectiveParams) => bot.api.sendAudio(chatId, file, { ...effectiveParams }),
301312
});
302313
markDelivered();
303314
}
304315
} else {
305-
await withTelegramApiErrorLogging({
316+
await sendTelegramWithThreadFallback({
306317
operation: "sendDocument",
307318
runtime,
308-
fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
319+
thread,
320+
requestParams: mediaParams,
321+
send: (effectiveParams) => bot.api.sendDocument(chatId, file, { ...effectiveParams }),
309322
});
310323
markDelivered();
311324
}
@@ -559,6 +572,69 @@ async function sendTelegramVoiceFallbackText(opts: {
559572
}
560573
}
561574

575+
function isTelegramThreadNotFoundError(err: unknown): boolean {
576+
if (err instanceof GrammyError) {
577+
return THREAD_NOT_FOUND_RE.test(err.description);
578+
}
579+
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));
580+
}
581+
582+
function hasMessageThreadIdParam(params: Record<string, unknown> | undefined): boolean {
583+
if (!params) {
584+
return false;
585+
}
586+
return typeof params.message_thread_id === "number";
587+
}
588+
589+
function removeMessageThreadIdParam(
590+
params: Record<string, unknown> | undefined,
591+
): Record<string, unknown> {
592+
if (!params) {
593+
return {};
594+
}
595+
const { message_thread_id: _ignored, ...rest } = params;
596+
return rest;
597+
}
598+
599+
async function sendTelegramWithThreadFallback<T>(params: {
600+
operation: string;
601+
runtime: RuntimeEnv;
602+
thread?: TelegramThreadSpec | null;
603+
requestParams: Record<string, unknown>;
604+
send: (effectiveParams: Record<string, unknown>) => Promise<T>;
605+
shouldLog?: (err: unknown) => boolean;
606+
}): Promise<T> {
607+
const allowThreadlessRetry = params.thread?.scope === "dm";
608+
const hasThreadId = hasMessageThreadIdParam(params.requestParams);
609+
const shouldSuppressFirstErrorLog = (err: unknown) =>
610+
allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err);
611+
const mergedShouldLog = params.shouldLog
612+
? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err)
613+
: (err: unknown) => !shouldSuppressFirstErrorLog(err);
614+
615+
try {
616+
return await withTelegramApiErrorLogging({
617+
operation: params.operation,
618+
runtime: params.runtime,
619+
shouldLog: mergedShouldLog,
620+
fn: () => params.send(params.requestParams),
621+
});
622+
} catch (err) {
623+
if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) {
624+
throw err;
625+
}
626+
const retryParams = removeMessageThreadIdParam(params.requestParams);
627+
params.runtime.log?.(
628+
`telegram ${params.operation}: message thread not found; retrying without message_thread_id`,
629+
);
630+
return await withTelegramApiErrorLogging({
631+
operation: `${params.operation} (threadless retry)`,
632+
runtime: params.runtime,
633+
fn: () => params.send(retryParams),
634+
});
635+
}
636+
}
637+
562638
function buildTelegramSendParams(opts?: {
563639
replyToMessageId?: number;
564640
thread?: TelegramThreadSpec | null;
@@ -601,14 +677,16 @@ async function sendTelegramText(
601677
const fallbackText = opts?.plainText ?? text;
602678
const hasFallbackText = fallbackText.trim().length > 0;
603679
const sendPlainFallback = async () => {
604-
const res = await withTelegramApiErrorLogging({
680+
const res = await sendTelegramWithThreadFallback({
605681
operation: "sendMessage",
606682
runtime,
607-
fn: () =>
683+
thread: opts?.thread,
684+
requestParams: baseParams,
685+
send: (effectiveParams) =>
608686
bot.api.sendMessage(chatId, fallbackText, {
609687
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
610688
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
611-
...baseParams,
689+
...effectiveParams,
612690
}),
613691
});
614692
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`);
@@ -623,19 +701,21 @@ async function sendTelegramText(
623701
return await sendPlainFallback();
624702
}
625703
try {
626-
const res = await withTelegramApiErrorLogging({
704+
const res = await sendTelegramWithThreadFallback({
627705
operation: "sendMessage",
628706
runtime,
707+
thread: opts?.thread,
708+
requestParams: baseParams,
629709
shouldLog: (err) => {
630710
const errText = formatErrorMessage(err);
631711
return !PARSE_ERR_RE.test(errText) && !EMPTY_TEXT_ERR_RE.test(errText);
632712
},
633-
fn: () =>
713+
send: (effectiveParams) =>
634714
bot.api.sendMessage(chatId, htmlText, {
635715
parse_mode: "HTML",
636716
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
637717
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
638-
...baseParams,
718+
...effectiveParams,
639719
}),
640720
});
641721
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id}`);

0 commit comments

Comments
 (0)