mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +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/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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user