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

@@ -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;
}
}
}