diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index d8be56e1db6..03e1a4b10cf 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -4445,7 +4445,7 @@ describe("createTelegramBot", () => { }); expect(sendMessageSpy.mock.calls.length).toBeGreaterThan(1); - for (const [index, call] of sendMessageSpy.mock.calls.entries()) { + for (const call of sendMessageSpy.mock.calls) { const params = call[2] as | { reply_to_message_id?: number; reply_parameters?: { message_id?: number } } | undefined; diff --git a/extensions/telegram/src/channel.message-adapter.test.ts b/extensions/telegram/src/channel.message-adapter.test.ts index 942c3297908..3f01ff70a42 100644 --- a/extensions/telegram/src/channel.message-adapter.test.ts +++ b/extensions/telegram/src/channel.message-adapter.test.ts @@ -82,19 +82,37 @@ describe("telegram channel message adapter", () => { }; const provePayload = async () => { - sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-payload", chatId: "12345" }); + sendMessageTelegramMock.mockResolvedValueOnce({ + messageId: "tg-payload-2", + chatId: "12345", + receipt: { + primaryPlatformMessageId: "tg-payload-1", + platformMessageIds: ["tg-payload-1", "tg-payload-2"], + parts: [ + { platformMessageId: "tg-payload-1", kind: "text", index: 0 }, + { platformMessageId: "tg-payload-2", kind: "text", index: 1 }, + ], + sentAt: 123, + }, + }); const result = await adapter.send!.payload!({ cfg: {} as never, to: "12345", text: "payload", payload: { text: "payload" }, + replyToId: "900", + replyToIdSource: "implicit", + replyToMode: "first", + threadId: "12", deps: { sendTelegram: sendMessageTelegramMock }, }); expect(sendMessageTelegramMock).toHaveBeenLastCalledWith("12345", "payload", { cfg: {}, verbose: false, - messageThreadId: undefined, - replyToMessageId: undefined, + messageThreadId: 12, + replyToMessageId: 900, + replyToIdSource: "implicit", + replyToMode: "first", accountId: undefined, silent: undefined, gatewayClientScopes: undefined, @@ -104,7 +122,8 @@ describe("telegram channel message adapter", () => { quoteText: undefined, buttons: undefined, }); - expect(result.receipt.platformMessageIds).toEqual(["tg-payload"]); + expect(result.receipt.primaryPlatformMessageId).toBe("tg-payload-1"); + expect(result.receipt.platformMessageIds).toEqual(["tg-payload-1", "tg-payload-2"]); }; const proveReplyThreadSilent = async () => { @@ -114,6 +133,8 @@ describe("telegram channel message adapter", () => { to: "12345", text: "threaded", replyToId: "900", + replyToIdSource: "implicit", + replyToMode: "first", threadId: "12", silent: true, deps: { sendTelegram: sendMessageTelegramMock }, @@ -123,6 +144,8 @@ describe("telegram channel message adapter", () => { verbose: false, messageThreadId: 12, replyToMessageId: 900, + replyToIdSource: "implicit", + replyToMode: "first", accountId: undefined, silent: true, gatewayClientScopes: undefined, @@ -138,6 +161,9 @@ describe("telegram channel message adapter", () => { cfg: {} as never, to: "12345", text: "batch", + replyToId: "900", + replyToIdSource: "implicit", + replyToMode: "first", payload: { text: "batch", mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], @@ -152,7 +178,9 @@ describe("telegram channel message adapter", () => { cfg: {}, verbose: false, messageThreadId: undefined, - replyToMessageId: undefined, + replyToMessageId: 900, + replyToIdSource: "implicit", + replyToMode: "first", accountId: undefined, silent: undefined, gatewayClientScopes: undefined, @@ -172,6 +200,8 @@ describe("telegram channel message adapter", () => { verbose: false, messageThreadId: undefined, replyToMessageId: undefined, + replyToIdSource: undefined, + replyToMode: undefined, accountId: undefined, silent: undefined, gatewayClientScopes: undefined, @@ -222,6 +252,36 @@ describe("telegram channel message adapter", () => { }); }); + it("keeps implicit first replies on the first delivered payload media", async () => { + const adapter = requireTelegramMessageAdapter(); + sendMessageTelegramMock + .mockResolvedValueOnce({ messageId: "tg-media-1", chatId: "12345" }) + .mockResolvedValueOnce({ messageId: "tg-media-2", chatId: "12345" }); + + await adapter.send!.payload!({ + cfg: {} as never, + to: "12345", + text: "batch", + replyToId: "900", + replyToIdSource: "implicit", + replyToMode: "first", + payload: { + text: "batch", + mediaUrls: ["", "https://example.com/a.png", "https://example.com/b.png"], + }, + deps: { sendTelegram: sendMessageTelegramMock }, + }); + + const firstOpts = sendMessageTelegramMock.mock.calls[0]?.[2] as + | { replyToMessageId?: number } + | undefined; + const secondOpts = sendMessageTelegramMock.mock.calls[1]?.[2] as + | { replyToMessageId?: number } + | undefined; + expect(firstOpts?.replyToMessageId).toBe(900); + expect(secondOpts?.replyToMessageId).toBeUndefined(); + }); + it("backs declared live preview finalizer capabilities with adapter proofs", async () => { const adapter = requireTelegramMessageAdapter(); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 51b6afe1d59..106856d1144 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -19,6 +19,7 @@ import { resolvePayloadMediaUrls, sendPayloadMediaSequenceOrFallback, } from "openclaw/plugin-sdk/reply-payload"; +import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; import type { TelegramInlineButtons } from "./button-types.js"; @@ -60,6 +61,8 @@ async function resolveTelegramSendContext(params: { deps?: OutboundSendDeps; accountId?: string | null; replyToId?: string | null; + replyToIdSource?: TelegramSendOpts["replyToIdSource"]; + replyToMode?: TelegramSendOpts["replyToMode"]; threadId?: string | number | null; formatting?: OutboundDeliveryFormattingOptions; silent?: boolean; @@ -74,6 +77,8 @@ async function resolveTelegramSendContext(params: { tableMode?: OutboundDeliveryFormattingOptions["tableMode"]; messageThreadId?: number; replyToMessageId?: number; + replyToIdSource?: TelegramSendOpts["replyToIdSource"]; + replyToMode?: TelegramSendOpts["replyToMode"]; accountId?: string; silent?: boolean; gatewayClientScopes?: readonly string[]; @@ -87,6 +92,8 @@ async function resolveTelegramSendContext(params: { cfg: params.cfg, messageThreadId: parseTelegramThreadId(params.threadId), replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), + ...(params.replyToIdSource !== undefined ? { replyToIdSource: params.replyToIdSource } : {}), + ...(params.replyToMode !== undefined ? { replyToMode: params.replyToMode } : {}), accountId: params.accountId ?? undefined, silent: params.silent, gatewayClientScopes: params.gatewayClientScopes, @@ -151,6 +158,19 @@ export async function sendTelegramPayloadMessages(params: { quoteText, ...(params.payload.audioAsVoice === true ? { asVoice: true } : {}), }; + const shouldConsumeImplicitReplyTarget = + payloadOpts.replyToIdSource === "implicit" && + payloadOpts.replyToMode !== undefined && + isSingleUseReplyToMode(payloadOpts.replyToMode); + const consumedImplicitReplyPayloadOpts = shouldConsumeImplicitReplyTarget + ? { + ...payloadOpts, + replyToMessageId: undefined, + replyToIdSource: undefined, + replyToMode: undefined, + } + : payloadOpts; + let implicitReplyTargetAvailable = true; if (reactionEmoji) { if (typeof replyToMessageId !== "number") { throw new Error("Telegram reaction requires a reply target"); @@ -179,12 +199,18 @@ export async function sendTelegramPayloadMessages(params: { ...payloadOpts, buttons, }), - send: async ({ text: textLocal, mediaUrl, isFirst }) => - await params.send(params.to, textLocal, { - ...payloadOpts, + send: async ({ text: textLocal, mediaUrl, isFirst }) => { + const mediaPayloadOpts = + shouldConsumeImplicitReplyTarget && !implicitReplyTargetAvailable + ? consumedImplicitReplyPayloadOpts + : payloadOpts; + implicitReplyTargetAvailable = false; + return await params.send(params.to, textLocal, { + ...mediaPayloadOpts, mediaUrl, ...(isFirst ? { buttons } : {}), - }), + }); + }, }); } diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index aba8d7f5e81..556b1c2fc24 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -1650,6 +1650,49 @@ describe("sendMessageTelegram", () => { expect(res.messageId).toBe("71"); }); + it("does not reuse first-mode reply-to on media caption follow-up text", async () => { + const chatId = "123"; + const longText = "A".repeat(1100); + + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 70, + chat: { id: chatId }, + }); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 71, + chat: { id: chatId }, + }); + const api = { sendPhoto, sendMessage } as unknown as { + sendPhoto: typeof sendPhoto; + sendMessage: typeof sendMessage; + }; + + mockLoadedMedia({ + buffer: Buffer.from("fake-image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await sendMessageTelegram(chatId, longText, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + api, + mediaUrl: "https://example.com/photo.jpg", + replyToMessageId: 500, + replyToIdSource: "implicit", + replyToMode: "first", + }); + + expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, { + caption: undefined, + reply_to_message_id: 500, + allow_sending_without_reply: true, + }); + expect(sendMessage).toHaveBeenCalledWith(chatId, longText, { + parse_mode: "HTML", + }); + }); + it("chunks long default markdown media follow-up text", async () => { const chatId = "123"; const longText = `**${"A".repeat(5000)}**`; @@ -1658,7 +1701,10 @@ describe("sendMessageTelegram", () => { message_id: 72, chat: { id: chatId }, }); - const sendMessage = vi.fn().mockResolvedValue({ message_id: 74, chat: { id: chatId } }); + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 73, chat: { id: chatId } }) + .mockResolvedValueOnce({ message_id: 74, chat: { id: chatId } }); const api = { sendPhoto, sendMessage } as unknown as { sendPhoto: typeof sendPhoto; sendMessage: typeof sendMessage; @@ -1684,6 +1730,9 @@ describe("sendMessageTelegram", () => { expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true); expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain("A"); expect(res.messageId).toBe("74"); + expect(res.receipt?.primaryPlatformMessageId).toBe("73"); + expect(res.receipt?.platformMessageIds).toEqual(["73", "74"]); + expect(res.receipt?.parts.map((part) => part.kind)).toEqual(["text", "text"]); }); it("uses caption when text is within 1024 char limit", async () => { @@ -2499,6 +2548,93 @@ describe("sendMessageTelegram", () => { } }); + it("returns a multipart receipt and avoids native replies for chunked first-mode text", async () => { + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 101, chat: { id: "-1001234567890" } }) + .mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + const result = await sendMessageTelegram("-1001234567890", `BEGIN ${"A".repeat(4100)} END`, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + api, + messageThreadId: 271, + replyToMessageId: 500, + replyToIdSource: "implicit", + replyToMode: "first", + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(sendMessage.mock.calls[0]?.[2]).toEqual({ + parse_mode: "HTML", + message_thread_id: 271, + }); + expect(sendMessage.mock.calls[1]?.[2]).toEqual({ + parse_mode: "HTML", + message_thread_id: 271, + }); + expect(result.messageId).toBe("102"); + expect(result.receipt?.primaryPlatformMessageId).toBe("101"); + expect(result.receipt?.platformMessageIds).toEqual(["101", "102"]); + expect(result.receipt?.threadId).toBe("271"); + expect(result.receipt?.replyToId).toBeUndefined(); + expect( + result.receipt?.parts.map(({ platformMessageId, kind, index, threadId, replyToId }) => ({ + platformMessageId, + kind, + index, + threadId, + replyToId, + })), + ).toEqual([ + { + platformMessageId: "101", + kind: "text", + index: 0, + threadId: "271", + replyToId: undefined, + }, + { + platformMessageId: "102", + kind: "text", + index: 1, + threadId: "271", + replyToId: undefined, + }, + ]); + }); + + it("keeps explicit native replies for chunked first-mode text", async () => { + const sendMessage = vi + .fn() + .mockResolvedValueOnce({ message_id: 101, chat: { id: "-1001234567890" } }) + .mockResolvedValueOnce({ message_id: 102, chat: { id: "-1001234567890" } }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await sendMessageTelegram("-1001234567890", `BEGIN ${"A".repeat(4100)} END`, { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + api, + replyToMessageId: 500, + replyToIdSource: "explicit", + replyToMode: "first", + }); + + expect(sendMessage.mock.calls[0]?.[2]).toMatchObject({ + reply_to_message_id: 500, + allow_sending_without_reply: true, + }); + expect(sendMessage.mock.calls[1]?.[2]).toMatchObject({ + reply_to_message_id: 500, + allow_sending_without_reply: true, + }); + }); + it("fails topic sends instead of retrying without message_thread_id", async () => { const cases = [{ name: "forum", chatId: "-100123", text: "hello forum" }] as const; const threadErr = new Error("400: Bad Request: message thread not found"); diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 6ae8891fc86..005ef3b1dae 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -3,12 +3,17 @@ import * as grammy from "grammy"; import { type ApiClientOptions, Bot, HttpError } from "grammy"; import type { ReactionType, ReactionTypeEmoji } from "grammy/types"; import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime"; -import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts"; +import { + createMessageReceiptFromOutboundResults, + type MessageReceipt, +} from "openclaw/plugin-sdk/channel-outbound"; +import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts"; import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime"; import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime"; import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core"; import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking"; +import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; import { createTelegramRetryRunner, type RetryConfig } from "openclaw/plugin-sdk/retry-runtime"; import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -84,6 +89,8 @@ type TelegramEditMessageCaptionParams = Parameters[2]>; type TelegramThreadScopedParams = { message_thread_id?: number; + reply_parameters?: { message_id?: number }; + reply_to_message_id?: number; }; const InputFileCtor = grammy.InputFile; const MAX_TELEGRAM_PHOTO_DIMENSION_SUM = 10_000; @@ -111,6 +118,10 @@ type TelegramSendOpts = { silent?: boolean; /** Message ID to reply to (for threading) */ replyToMessageId?: number; + /** Whether replyToMessageId came from ambient context or explicit payload/action input. */ + replyToIdSource?: "explicit" | "implicit"; + /** Controls whether replyToMessageId is applied to every internal text chunk. */ + replyToMode?: ReplyToMode; /** Quote text for Telegram reply_parameters. */ quoteText?: string; /** Forum topic thread ID (for forum supergroups) */ @@ -124,6 +135,7 @@ type TelegramSendOpts = { type TelegramSendResult = { messageId: string; chatId: string; + receipt?: MessageReceipt; }; type TelegramMessageLike = { @@ -274,6 +286,42 @@ function logTelegramOutboundSendOk(params: TelegramOutboundSuccessLogParams): vo sendLogger.info(parts.join(" ")); } +function buildTelegramTextSendReceipt(params: { + messageIds: readonly string[]; + chatId: string; + messageThreadId?: number; + replyToMessageId?: number; +}): MessageReceipt | undefined { + if (params.messageIds.length <= 1) { + return undefined; + } + return createMessageReceiptFromOutboundResults({ + results: params.messageIds.map((messageId) => ({ + messageId, + chatId: params.chatId, + })), + kind: "text", + ...(typeof params.messageThreadId === "number" + ? { threadId: String(params.messageThreadId) } + : {}), + ...(typeof params.replyToMessageId === "number" + ? { replyToId: String(params.replyToMessageId) } + : {}), + }); +} + +function resolveAcceptedReplyToMessageId( + params: TelegramThreadScopedParams | TelegramRichMessageContextParams | undefined, +): number | undefined { + if (!params) { + return undefined; + } + if ("reply_to_message_id" in params) { + return params.reply_to_message_id; + } + return params.reply_parameters?.message_id; +} + const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const MESSAGE_NOT_MODIFIED_RE = /400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i; @@ -661,19 +709,26 @@ export async function sendMessageTelegram( (typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024; const replyMarkup = buildInlineKeyboard(opts.buttons); - const threadParams = buildTelegramThreadReplyParams({ - thread: resolveTelegramSendThreadSpec({ - targetMessageThreadId: target.messageThreadId, - messageThreadId: opts.messageThreadId, - chatType: target.chatType, - }), - replyToMessageId: opts.replyToMessageId, - replyQuoteText: opts.quoteText, - useReplyIdAsQuoteSource: true, + const threadSpec = resolveTelegramSendThreadSpec({ + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, }); - const richThreadParams = toTelegramRichMessageContextParams(threadParams); - const hasThreadParams = Object.keys(threadParams).length > 0; - const hasRichThreadParams = Object.keys(richThreadParams).length > 0; + const singleUseReplyTo = + opts.replyToIdSource === "implicit" && + opts.replyToMode !== undefined && + isSingleUseReplyToMode(opts.replyToMode); + const buildThreadParams = (includeReplyTo: boolean) => + buildTelegramThreadReplyParams({ + thread: threadSpec, + ...(includeReplyTo + ? { + replyToMessageId: opts.replyToMessageId, + replyQuoteText: opts.quoteText, + useReplyIdAsQuoteSource: true, + } + : {}), + }); const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({ cfg, account, @@ -746,29 +801,59 @@ export async function sendMessageTelegram( return { result, acceptedParams: params }; }; - const buildTextParams = (isLastChunk: boolean) => - hasThreadParams || (isLastChunk && replyMarkup) - ? { - ...threadParams, - ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), - } - : undefined; + const shouldIncludeReplyForChunk = ( + index: number, + chunkCount: number, + replyToAlreadyUsed: boolean, + ) => + // Telegram Desktop can render long formatted reply chunks as unsupported messages. + // Multi-part `first` replies keep chat/topic routing but avoid hiding chunk text. + !replyToAlreadyUsed && (!singleUseReplyTo || (chunkCount === 1 && index === 0)); - const buildRichTextParams = (isLastChunk: boolean) => - hasRichThreadParams || (isLastChunk && replyMarkup) + const buildTextParams = ( + index: number, + chunkCount: number, + isLastChunk: boolean, + replyToAlreadyUsed: boolean, + ) => { + const params = buildThreadParams( + shouldIncludeReplyForChunk(index, chunkCount, replyToAlreadyUsed), + ); + return Object.keys(params).length > 0 || (isLastChunk && replyMarkup) ? { - ...richThreadParams, + ...params, ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), } : undefined; + }; + + const buildRichTextParams = ( + index: number, + chunkCount: number, + isLastChunk: boolean, + replyToAlreadyUsed: boolean, + ) => { + const params = toTelegramRichMessageContextParams( + buildThreadParams(shouldIncludeReplyForChunk(index, chunkCount, replyToAlreadyUsed)), + ); + return Object.keys(params).length > 0 || (isLastChunk && replyMarkup) + ? { + ...params, + ...(isLastChunk && replyMarkup ? { reply_markup: replyMarkup } : {}), + } + : undefined; + }; const sendTelegramTextChunks = async ( chunks: TelegramTextChunk[], context: string, - ): Promise<{ messageId: string; chatId: string }> => { + options: { replyToAlreadyUsed?: boolean } = {}, + ): Promise => { let lastMessageId = ""; let lastChatId = chatId; let lastAcceptedParams: TelegramThreadScopedParams | undefined; + let acceptedReplyToMessageId: number | undefined; + const messageIds: string[] = []; let sentChunkCount = 0; for (let index = 0; index < chunks.length; index += 1) { const chunk = chunks[index]; @@ -777,7 +862,12 @@ export async function sendMessageTelegram( } const { result: res, acceptedParams } = await sendTelegramTextChunk( chunk, - buildTextParams(index === chunks.length - 1), + buildTextParams( + index, + chunks.length, + index === chunks.length - 1, + options.replyToAlreadyUsed === true, + ), ); const messageId = resolveTelegramMessageIdOrThrow(res, context); recordSentMessage(chatId, messageId, cfg); @@ -795,6 +885,8 @@ export async function sendMessageTelegram( lastMessageId = String(messageId); lastChatId = String(res?.chat?.id ?? chatId); lastAcceptedParams = acceptedParams; + acceptedReplyToMessageId ??= resolveAcceptedReplyToMessageId(acceptedParams); + messageIds.push(lastMessageId); sentChunkCount += 1; } if (lastMessageId) { @@ -810,7 +902,17 @@ export async function sendMessageTelegram( chunkCount: sentChunkCount, }); } - return { messageId: lastMessageId, chatId: lastChatId }; + const receipt = buildTelegramTextSendReceipt({ + messageIds, + chatId: lastChatId, + messageThreadId: lastAcceptedParams?.message_thread_id, + replyToMessageId: acceptedReplyToMessageId, + }); + return { + messageId: lastMessageId, + chatId: lastChatId, + ...(receipt ? { receipt } : {}), + }; }; const buildChunkedTextPlan = (rawText: string, context: string): TelegramTextChunk[] => { @@ -841,10 +943,14 @@ export async function sendMessageTelegram( })); }; - const sendChunkedText = async (rawText: string, context: string) => + const sendChunkedText = async ( + rawText: string, + context: string, + options: { replyToAlreadyUsed?: boolean } = {}, + ) => useRichMessages - ? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context) - : await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context); + ? await sendTelegramRichTextChunks(buildRichTextPlan(rawText), context, options) + : await sendTelegramTextChunks(buildChunkedTextPlan(rawText, context), context, options); const buildRichTextPlan = (rawText: string): TelegramRichTextChunk[] => { const textLimit = Math.min( @@ -866,18 +972,26 @@ export async function sendMessageTelegram( const sendTelegramRichTextChunks = async ( chunks: TelegramRichTextChunk[], context: string, - ): Promise<{ messageId: string; chatId: string }> => { + options: { replyToAlreadyUsed?: boolean } = {}, + ): Promise => { const richRawApi = getTelegramRichRawApi(api); let lastMessageId = ""; let lastChatId = chatId; let lastAcceptedParams: TelegramRichMessageContextParams | undefined; + let acceptedReplyToMessageId: number | undefined; + const messageIds: string[] = []; let sentChunkCount = 0; for (let index = 0; index < chunks.length; index += 1) { const chunk = chunks[index]; if (!chunk) { continue; } - const acceptedParams = buildRichTextParams(index === chunks.length - 1); + const acceptedParams = buildRichTextParams( + index, + chunks.length, + index === chunks.length - 1, + options.replyToAlreadyUsed === true, + ); const result = await requestWithChatNotFound( () => richRawApi.sendRichMessage({ @@ -907,6 +1021,8 @@ export async function sendMessageTelegram( lastMessageId = String(messageId); lastChatId = String(result?.chat?.id ?? chatId); lastAcceptedParams = acceptedParams; + acceptedReplyToMessageId ??= resolveAcceptedReplyToMessageId(acceptedParams); + messageIds.push(lastMessageId); sentChunkCount += 1; } if (lastMessageId) { @@ -922,7 +1038,17 @@ export async function sendMessageTelegram( chunkCount: sentChunkCount, }); } - return { messageId: lastMessageId, chatId: lastChatId }; + const receipt = buildTelegramTextSendReceipt({ + messageIds, + chatId: lastChatId, + messageThreadId: lastAcceptedParams?.message_thread_id, + replyToMessageId: acceptedReplyToMessageId, + }); + return { + messageId: lastMessageId, + chatId: lastChatId, + ...(receipt ? { receipt } : {}), + }; }; async function shouldSendTelegramImageAsPhoto(buffer: Buffer): Promise { @@ -1001,8 +1127,10 @@ export async function sendMessageTelegram( const needsSeparateText = Boolean(followUpText); // When splitting, put reply_markup only on the follow-up text (the "main" content), // not on the media message. + const mediaThreadParams = buildThreadParams(true); + const mediaUsedReplyTo = resolveAcceptedReplyToMessageId(mediaThreadParams) !== undefined; const baseMediaParams = { - ...(hasThreadParams ? threadParams : {}), + ...mediaThreadParams, ...(!needsSeparateText && replyMarkup ? { reply_markup: replyMarkup } : {}), }; const videoDimensions = @@ -1145,8 +1273,13 @@ export async function sendMessageTelegram( // If text was too long for a caption, send it as a separate follow-up message. // Use HTML conversion so markdown renders like captions. if (needsSeparateText && followUpText) { - const textResult = await sendChunkedText(followUpText, "text follow-up send"); - return { messageId: textResult.messageId, chatId: resolvedChatId }; + const textResult = await sendChunkedText(followUpText, "text follow-up send", { + replyToAlreadyUsed: singleUseReplyTo && mediaUsedReplyTo, + }); + return { + ...textResult, + chatId: resolvedChatId, + }; } return { messageId: String(mediaMessageId), chatId: resolvedChatId };