mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 19:04:31 +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
@@ -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