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 <noreply@anthropic.com>
This commit is contained in:
scoootscooob
2026-03-01 15:49:22 -08:00
committed by Peter Steinberger
parent f64d25bd3e
commit 2a381e6d7b
3 changed files with 135 additions and 15 deletions

View File

@@ -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.

View File

@@ -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(),

View File

@@ -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<string, unknown> = {
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<void> {
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;
}
}
}