mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:04:30 +00:00
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:
committed by
Peter Steinberger
parent
f64d25bd3e
commit
2a381e6d7b
@@ -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/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/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/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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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 () => {
|
it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => {
|
||||||
const runtime = createRuntime();
|
const runtime = createRuntime();
|
||||||
const sendVoice = vi.fn().mockRejectedValue(new Error("Network error"));
|
const sendVoice = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||||
@@ -380,6 +409,89 @@ describe("deliverReplies", () => {
|
|||||||
expect(sendMessage).not.toHaveBeenCalled();
|
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 () => {
|
it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => {
|
||||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||||
voiceError: createVoiceMessagesForbiddenError(),
|
voiceError: createVoiceMessagesForbiddenError(),
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ export async function deliverReplies(params: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
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;
|
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
||||||
const mediaList = reply.mediaUrls?.length
|
const mediaList = reply.mediaUrls?.length
|
||||||
? reply.mediaUrls
|
? reply.mediaUrls
|
||||||
@@ -125,7 +127,6 @@ export async function deliverReplies(params: {
|
|||||||
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
const chunks = chunkText(reply.text || "");
|
const chunks = chunkText(reply.text || "");
|
||||||
let sentTextChunk = false;
|
|
||||||
for (let i = 0; i < chunks.length; i += 1) {
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
@@ -133,8 +134,9 @@ export async function deliverReplies(params: {
|
|||||||
}
|
}
|
||||||
// Only attach buttons to the first chunk.
|
// Only attach buttons to the first chunk.
|
||||||
const shouldAttachButtons = i === 0 && replyMarkup;
|
const shouldAttachButtons = i === 0 && replyMarkup;
|
||||||
|
const replyToForChunk = resolveReplyTo();
|
||||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||||
replyToMessageId: replyToMessageIdForPayload,
|
replyToMessageId: replyToForChunk,
|
||||||
replyQuoteText,
|
replyQuoteText,
|
||||||
thread,
|
thread,
|
||||||
textMode: "html",
|
textMode: "html",
|
||||||
@@ -142,12 +144,11 @@ export async function deliverReplies(params: {
|
|||||||
linkPreview,
|
linkPreview,
|
||||||
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
|
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
|
||||||
});
|
});
|
||||||
sentTextChunk = true;
|
if (replyToForChunk && !hasReplied) {
|
||||||
|
hasReplied = true;
|
||||||
|
}
|
||||||
markDelivered();
|
markDelivered();
|
||||||
}
|
}
|
||||||
if (replyToMessageIdForPayload && !hasReplied && sentTextChunk) {
|
|
||||||
hasReplied = true;
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// media with optional caption on first item
|
// media with optional caption on first item
|
||||||
@@ -178,7 +179,7 @@ export async function deliverReplies(params: {
|
|||||||
pendingFollowUpText = followUpText;
|
pendingFollowUpText = followUpText;
|
||||||
}
|
}
|
||||||
first = false;
|
first = false;
|
||||||
const replyToMessageId = replyToMessageIdForPayload;
|
const replyToMessageId = resolveReplyTo();
|
||||||
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
|
const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText;
|
||||||
const mediaParams: Record<string, unknown> = {
|
const mediaParams: Record<string, unknown> = {
|
||||||
caption: htmlCaption,
|
caption: htmlCaption,
|
||||||
@@ -245,13 +246,13 @@ export async function deliverReplies(params: {
|
|||||||
runtime,
|
runtime,
|
||||||
text: fallbackText,
|
text: fallbackText,
|
||||||
chunkText,
|
chunkText,
|
||||||
replyToId: replyToMessageIdForPayload,
|
replyToId: resolveReplyTo(),
|
||||||
thread,
|
thread,
|
||||||
linkPreview,
|
linkPreview,
|
||||||
replyMarkup,
|
replyMarkup,
|
||||||
replyQuoteText,
|
replyQuoteText,
|
||||||
});
|
});
|
||||||
if (replyToMessageIdForPayload && !hasReplied) {
|
if (replyToId && !hasReplied) {
|
||||||
hasReplied = true;
|
hasReplied = true;
|
||||||
}
|
}
|
||||||
markDelivered();
|
markDelivered();
|
||||||
@@ -317,21 +318,22 @@ export async function deliverReplies(params: {
|
|||||||
const chunks = chunkText(pendingFollowUpText);
|
const chunks = chunkText(pendingFollowUpText);
|
||||||
for (let i = 0; i < chunks.length; i += 1) {
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
|
const replyToForFollowUp = resolveReplyTo();
|
||||||
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
||||||
replyToMessageId: replyToMessageIdForPayload,
|
replyToMessageId: replyToForFollowUp,
|
||||||
thread,
|
thread,
|
||||||
textMode: "html",
|
textMode: "html",
|
||||||
plainText: chunk.text,
|
plainText: chunk.text,
|
||||||
linkPreview,
|
linkPreview,
|
||||||
replyMarkup: i === 0 ? replyMarkup : undefined,
|
replyMarkup: i === 0 ? replyMarkup : undefined,
|
||||||
});
|
});
|
||||||
|
if (replyToForFollowUp && !hasReplied) {
|
||||||
|
hasReplied = true;
|
||||||
|
}
|
||||||
markDelivered();
|
markDelivered();
|
||||||
}
|
}
|
||||||
pendingFollowUpText = undefined;
|
pendingFollowUpText = undefined;
|
||||||
}
|
}
|
||||||
if (replyToMessageIdForPayload && !hasReplied) {
|
|
||||||
hasReplied = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,10 +540,12 @@ async function sendTelegramVoiceFallbackText(opts: {
|
|||||||
replyQuoteText?: string;
|
replyQuoteText?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const chunks = opts.chunkText(opts.text);
|
const chunks = opts.chunkText(opts.text);
|
||||||
|
let appliedReplyTo = false;
|
||||||
for (let i = 0; i < chunks.length; i += 1) {
|
for (let i = 0; i < chunks.length; i += 1) {
|
||||||
const chunk = chunks[i];
|
const chunk = chunks[i];
|
||||||
|
const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined;
|
||||||
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
||||||
replyToMessageId: opts.replyToId,
|
replyToMessageId: replyToForChunk,
|
||||||
replyQuoteText: opts.replyQuoteText,
|
replyQuoteText: opts.replyQuoteText,
|
||||||
thread: opts.thread,
|
thread: opts.thread,
|
||||||
textMode: "html",
|
textMode: "html",
|
||||||
@@ -549,6 +553,9 @@ async function sendTelegramVoiceFallbackText(opts: {
|
|||||||
linkPreview: opts.linkPreview,
|
linkPreview: opts.linkPreview,
|
||||||
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
|
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
|
||||||
});
|
});
|
||||||
|
if (replyToForChunk) {
|
||||||
|
appliedReplyTo = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user