From 1f73dac9230e1e159805af81d4dcd9700c51ba87 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 27 Jan 2026 15:50:47 -0500 Subject: [PATCH] fix: wire telegram quote support (#2900) Co-authored-by: aduk059 --- src/agents/tools/message-tool.ts | 3 + src/agents/tools/telegram-actions.test.ts | 25 +++++++++ src/agents/tools/telegram-actions.ts | 2 + src/auto-reply/templating.ts | 1 + src/canvas-host/a2ui/.bundle.hash | 2 +- src/channels/plugins/actions/telegram.ts | 2 + src/channels/plugins/outbound/telegram.ts | 5 +- src/telegram/bot-message-context.ts | 15 ++--- src/telegram/bot-message-dispatch.ts | 5 ++ src/telegram/bot.test.ts | 67 +++++++++++++++++++++++ src/telegram/bot/delivery.test.ts | 31 +++++++++++ src/telegram/bot/delivery.ts | 41 +++++++++++--- src/telegram/bot/helpers.ts | 42 +++++++------- src/telegram/bot/types.ts | 8 ++- src/telegram/send.ts | 14 ++++- 15 files changed, 225 insertions(+), 38 deletions(-) diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 2188d737d..4ea178a54 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -60,6 +60,9 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole threadId: Type.Optional(Type.String()), asVoice: Type.Optional(Type.Boolean()), silent: Type.Optional(Type.Boolean()), + quoteText: Type.Optional( + Type.String({ description: "Quote text for Telegram reply_parameters" }), + ), bestEffort: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()), buttons: Type.Optional( diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts index 63a55e3d0..3b78dde20 100644 --- a/src/agents/tools/telegram-actions.test.ts +++ b/src/agents/tools/telegram-actions.test.ts @@ -261,6 +261,31 @@ describe("handleTelegramAction", () => { ); }); + it("passes quoteText when provided", async () => { + const cfg = { + channels: { telegram: { botToken: "tok" } }, + } as MoltbotConfig; + await handleTelegramAction( + { + action: "sendMessage", + to: "123456", + content: "Replying now", + replyToMessageId: 144, + quoteText: "The text you want to quote", + }, + cfg, + ); + expect(sendMessageTelegram).toHaveBeenCalledWith( + "123456", + "Replying now", + expect.objectContaining({ + token: "tok", + replyToMessageId: 144, + quoteText: "The text you want to quote", + }), + ); + }); + it("allows media-only messages without content", async () => { const cfg = { channels: { telegram: { botToken: "tok" } }, diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index 3d7dc6eb2..515ff8c47 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -165,6 +165,7 @@ export async function handleTelegramAction( const messageThreadId = readNumberParam(params, "messageThreadId", { integer: true, }); + const quoteText = readStringParam(params, "quoteText"); const token = resolveTelegramToken(cfg, { accountId }).token; if (!token) { throw new Error( @@ -178,6 +179,7 @@ export async function handleTelegramAction( buttons, replyToMessageId: replyToMessageId ?? undefined, messageThreadId: messageThreadId ?? undefined, + quoteText: quoteText ?? undefined, asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined, silent: typeof params.silent === "boolean" ? params.silent : undefined, }); diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 593858e64..242cee232 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -49,6 +49,7 @@ export type MsgContext = { ReplyToIdFull?: string; ReplyToBody?: string; ReplyToSender?: string; + ReplyToIsQuote?: boolean; ForwardedFrom?: string; ForwardedFromType?: string; ForwardedFromId?: string; diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 6c9cb0299..dd57697ae 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -b6d3dea7c656c8a480059c32e954c4d39053ff79c4e9c69b38f4c04e3f0280d4 +bd5789522e6bde45274e15fdd45b10c9a41da378b190d6f42cef5ef2a69d72a7 diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index 2acfaf9f1..693e94492 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -23,6 +23,7 @@ function readTelegramSendParams(params: Record) { const buttons = params.buttons; const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined; const silent = typeof params.silent === "boolean" ? params.silent : undefined; + const quoteText = readStringParam(params, "quoteText"); return { to, content, @@ -32,6 +33,7 @@ function readTelegramSendParams(params: Record) { buttons, asVoice, silent, + quoteText: quoteText ?? undefined, }; } diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 6db7afd28..04abb77e0 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -56,8 +56,10 @@ export const telegramOutbound: ChannelOutboundAdapter = { const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); const telegramData = payload.channelData?.telegram as - | { buttons?: Array> } + | { buttons?: Array>; quoteText?: string } | undefined; + const quoteText = + typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const text = payload.text ?? ""; const mediaUrls = payload.mediaUrls?.length ? payload.mediaUrls @@ -69,6 +71,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { textMode: "html" as const, messageThreadId, replyToMessageId, + quoteText, accountId: accountId ?? undefined, }; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index a5f50c642..aa6dcd88b 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -480,13 +480,13 @@ export const buildTelegramMessageContext = async ({ const replyTarget = describeReplyTarget(msg); const forwardOrigin = normalizeForwardedContext(msg); const replySuffix = replyTarget - ? (replyTarget as any).isQuote - ? `\n\n[Quoting ${replyTarget.sender}${ - replyTarget.id ? ` id:${replyTarget.id}` : "" - }]\n"${replyTarget.body}"\n[/Quoting]` - : `\n\n[Replying to ${replyTarget.sender}${ - replyTarget.id ? ` id:${replyTarget.id}` : "" - }]\n${replyTarget.body}\n[/Replying]` + ? replyTarget.kind === "quote" + ? `\n\n[Quoting ${replyTarget.sender}${ + replyTarget.id ? ` id:${replyTarget.id}` : "" + }]\n"${replyTarget.body}"\n[/Quoting]` + : `\n\n[Replying to ${replyTarget.sender}${ + replyTarget.id ? ` id:${replyTarget.id}` : "" + }]\n${replyTarget.body}\n[/Replying]` : ""; const forwardPrefix = forwardOrigin ? `[Forwarded from ${forwardOrigin.from}${ @@ -569,6 +569,7 @@ export const buildTelegramMessageContext = async ({ ReplyToId: replyTarget?.id, ReplyToBody: replyTarget?.body, ReplyToSender: replyTarget?.sender, + ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined, ForwardedFrom: forwardOrigin?.from, ForwardedFromType: forwardOrigin?.fromType, ForwardedFromId: forwardOrigin?.fromId, diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 27c6a3bfa..cead0628a 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -210,6 +210,10 @@ export const dispatchTelegramMessage = async ({ draftStream?.stop(); } + const replyQuoteText = + ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody + ? ctxPayload.ReplyToBody.trim() || undefined + : undefined; await deliverReplies({ replies: [payload], chatId: String(chatId), @@ -223,6 +227,7 @@ export const dispatchTelegramMessage = async ({ chunkMode, onVoiceRecording: sendRecordVoice, linkPreview: telegramCfg.linkPreview, + replyQuoteText, }); }, onError: (err, info) => { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 72ee418bb..75dd32faf 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -894,6 +894,73 @@ describe("createTelegramBot", () => { expect(payload.ReplyToSender).toBe("Ada"); }); + it("uses quote text when a Telegram partial reply is received", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + text: "Sure, see below", + date: 1736380800, + reply_to_message: { + message_id: 9001, + text: "Can you summarize this?", + from: { first_name: "Ada" }, + }, + quote: { + text: "summarize this", + }, + }, + me: { username: "moltbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("[Quoting Ada id:9001]"); + expect(payload.Body).toContain('"summarize this"'); + expect(payload.ReplyToId).toBe("9001"); + expect(payload.ReplyToBody).toBe("summarize this"); + expect(payload.ReplyToSender).toBe("Ada"); + }); + + it("handles quote-only replies without reply metadata", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType; + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + text: "Sure, see below", + date: 1736380800, + quote: { + text: "summarize this", + }, + }, + me: { username: "moltbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain("[Quoting unknown sender]"); + expect(payload.Body).toContain('"summarize this"'); + expect(payload.ReplyToId).toBeUndefined(); + expect(payload.ReplyToBody).toBe("summarize this"); + expect(payload.ReplyToSender).toBe("unknown sender"); + }); + it("sends replies without native reply threading", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 404cc2fc2..3cf1b2534 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -168,6 +168,37 @@ describe("deliverReplies", () => { ); }); + it("uses reply_parameters when quote text is provided", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 10, + chat: { id: "123" }, + }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverReplies({ + replies: [{ text: "Hello there", replyToId: "500" }], + chatId: "123", + token: "tok", + runtime, + bot, + replyToMode: "all", + textLimit: 4000, + replyQuoteText: "quoted text", + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.objectContaining({ + reply_parameters: { + message_id: 500, + quote: "quoted text", + }, + }), + ); + }); + it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendVoice = vi diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 779c0c026..e8cea63d8 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -42,9 +42,20 @@ export async function deliverReplies(params: { onVoiceRecording?: () => Promise | void; /** Controls whether link previews are shown. Default: true (previews enabled). */ linkPreview?: boolean; + /** Optional quote text for Telegram reply_parameters. */ + replyQuoteText?: string; }) { - const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } = - params; + const { + replies, + chatId, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId, + linkPreview, + replyQuoteText, + } = params; const chunkMode = params.chunkMode ?? "length"; const threadParams = buildTelegramThreadParams(messageThreadId); let hasReplied = false; @@ -97,6 +108,7 @@ export async function deliverReplies(params: { await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, + replyQuoteText, messageThreadId, textMode: "html", plainText: chunk.text, @@ -140,13 +152,14 @@ export async function deliverReplies(params: { const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText; const mediaParams: Record = { caption: htmlCaption, - reply_to_message_id: replyToMessageId, ...(htmlCaption ? { parse_mode: "HTML" } : {}), ...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}), + ...buildTelegramSendParams({ + replyToMessageId, + messageThreadId, + replyQuoteText, + }), }; - if (threadParams) { - mediaParams.message_thread_id = threadParams.message_thread_id; - } if (isGif) { await withTelegramApiErrorLogging({ operation: "sendAnimation", @@ -207,6 +220,7 @@ export async function deliverReplies(params: { messageThreadId, linkPreview, replyMarkup, + replyQuoteText, }); // Skip this media item; continue with next. continue; @@ -391,6 +405,7 @@ async function sendTelegramVoiceFallbackText(opts: { messageThreadId?: number; linkPreview?: boolean; replyMarkup?: ReturnType; + replyQuoteText?: string; }): Promise { const chunks = opts.chunkText(opts.text); let hasReplied = opts.hasReplied; @@ -399,6 +414,7 @@ async function sendTelegramVoiceFallbackText(opts: { await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { replyToMessageId: opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined, + replyQuoteText: opts.replyQuoteText, messageThreadId: opts.messageThreadId, textMode: "html", plainText: chunk.text, @@ -415,11 +431,20 @@ async function sendTelegramVoiceFallbackText(opts: { function buildTelegramSendParams(opts?: { replyToMessageId?: number; messageThreadId?: number; + replyQuoteText?: string; }): Record { const threadParams = buildTelegramThreadParams(opts?.messageThreadId); const params: Record = {}; + const quoteText = opts?.replyQuoteText?.trim(); if (opts?.replyToMessageId) { - params.reply_to_message_id = opts.replyToMessageId; + if (quoteText) { + params.reply_parameters = { + message_id: Math.trunc(opts.replyToMessageId), + quote: quoteText, + }; + } else { + params.reply_to_message_id = opts.replyToMessageId; + } } if (threadParams) { params.message_thread_id = threadParams.message_thread_id; @@ -434,6 +459,7 @@ async function sendTelegramText( runtime: RuntimeEnv, opts?: { replyToMessageId?: number; + replyQuoteText?: string; messageThreadId?: number; textMode?: "markdown" | "html"; plainText?: string; @@ -443,6 +469,7 @@ async function sendTelegramText( ): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, + replyQuoteText: opts?.replyQuoteText, messageThreadId: opts?.messageThreadId, }); // Add link_preview_options when link preview is disabled. diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index da89a3639..19b8e76c0 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -150,24 +150,29 @@ export function resolveTelegramReplyId(raw?: string): number | undefined { return parsed; } -export function describeReplyTarget(msg: TelegramMessage) { - const reply = msg.reply_to_message; - if (!reply) return null; - - // Check for quote (partial message reply) - const quote = (msg as any).quote; - let body = ""; - let isQuote = false; +export type TelegramReplyTarget = { + id?: string; + sender: string; + body: string; + kind: "reply" | "quote"; +}; - if (quote && quote.text) { - // Use quoted text +export function describeReplyTarget(msg: TelegramMessage): TelegramReplyTarget | null { + const reply = msg.reply_to_message; + const quote = msg.quote; + let body = ""; + let kind: TelegramReplyTarget["kind"] = "reply"; + + if (quote?.text) { body = quote.text.trim(); - isQuote = true; - } else { - // Regular reply - use full message + if (body) { + kind = "quote"; + } + } + + if (!body && reply) { const replyBody = (reply.text ?? reply.caption ?? "").trim(); body = replyBody; - if (!body) { if (reply.photo) body = ""; else if (reply.video) body = ""; @@ -179,16 +184,15 @@ export function describeReplyTarget(msg: TelegramMessage) { } } } - if (!body) return null; - const sender = buildSenderName(reply); + const sender = reply ? buildSenderName(reply) : undefined; const senderLabel = sender ? `${sender}` : "unknown sender"; - + return { - id: reply.message_id ? String(reply.message_id) : undefined, + id: reply?.message_id ? String(reply.message_id) : undefined, sender: senderLabel, body, - isQuote // Add flag to distinguish quote from regular reply + kind, }; } diff --git a/src/telegram/bot/types.ts b/src/telegram/bot/types.ts index 3e106b885..df3dba6d3 100644 --- a/src/telegram/bot/types.ts +++ b/src/telegram/bot/types.ts @@ -1,6 +1,12 @@ import type { Message } from "@grammyjs/types"; -export type TelegramMessage = Message; +export type TelegramQuote = { + text?: string; +}; + +export type TelegramMessage = Message & { + quote?: TelegramQuote; +}; export type TelegramStreamMode = "off" | "partial" | "block"; diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 7dd79dd1f..e3f3ac30e 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -46,6 +46,8 @@ type TelegramSendOpts = { silent?: boolean; /** Message ID to reply to (for threading) */ replyToMessageId?: number; + /** Quote text for Telegram reply_parameters. */ + quoteText?: string; /** Forum topic thread ID (for forum supergroups) */ messageThreadId?: number; /** Inline keyboard buttons (reply markup). */ @@ -198,9 +200,17 @@ export async function sendMessageTelegram( const messageThreadId = opts.messageThreadId != null ? opts.messageThreadId : target.messageThreadId; const threadIdParams = buildTelegramThreadParams(messageThreadId); - const threadParams: Record = threadIdParams ? { ...threadIdParams } : {}; + const threadParams: Record = threadIdParams ? { ...threadIdParams } : {}; + const quoteText = opts.quoteText?.trim(); if (opts.replyToMessageId != null) { - threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); + if (quoteText) { + threadParams.reply_parameters = { + message_id: Math.trunc(opts.replyToMessageId), + quote: quoteText, + }; + } else { + threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); + } } const hasThreadParams = Object.keys(threadParams).length > 0; const request = createTelegramRetryRunner({