From 2a381e6d7bec73c9a02e7910166ba8848c38b5d1 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Sun, 1 Mar 2026 15:49:22 -0800 Subject: [PATCH] fix(telegram): replyToMode 'first' now only applies reply-to to first chunk The `replyToMessageIdForPayload` was computed once outside the chunk and media loops, so all chunks received the same reply-to ID even when replyToMode was set to "first". This replaces the static binding with a lazy `resolveReplyTo()` function that checks `hasReplied` at each send site, and updates `hasReplied` immediately after the first successful send. Fixes #31039 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 1 + src/telegram/bot/delivery.test.ts | 112 ++++++++++++++++++++++++++++++ src/telegram/bot/delivery.ts | 37 ++++++---- 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6430f91ea4..fb7b099e0a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,7 @@ Docs: https://docs.openclaw.ai - Telegram/Group allowlist ordering: evaluate chat allowlist before sender allowlist enforcement so explicitly allowlisted groups are not fail-closed by empty sender allowlists. Landed from contributor PR #30680 by @openperf. Thanks @openperf. - Telegram/Multi-account group isolation: prevent channel-level `groups` config from leaking across Telegram accounts in multi-account setups, avoiding cross-account group routing drops. Landed from contributor PR #30677 by @YUJIE2002. Thanks @YUJIE2002. - Telegram/Voice caption overflow fallback: recover from `sendVoice` caption length errors by re-sending voice without caption and delivering text separately so replies are not lost. Landed from contributor PR #31131 by @Sid-Qin. Thanks @Sid-Qin. +- Telegram/Reply `first` chunking: apply `replyToMode: "first"` reply targets only to the first Telegram text/media/fallback chunk, avoiding multi-chunk over-quoting in split replies. Landed from contributor PR #31077 by @scoootscooob. Thanks @scoootscooob. - Feishu/Doc create permissions: remove caller-controlled owner fields from `feishu_doc` create and bind optional grant behavior to trusted Feishu requester context (`grant_to_requester`), preventing principal selection via tool arguments. (#31184) Thanks @Takhoffman. - Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin. - Cron/Store EBUSY fallback: retry `rename` on `EBUSY` and use `copyFile` fallback on Windows when replacing cron store files so busy-file contention no longer causes false write failures. (#16932) Thanks @sudhanva-chakra. diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 971ee679c26..a64c2b9be6b 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -359,6 +359,35 @@ describe("deliverReplies", () => { ); }); + it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => { + const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ + voiceError: createVoiceMessagesForbiddenError(), + sendMessageResult: { + message_id: 6, + chat: { id: "123" }, + }, + }); + + mockMediaLoad("note.ogg", "audio/ogg", "voice"); + + await deliverWith({ + replies: [ + { mediaUrl: "https://example.com/note.ogg", text: "chunk-one\n\nchunk-two", replyToId: "77" }, + ], + runtime, + bot, + replyToMode: "first", + textLimit: 12, + }); + + expect(sendVoice).toHaveBeenCalledTimes(1); + expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(sendMessage.mock.calls[0][2]).toEqual( + expect.objectContaining({ reply_to_message_id: 77 }), + ); + expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id"); + }); + it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => { const runtime = createRuntime(); const sendVoice = vi.fn().mockRejectedValue(new Error("Network error")); @@ -380,6 +409,89 @@ describe("deliverReplies", () => { expect(sendMessage).not.toHaveBeenCalled(); }); + it("replyToMode 'first' only applies reply-to to the first text chunk", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 20, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + // Use a small textLimit to force multiple chunks + await deliverReplies({ + replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "700" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "first", + textLimit: 12, + }); + + expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); + // First chunk should have reply_to_message_id + expect(sendMessage.mock.calls[0][2]).toEqual( + expect.objectContaining({ reply_to_message_id: 700 }), + ); + // Second chunk should NOT have reply_to_message_id + expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id"); + }); + + it("replyToMode 'all' applies reply-to to every text chunk", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 21, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverReplies({ + replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "800" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "all", + textLimit: 12, + }); + + expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); + // Both chunks should have reply_to_message_id + for (const call of sendMessage.mock.calls) { + expect(call[2]).toEqual(expect.objectContaining({ reply_to_message_id: 800 })); + } + }); + + it("replyToMode 'first' only applies reply-to to first media item", async () => { + const runtime = createRuntime(); + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 30, + chat: { id: "123" }, + }); + const bot = createBot({ sendPhoto }); + + mockMediaLoad("a.jpg", "image/jpeg", "img1"); + mockMediaLoad("b.jpg", "image/jpeg", "img2"); + + await deliverReplies({ + replies: [{ mediaUrls: ["https://a.jpg", "https://b.jpg"], replyToId: "900" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "first", + textLimit: 4000, + }); + + expect(sendPhoto).toHaveBeenCalledTimes(2); + // First media should have reply_to_message_id + expect(sendPhoto.mock.calls[0][2]).toEqual( + expect.objectContaining({ reply_to_message_id: 900 }), + ); + // Second media should NOT have reply_to_message_id + expect(sendPhoto.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id"); + }); + it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => { const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ voiceError: createVoiceMessagesForbiddenError(), diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index abf79c2cc7f..636db4c5fb4 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -112,7 +112,9 @@ export async function deliverReplies(params: { continue; } const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); - const replyToMessageIdForPayload = + // Evaluate lazily so `hasReplied` is checked at each send site. + // When replyToMode is "first", only the first chunk/media item gets the reply-to. + const resolveReplyTo = () => replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; const mediaList = reply.mediaUrls?.length ? reply.mediaUrls @@ -125,7 +127,6 @@ export async function deliverReplies(params: { const replyMarkup = buildInlineKeyboard(telegramData?.buttons); if (mediaList.length === 0) { const chunks = chunkText(reply.text || ""); - let sentTextChunk = false; for (let i = 0; i < chunks.length; i += 1) { const chunk = chunks[i]; if (!chunk) { @@ -133,8 +134,9 @@ export async function deliverReplies(params: { } // Only attach buttons to the first chunk. const shouldAttachButtons = i === 0 && replyMarkup; + const replyToForChunk = resolveReplyTo(); await sendTelegramText(bot, chatId, chunk.html, runtime, { - replyToMessageId: replyToMessageIdForPayload, + replyToMessageId: replyToForChunk, replyQuoteText, thread, textMode: "html", @@ -142,12 +144,11 @@ export async function deliverReplies(params: { linkPreview, replyMarkup: shouldAttachButtons ? replyMarkup : undefined, }); - sentTextChunk = true; + if (replyToForChunk && !hasReplied) { + hasReplied = true; + } markDelivered(); } - if (replyToMessageIdForPayload && !hasReplied && sentTextChunk) { - hasReplied = true; - } continue; } // media with optional caption on first item @@ -178,7 +179,7 @@ export async function deliverReplies(params: { pendingFollowUpText = followUpText; } first = false; - const replyToMessageId = replyToMessageIdForPayload; + const replyToMessageId = resolveReplyTo(); const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText; const mediaParams: Record = { caption: htmlCaption, @@ -245,13 +246,13 @@ export async function deliverReplies(params: { runtime, text: fallbackText, chunkText, - replyToId: replyToMessageIdForPayload, + replyToId: resolveReplyTo(), thread, linkPreview, replyMarkup, replyQuoteText, }); - if (replyToMessageIdForPayload && !hasReplied) { + if (replyToId && !hasReplied) { hasReplied = true; } markDelivered(); @@ -317,21 +318,22 @@ export async function deliverReplies(params: { const chunks = chunkText(pendingFollowUpText); for (let i = 0; i < chunks.length; i += 1) { const chunk = chunks[i]; + const replyToForFollowUp = resolveReplyTo(); await sendTelegramText(bot, chatId, chunk.html, runtime, { - replyToMessageId: replyToMessageIdForPayload, + replyToMessageId: replyToForFollowUp, thread, textMode: "html", plainText: chunk.text, linkPreview, replyMarkup: i === 0 ? replyMarkup : undefined, }); + if (replyToForFollowUp && !hasReplied) { + hasReplied = true; + } markDelivered(); } pendingFollowUpText = undefined; } - if (replyToMessageIdForPayload && !hasReplied) { - hasReplied = true; - } } } @@ -538,10 +540,12 @@ async function sendTelegramVoiceFallbackText(opts: { replyQuoteText?: string; }): Promise { const chunks = opts.chunkText(opts.text); + let appliedReplyTo = false; for (let i = 0; i < chunks.length; i += 1) { const chunk = chunks[i]; + const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined; await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { - replyToMessageId: opts.replyToId, + replyToMessageId: replyToForChunk, replyQuoteText: opts.replyQuoteText, thread: opts.thread, textMode: "html", @@ -549,6 +553,9 @@ async function sendTelegramVoiceFallbackText(opts: { linkPreview: opts.linkPreview, replyMarkup: i === 0 ? opts.replyMarkup : undefined, }); + if (replyToForChunk) { + appliedReplyTo = true; + } } }