From f9146cabfca7387b9c2af49bbffd70d06bb65f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20Cuevas?= Date: Sun, 26 Apr 2026 01:09:43 -0400 Subject: [PATCH] fix(telegram): preserve native quote replies Preserve exact Telegram selected quote text for native quote replies, share Telegram reply parameter construction between bot delivery and direct outbound sends, and retry with legacy replies when Telegram rejects native quote parameters.\n\nThanks @rubencu. --- CHANGELOG.md | 1 + .../src/bot-message-context.session.ts | 4 + .../telegram/src/bot-message-dispatch.test.ts | 127 ++++++++++++++- .../telegram/src/bot-message-dispatch.ts | 55 ++++++- extensions/telegram/src/bot.test.ts | 17 +- .../telegram/src/bot/delivery.replies.ts | 37 ++++- extensions/telegram/src/bot/delivery.send.ts | 60 +++++--- extensions/telegram/src/bot/delivery.test.ts | 145 +++++++++++++++++- extensions/telegram/src/bot/helpers.test.ts | 29 ++++ extensions/telegram/src/bot/helpers.ts | 22 ++- .../telegram/src/reply-parameters.test.ts | 105 +++++++++++++ extensions/telegram/src/reply-parameters.ts | 128 ++++++++++++++++ extensions/telegram/src/send.test.ts | 28 ++++ extensions/telegram/src/send.ts | 78 +++------- 14 files changed, 734 insertions(+), 102 deletions(-) create mode 100644 extensions/telegram/src/reply-parameters.test.ts create mode 100644 extensions/telegram/src/reply-parameters.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f14438c75a8..fc3f33da78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram: preserve exact selected quote text when sending native quote replies, and retry with legacy replies if Telegram rejects quote parameters. (#71952) Thanks @rubencu. - Plugins/CLI: preserve manifest name, description, format, and source metadata in cold `openclaw plugins list` output without importing plugin runtime. Thanks @shakkernerd. - Security/audit: read channel exposure and plugin allowlist ownership from read-only plugin index metadata so cold audits do not depend on loaded channel runtime. Thanks @shakkernerd. - Logging: redact configured secret patterns at console and file-log sink exits diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index ca4905946ed..1f917884f09 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -346,6 +346,10 @@ export async function buildTelegramInboundContextPayload(params: { ReplyToBody: visibleReplyTarget?.body, ReplyToSender: visibleReplyTarget?.sender, ReplyToIsQuote: visibleReplyTarget?.kind === "quote" ? true : undefined, + ReplyToIsExternal: visibleReplyTarget?.source === "external_reply" ? true : undefined, + ReplyToQuoteText: visibleReplyTarget?.quoteText, + ReplyToQuotePosition: visibleReplyTarget?.quotePosition, + ReplyToQuoteEntities: visibleReplyTarget?.quoteEntities, ReplyToForwardedFrom: visibleReplyTarget?.forwardedFrom?.from, ReplyToForwardedFromType: visibleReplyTarget?.forwardedFrom?.fromType, ReplyToForwardedFromId: visibleReplyTarget?.forwardedFrom?.fromId, diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 9b060a74c43..db2532901f8 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -365,6 +365,7 @@ describe("dispatchTelegramMessage draft streaming", () => { streamMode?: Parameters[0]["streamMode"]; telegramDeps?: TelegramBotDeps; bot?: Bot; + replyToMode?: Parameters[0]["replyToMode"]; }) { const bot = params.bot ?? createBot(); await dispatchTelegramMessage({ @@ -372,7 +373,7 @@ describe("dispatchTelegramMessage draft streaming", () => { bot, cfg: params.cfg ?? {}, runtime: createRuntime(), - replyToMode: "first", + replyToMode: params.replyToMode ?? "first", streamMode: params.streamMode ?? "partial", textLimit: 4096, telegramCfg: params.telegramCfg ?? {}, @@ -439,6 +440,130 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(draftStream.clear).toHaveBeenCalledTimes(1); }); + it("skips answer draft preview for same-chat selected quotes", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext({ + msg: { + message_id: 1001, + } as unknown as TelegramMessageContext["msg"], + ctxPayload: { + MessageSid: "1001", + ReplyToId: "9001", + ReplyToBody: "quoted slice", + ReplyToQuoteText: " quoted slice\n", + ReplyToIsQuote: true, + } as unknown as TelegramMessageContext["ctxPayload"], + }), + }); + + expect(createTelegramDraftStream).not.toHaveBeenCalled(); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ replyToId: "9001" })], + replyQuoteMessageId: 9001, + replyQuoteText: " quoted slice\n", + }), + ); + }); + + it("keeps answer draft preview for selected quotes when reply mode is off", async () => { + const draftStream = createDraftStream(); + createTelegramDraftStream.mockReturnValue(draftStream); + dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({ queuedFinal: true }); + + await dispatchWithContext({ + context: createContext({ + msg: { + message_id: 1001, + } as unknown as TelegramMessageContext["msg"], + ctxPayload: { + MessageSid: "1001", + ReplyToId: "9001", + ReplyToBody: "quoted slice", + ReplyToQuoteText: " quoted slice\n", + ReplyToIsQuote: true, + } as unknown as TelegramMessageContext["ctxPayload"], + }), + replyToMode: "off", + }); + + expect(createTelegramDraftStream).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: undefined, + }), + ); + }); + + it("passes same-chat quoted reply target id with Telegram quote text", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + MessageSid: "1001", + ReplyToId: "9001", + ReplyToBody: "quoted slice", + ReplyToQuoteText: " quoted slice\n", + ReplyToIsQuote: true, + ReplyToQuotePosition: 12, + ReplyToQuoteEntities: [{ type: "italic", offset: 0, length: 6 }], + } as unknown as TelegramMessageContext["ctxPayload"], + }), + streamMode: "off", + }); + + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [expect.objectContaining({ replyToId: "9001" })], + replyQuoteMessageId: 9001, + replyQuoteText: " quoted slice\n", + replyQuotePosition: 12, + replyQuoteEntities: [{ type: "italic", offset: 0, length: 6 }], + }), + ); + }); + + it("does not pass a native quote target for external replies", async () => { + dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { + await dispatcherOptions.deliver({ text: "Hello", replyToId: "1001" }, { kind: "final" }); + return { queuedFinal: true }; + }); + deliverReplies.mockResolvedValue({ delivered: true }); + + await dispatchWithContext({ + context: createContext({ + ctxPayload: { + MessageSid: "1001", + ReplyToId: "9001", + ReplyToBody: "external quoted slice", + ReplyToQuoteText: " external quoted slice\n", + ReplyToIsQuote: true, + ReplyToIsExternal: true, + } as unknown as TelegramMessageContext["ctxPayload"], + }), + streamMode: "off", + }); + + const params = deliverReplies.mock.calls[0]?.[0]; + expect(params).toEqual( + expect.objectContaining({ + replies: [expect.objectContaining({ replyToId: "1001" })], + replyQuoteText: " external quoted slice\n", + }), + ); + expect(params?.replyQuoteMessageId).toBeUndefined(); + }); + it("does not inject approval buttons in local dispatch once the monitor owns approvals", async () => { dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => { await dispatcherOptions.deliver( diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index ceb9ce507b1..2f9888f9968 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -52,6 +52,7 @@ import { } from "./bot-message-dispatch.runtime.js"; import type { TelegramBotOptions } from "./bot.types.js"; import { deliverReplies, emitInternalMessageSentHook } from "./bot/delivery.js"; +import { resolveTelegramReplyId } from "./bot/helpers.js"; import type { TelegramStreamMode } from "./bot/types.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { createTelegramDraftStream } from "./draft-stream.js"; @@ -340,11 +341,31 @@ export const dispatchTelegramMessage = async ({ const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on"; const streamReasoningDraft = resolvedReasoningLevel === "stream"; const previewStreamingEnabled = streamMode !== "off"; + const rawReplyQuoteText = + ctxPayload.ReplyToIsQuote && typeof ctxPayload.ReplyToQuoteText === "string" + ? ctxPayload.ReplyToQuoteText + : undefined; + const replyQuoteText = ctxPayload.ReplyToIsQuote + ? rawReplyQuoteText?.trim() + ? rawReplyQuoteText + : ctxPayload.ReplyToBody?.trim() || undefined + : undefined; + const replyQuoteMessageId = + replyQuoteText && !ctxPayload.ReplyToIsExternal + ? resolveTelegramReplyId(ctxPayload.ReplyToId) + : undefined; + const hasNativeQuoteReply = + replyToMode !== "off" && replyQuoteText != null && replyQuoteMessageId != null; const canStreamAnswerDraft = - previewStreamingEnabled && !accountBlockStreamingEnabled && !forceBlockStreamingForReasoning; + previewStreamingEnabled && + !hasNativeQuoteReply && + !accountBlockStreamingEnabled && + !forceBlockStreamingForReasoning; const canStreamReasoningDraft = streamReasoningDraft; const draftReplyToMessageId = - replyToMode !== "off" && typeof msg.message_id === "number" ? msg.message_id : undefined; + replyToMode !== "off" && typeof msg.message_id === "number" + ? (replyQuoteMessageId ?? msg.message_id) + : undefined; const draftMinInitialChars = DRAFT_MIN_INITIAL_CHARS; // DM draft previews still duplicate briefly at materialize time. const useMessagePreviewTransportForDm = threadSpec?.scope === "dm" && canStreamAnswerDraft; @@ -558,10 +579,17 @@ export const dispatchTelegramMessage = async ({ supersede: shouldSupersedeAbortFence, }); - const replyQuoteText = - ctxPayload.ReplyToIsQuote && ctxPayload.ReplyToBody - ? ctxPayload.ReplyToBody.trim() || undefined + const implicitQuoteReplyTargetId = + replyQuoteMessageId != null ? String(replyQuoteMessageId) : undefined; + const currentMessageIdForQuoteReply = + implicitQuoteReplyTargetId && ctxPayload.MessageSid ? ctxPayload.MessageSid : undefined; + const replyQuotePosition = + typeof ctxPayload.ReplyToQuotePosition === "number" + ? ctxPayload.ReplyToQuotePosition : undefined; + const replyQuoteEntities = Array.isArray(ctxPayload.ReplyToQuoteEntities) + ? ctxPayload.ReplyToQuoteEntities + : undefined; const deliveryState = createLaneDeliveryStateTracker(); const clearGroupHistory = () => { if (isGroup && historyKey) { @@ -588,7 +616,10 @@ export const dispatchTelegramMessage = async ({ tableMode, chunkMode, linkPreview: telegramCfg.linkPreview, + replyQuoteMessageId, replyQuoteText, + replyQuotePosition, + replyQuoteEntities, }; const silentErrorReplies = telegramCfg.silentErrorReplies === true; const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null; @@ -644,13 +675,25 @@ export const dispatchTelegramMessage = async ({ } return { ...payload, text }; }; + const applyQuoteReplyTarget = (payload: ReplyPayload): ReplyPayload => { + if ( + !implicitQuoteReplyTargetId || + !currentMessageIdForQuoteReply || + payload.replyToId !== currentMessageIdForQuoteReply || + payload.replyToTag || + payload.replyToCurrent + ) { + return payload; + } + return { ...payload, replyToId: implicitQuoteReplyTargetId }; + }; const sendPayload = async (payload: ReplyPayload) => { if (isDispatchSuperseded()) { return false; } const result = await (telegramDeps.deliverReplies ?? deliverReplies)({ ...deliveryBaseOptions, - replies: [payload], + replies: [applyQuoteReplyTarget(payload)], onVoiceRecording: sendRecordVoice, silent: silentErrorReplies && payload.isError === true, mediaLoader: telegramDeps.loadWebMedia, diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 565474bc529..67355ef5116 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1502,7 +1502,9 @@ describe("createTelegramBot", () => { from: { first_name: "Ada" }, }, quote: { - text: "summarize this", + text: " summarize this\n", + position: 8, + entities: [{ type: "bold", offset: 1, length: 9 }], }, }, me: { username: "openclaw_bot" }, @@ -1516,6 +1518,10 @@ describe("createTelegramBot", () => { expect(payload.ReplyToId).toBe("9001"); expect(payload.ReplyToBody).toBe("summarize this"); expect(payload.ReplyToSender).toBe("Ada"); + const telegramPayload = payload as Record; + expect(telegramPayload.ReplyToQuoteText).toBe(" summarize this\n"); + expect(telegramPayload.ReplyToQuotePosition).toBe(8); + expect(telegramPayload.ReplyToQuoteEntities).toEqual([{ type: "bold", offset: 1, length: 9 }]); }); it("keeps reply linkage while omitting filtered binary reply captions", async () => { @@ -1782,7 +1788,7 @@ describe("createTelegramBot", () => { expect(payload.ReplyToSender).toBe("unknown sender"); }); - it("uses external_reply quote text for partial replies", async () => { + it("uses top-level quote text for external partial replies", async () => { onSpy.mockClear(); sendMessageSpy.mockClear(); replySpy.mockClear(); @@ -1795,13 +1801,13 @@ describe("createTelegramBot", () => { chat: { id: 7, type: "private" }, text: "Sure, see below", date: 1736380800, + quote: { + text: "summarize this", + }, external_reply: { message_id: 9002, text: "Can you summarize this?", from: { first_name: "Ada" }, - quote: { - text: "summarize this", - }, }, }, me: { username: "openclaw_bot" }, @@ -1815,6 +1821,7 @@ describe("createTelegramBot", () => { expect(payload.ReplyToId).toBe("9002"); expect(payload.ReplyToBody).toBe("summarize this"); expect(payload.ReplyToSender).toBe("Ada"); + expect((payload as Record).ReplyToIsExternal).toBe(true); }); it("propagates forwarded origin from external_reply targets", async () => { diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 0f5985d4e2c..59e6605d323 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -113,7 +113,10 @@ async function deliverTextReply(params: { chunkText: ChunkTextFn; replyText: string; replyMarkup?: ReturnType; + replyQuoteMessageId?: number; replyQuoteText?: string; + replyQuotePosition?: number; + replyQuoteEntities?: unknown[]; linkPreview?: boolean; silent?: boolean; replyToId?: number; @@ -138,7 +141,10 @@ async function deliverTextReply(params: { params.runtime, { replyToMessageId, + replyQuoteMessageId: params.replyQuoteMessageId, replyQuoteText, + replyQuotePosition: params.replyQuotePosition, + replyQuoteEntities: params.replyQuoteEntities, thread: params.thread, textMode: "html", plainText: chunk.text, @@ -212,6 +218,9 @@ async function sendTelegramVoiceFallbackText(opts: { text: string; chunkText: (markdown: string) => ReturnType; replyToId?: number; + replyQuoteMessageId?: number; + replyQuotePosition?: number; + replyQuoteEntities?: unknown[]; thread?: TelegramThreadSpec | null; linkPreview?: boolean; silent?: boolean; @@ -225,9 +234,13 @@ async function sendTelegramVoiceFallbackText(opts: { const chunk = chunks[i]; // Only apply reply reference, quote text, and buttons to the first chunk. const replyToForChunk = !appliedReplyTo ? opts.replyToId : undefined; + const applyQuoteForChunk = !appliedReplyTo; const messageId = await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { replyToMessageId: replyToForChunk, - replyQuoteText: !appliedReplyTo ? opts.replyQuoteText : undefined, + replyQuoteMessageId: applyQuoteForChunk ? opts.replyQuoteMessageId : undefined, + replyQuoteText: applyQuoteForChunk ? opts.replyQuoteText : undefined, + replyQuotePosition: applyQuoteForChunk ? opts.replyQuotePosition : undefined, + replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined, thread: opts.thread, textMode: "html", plainText: chunk.text, @@ -259,7 +272,10 @@ async function deliverMediaReply(params: { onVoiceRecording?: () => Promise | void; linkPreview?: boolean; silent?: boolean; + replyQuoteMessageId?: number; replyQuoteText?: string; + replyQuotePosition?: number; + replyQuoteEntities?: unknown[]; replyMarkup?: ReturnType; replyToId?: number; replyToMode: ReplyToMode; @@ -303,6 +319,10 @@ async function deliverMediaReply(params: { ...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}), ...buildTelegramSendParams({ replyToMessageId, + replyQuoteMessageId: params.replyQuoteMessageId, + replyQuoteText: params.replyQuoteText, + replyQuotePosition: params.replyQuotePosition, + replyQuoteEntities: params.replyQuoteEntities, thread: params.thread, silent: params.silent, }), @@ -396,6 +416,9 @@ async function deliverMediaReply(params: { text: fallbackText, chunkText: params.chunkText, replyToId: voiceFallbackReplyTo, + replyQuoteMessageId: params.replyQuoteMessageId, + replyQuotePosition: params.replyQuotePosition, + replyQuoteEntities: params.replyQuoteEntities, thread: params.thread, linkPreview: params.linkPreview, silent: params.silent, @@ -612,8 +635,14 @@ export async function deliverReplies(params: { linkPreview?: boolean; /** When true, messages are sent with disable_notification. */ silent?: boolean; + /** Message id that the optional quote text belongs to. */ + replyQuoteMessageId?: number; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; + /** UTF-16 position of the selected quote in the original Telegram message. */ + replyQuotePosition?: number; + /** Telegram entities that belong to the selected quote text. */ + replyQuoteEntities?: unknown[]; /** Override media loader (tests). */ mediaLoader?: typeof loadWebMedia; }): Promise<{ delivered: boolean }> { @@ -721,7 +750,10 @@ export async function deliverReplies(params: { chunkText, replyText: reply.text || "", replyMarkup, + replyQuoteMessageId: params.replyQuoteMessageId, replyQuoteText: params.replyQuoteText, + replyQuotePosition: params.replyQuotePosition, + replyQuoteEntities: params.replyQuoteEntities, linkPreview: params.linkPreview, silent: params.silent, replyToId, @@ -743,7 +775,10 @@ export async function deliverReplies(params: { onVoiceRecording: params.onVoiceRecording, linkPreview: params.linkPreview, silent: params.silent, + replyQuoteMessageId: params.replyQuoteMessageId, replyQuoteText: params.replyQuoteText, + replyQuotePosition: params.replyQuotePosition, + replyQuoteEntities: params.replyQuoteEntities, replyMarkup, replyToId, replyToMode: params.replyToMode, diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index 1e9aae4ff4e..218ee108c43 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -3,13 +3,20 @@ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { withTelegramApiErrorLogging } from "../api-logging.js"; import { markdownToTelegramHtml } from "../format.js"; -import { normalizeTelegramReplyToMessageId } from "../outbound-params.js"; +import { + buildTelegramSendParams, + getTelegramNativeQuoteReplyMessageId, + removeTelegramNativeQuoteParam, +} from "../reply-parameters.js"; import { buildInlineKeyboard } from "../send.js"; -import { buildTelegramThreadParams, type TelegramThreadSpec } from "./helpers.js"; +import type { TelegramThreadSpec } from "./helpers.js"; + +export { buildTelegramSendParams } from "../reply-parameters.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const EMPTY_TEXT_ERR_RE = /message text is empty/i; const THREAD_NOT_FOUND_RE = /message thread not found/i; +const QUOTE_PARAM_RE = /\bquote not found\b/i; const GrammyErrorCtor: typeof GrammyError | undefined = typeof GrammyError === "function" ? GrammyError : undefined; @@ -20,6 +27,13 @@ function isTelegramThreadNotFoundError(err: unknown): boolean { return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err)); } +function isTelegramQuoteParamError(err: unknown): boolean { + if (GrammyErrorCtor && err instanceof GrammyErrorCtor) { + return QUOTE_PARAM_RE.test(err.description); + } + return QUOTE_PARAM_RE.test(formatErrorMessage(err)); +} + function hasMessageThreadIdParam(params: Record | undefined): boolean { if (!params) { return false; @@ -47,8 +61,10 @@ export async function sendTelegramWithThreadFallback(params: { }): Promise { const allowThreadlessRetry = params.thread?.scope === "dm"; const hasThreadId = hasMessageThreadIdParam(params.requestParams); + const hasNativeQuote = getTelegramNativeQuoteReplyMessageId(params.requestParams) != null; const shouldSuppressFirstErrorLog = (err: unknown) => - allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err); + (allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err)) || + (hasNativeQuote && isTelegramQuoteParamError(err)); const mergedShouldLog = params.shouldLog ? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err) : (err: unknown) => !shouldSuppressFirstErrorLog(err); @@ -61,6 +77,16 @@ export async function sendTelegramWithThreadFallback(params: { fn: () => params.send(params.requestParams), }); } catch (err) { + if (hasNativeQuote && isTelegramQuoteParamError(err)) { + params.runtime.log?.( + `telegram ${params.operation}: native quote rejected; retrying with legacy reply_to_message_id`, + ); + return await sendTelegramWithThreadFallback({ + ...params, + operation: `${params.operation} (legacy reply retry)`, + requestParams: removeTelegramNativeQuoteParam(params.requestParams), + }); + } if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) { throw err; } @@ -76,27 +102,6 @@ export async function sendTelegramWithThreadFallback(params: { } } -export function buildTelegramSendParams(opts?: { - replyToMessageId?: number; - thread?: TelegramThreadSpec | null; - silent?: boolean; -}): Record { - const threadParams = buildTelegramThreadParams(opts?.thread); - const params: Record = {}; - const replyToMessageId = normalizeTelegramReplyToMessageId(opts?.replyToMessageId); - if (replyToMessageId != null) { - params.reply_to_message_id = replyToMessageId; - params.allow_sending_without_reply = true; - } - if (threadParams) { - params.message_thread_id = threadParams.message_thread_id; - } - if (opts?.silent === true) { - params.disable_notification = true; - } - return params; -} - export async function sendTelegramText( bot: Bot, chatId: string, @@ -104,7 +109,10 @@ export async function sendTelegramText( runtime: RuntimeEnv, opts?: { replyToMessageId?: number; + replyQuoteMessageId?: number; replyQuoteText?: string; + replyQuotePosition?: number; + replyQuoteEntities?: unknown[]; thread?: TelegramThreadSpec | null; textMode?: "markdown" | "html"; plainText?: string; @@ -115,6 +123,10 @@ export async function sendTelegramText( ): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, + replyQuoteMessageId: opts?.replyQuoteMessageId, + replyQuoteText: opts?.replyQuoteText, + replyQuotePosition: opts?.replyQuotePosition, + replyQuoteEntities: opts?.replyQuoteEntities, thread: opts?.thread, silent: opts?.silent, }); diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 92a2dbb479d..1a615abf733 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -114,6 +114,12 @@ function createThreadNotFoundError(operation = "sendMessage") { ); } +function createQuoteNotFoundError(operation = "sendMessage") { + return new Error( + `GrammyError: Call to '${operation}' failed! (400: Bad Request: quote not found)`, + ); +} + function createVoiceFailureHarness(params: { voiceError: Error; sendMessageResult?: { message_id: number; chat: { id: string } }; @@ -698,7 +704,7 @@ describe("deliverReplies", () => { expect(sendMessage).not.toHaveBeenCalled(); }); - it("uses reply_to_message_id when quote text is provided", async () => { + it("uses reply_parameters when quote text is provided", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ message_id: 10, @@ -711,6 +717,87 @@ describe("deliverReplies", () => { runtime, bot, replyToMode: "all", + replyQuoteMessageId: 500, + replyQuoteText: " quoted text\n", + replyQuotePosition: 17, + replyQuoteEntities: [{ type: "bold", offset: 0, length: 6 }], + }); + + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.objectContaining({ + reply_parameters: { + message_id: 500, + quote: " quoted text\n", + quote_position: 17, + quote_entities: [{ type: "bold", offset: 0, length: 6 }], + allow_sending_without_reply: true, + }, + }), + ); + expect(sendMessage).toHaveBeenCalledWith( + "123", + expect.any(String), + expect.not.objectContaining({ + reply_to_message_id: expect.anything(), + }), + ); + }); + + it("retries with legacy reply id when native quote parameters are rejected", async () => { + const runtime = createRuntime(); + const sendMessage = vi + .fn() + .mockRejectedValueOnce(createQuoteNotFoundError()) + .mockResolvedValueOnce({ + message_id: 11, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "Hello there", replyToId: "500" }], + runtime, + bot, + replyToMode: "all", + replyQuoteMessageId: 500, + replyQuoteText: " quoted text\n", + }); + + expect(sendMessage).toHaveBeenCalledTimes(2); + expect(sendMessage.mock.calls[0][2]).toEqual( + expect.objectContaining({ + reply_parameters: { + message_id: 500, + quote: " quoted text\n", + allow_sending_without_reply: true, + }, + }), + ); + expect(sendMessage.mock.calls[1][2]).toEqual( + expect.objectContaining({ + reply_to_message_id: 500, + allow_sending_without_reply: true, + }), + ); + expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_parameters"); + }); + + it("uses legacy reply id when selected reply target differs from quote source", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 11, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "Hello there", replyToId: "501" }], + runtime, + bot, + replyToMode: "all", + replyQuoteMessageId: 500, replyQuoteText: "quoted text", }); @@ -718,17 +805,59 @@ describe("deliverReplies", () => { "123", expect.any(String), expect.objectContaining({ - reply_to_message_id: 500, + reply_to_message_id: 501, allow_sending_without_reply: true, }), ); + expect(sendMessage.mock.calls[0][2]).not.toHaveProperty("reply_parameters"); + }); + + it("omits native quote parameters when reply mode suppresses the reply", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 13, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "Hello there", replyToId: "500" }], + runtime, + bot, + replyToMode: "off", + replyQuoteMessageId: 500, + replyQuoteText: "quoted text", + }); + + expect(sendMessage.mock.calls[0][2]).not.toHaveProperty("reply_parameters"); + expect(sendMessage.mock.calls[0][2]).not.toHaveProperty("reply_to_message_id"); + }); + + it("uses legacy reply id when quote text has no quoted message id", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 12, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + + await deliverWith({ + replies: [{ text: "Hello there", replyToId: "501" }], + runtime, + bot, + replyToMode: "all", + replyQuoteText: "quoted text", + }); + expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), - expect.not.objectContaining({ - reply_parameters: expect.anything(), + expect.objectContaining({ + reply_to_message_id: 501, + allow_sending_without_reply: true, }), ); + expect(sendMessage.mock.calls[0][2]).not.toHaveProperty("reply_parameters"); }); it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => { @@ -819,6 +948,7 @@ describe("deliverReplies", () => { runtime, bot, replyToMode: "first", + replyQuoteMessageId: 77, replyQuoteText: "quoted context", textLimit: 12, }); @@ -827,8 +957,11 @@ describe("deliverReplies", () => { expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); expect(sendMessage.mock.calls[0][2]).toEqual( expect.objectContaining({ - reply_to_message_id: 77, - allow_sending_without_reply: true, + reply_parameters: { + message_id: 77, + quote: "quoted context", + allow_sending_without_reply: true, + }, reply_markup: { inline_keyboard: [[{ text: "Ack", callback_data: "ack" }]], }, diff --git a/extensions/telegram/src/bot/helpers.test.ts b/extensions/telegram/src/bot/helpers.test.ts index d950f0977fc..7c4de43f81b 100644 --- a/extensions/telegram/src/bot/helpers.test.ts +++ b/extensions/telegram/src/bot/helpers.test.ts @@ -341,6 +341,7 @@ describe("describeReplyTarget", () => { expect(result?.sender).toBe("Alice"); expect(result?.id).toBe("1"); expect(result?.kind).toBe("reply"); + expect(result?.source).toBe("reply_to_message"); }); it("handles non-string reply text gracefully (issue #27201)", () => { @@ -502,6 +503,34 @@ describe("describeReplyTarget", () => { expect(result?.forwardedFrom?.fromMessageId).toBe(456); }); + it("marks top-level quote metadata on external replies as external targets", () => { + const result = describeReplyTarget({ + message_id: 5, + date: 1300, + chat: { id: 1, type: "private" }, + text: "Comment on forwarded message", + quote: { + text: "quoted slice", + position: 4, + entities: [{ type: "italic", offset: 0, length: 6 }], + }, + external_reply: { + message_id: 4, + date: 1200, + chat: { id: 1, type: "private" }, + text: "Forwarded from elsewhere", + from: { id: 123, first_name: "Eve", is_bot: false }, + }, + } as any); + + expect(result?.id).toBe("4"); + expect(result?.kind).toBe("quote"); + expect(result?.source).toBe("external_reply"); + expect(result?.quoteText).toBe("quoted slice"); + expect(result?.quotePosition).toBe(4); + expect(result?.quoteEntities).toEqual([{ type: "italic", offset: 0, length: 6 }]); + }); + it("extracts forwarded context from external_reply", () => { const result = describeReplyTarget({ message_id: 5, diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts index 0a26488c7ea..c8ee9c661fb 100644 --- a/extensions/telegram/src/bot/helpers.ts +++ b/extensions/telegram/src/bot/helpers.ts @@ -23,6 +23,7 @@ import { resolveTelegramTextContent, resolveTelegramMediaPlaceholder, type TelegramForwardedContext, + type TelegramTextEntity, } from "./body-helpers.js"; import type { TelegramGetChat, TelegramStreamMode } from "./types.js"; @@ -375,6 +376,10 @@ export type TelegramReplyTarget = { senderUsername?: string; body?: string; kind: "reply" | "quote"; + source: "reply_to_message" | "external_reply"; + quoteText?: string; + quotePosition?: number; + quoteEntities?: TelegramTextEntity[]; /** Forward context if the reply target was itself a forwarded message (issue #9619). */ forwardedFrom?: TelegramForwardedContext; }; @@ -382,9 +387,9 @@ export type TelegramReplyTarget = { export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { const reply = msg.reply_to_message; const externalReply = (msg as Message & { external_reply?: Message }).external_reply; - const rawQuoteText = - msg.quote?.text ?? - (externalReply as (Message & { quote?: { text?: string } }) | undefined)?.quote?.text; + const quote = + msg.quote ?? (externalReply as (Message & { quote?: Message["quote"] }) | undefined)?.quote; + const rawQuoteText = quote?.text; const quoteText = resolveTelegramTextContent(rawQuoteText); let body = ""; let kind: TelegramReplyTarget["kind"] = "reply"; @@ -425,6 +430,13 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { } const sender = replyLike ? buildSenderName(replyLike) : undefined; const senderLabel = sender ?? "unknown sender"; + const source = reply ? "reply_to_message" : "external_reply"; + const quotePosition = + kind === "quote" && typeof quote?.position === "number" && Number.isFinite(quote.position) + ? Math.trunc(quote.position) + : undefined; + const quoteEntities = + kind === "quote" && Array.isArray(quote?.entities) ? quote.entities : undefined; // Extract forward context from the resolved reply target (reply_to_message or external_reply). const forwardedFrom = replyLike ? (normalizeForwardedContext(replyLike) ?? undefined) : undefined; @@ -436,6 +448,10 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { senderUsername: replyLike?.from?.username ?? undefined, body: body || undefined, kind, + source, + quoteText: kind === "quote" ? quoteText : undefined, + quotePosition, + quoteEntities, forwardedFrom, }; } diff --git a/extensions/telegram/src/reply-parameters.test.ts b/extensions/telegram/src/reply-parameters.test.ts new file mode 100644 index 00000000000..eb337feb606 --- /dev/null +++ b/extensions/telegram/src/reply-parameters.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + buildTelegramSendParams, + buildTelegramThreadReplyParams, + removeTelegramNativeQuoteParam, + resolveTelegramSendThreadSpec, +} from "./reply-parameters.js"; + +describe("telegram reply parameters", () => { + it("preserves exact quote text and quote metadata for native Telegram replies", () => { + expect( + buildTelegramSendParams({ + replyToMessageId: 42, + replyQuoteMessageId: 42, + replyQuoteText: " quoted text\n", + replyQuotePosition: 12.9, + replyQuoteEntities: [{ type: "bold", offset: 1, length: 6 }], + thread: { id: 99, scope: "forum" }, + silent: true, + }), + ).toEqual({ + message_thread_id: 99, + reply_parameters: { + message_id: 42, + quote: " quoted text\n", + quote_position: 12, + quote_entities: [{ type: "bold", offset: 1, length: 6 }], + allow_sending_without_reply: true, + }, + disable_notification: true, + }); + }); + + it("uses the selected reply id as the quote id when direct sends only provide quote text", () => { + expect( + buildTelegramThreadReplyParams({ + replyToMessageId: 77, + replyQuoteText: " exact slice ", + useReplyIdAsQuoteSource: true, + }), + ).toEqual({ + reply_parameters: { + message_id: 77, + quote: " exact slice ", + allow_sending_without_reply: true, + }, + }); + }); + + it("falls back to legacy reply id for blank quotes or mismatched quote sources", () => { + expect( + buildTelegramThreadReplyParams({ + replyToMessageId: 77, + replyQuoteMessageId: 78, + replyQuoteText: "quoted", + }), + ).toEqual({ + reply_to_message_id: 77, + allow_sending_without_reply: true, + }); + + expect( + buildTelegramThreadReplyParams({ + replyToMessageId: 77, + replyQuoteText: " \n\t", + }), + ).toEqual({ + reply_to_message_id: 77, + allow_sending_without_reply: true, + }); + }); + + it("converts rejected native quote params to legacy reply params for retry", () => { + expect( + removeTelegramNativeQuoteParam({ + parse_mode: "HTML", + reply_parameters: { + message_id: 42, + quote: "quoted", + allow_sending_without_reply: true, + }, + }), + ).toEqual({ + parse_mode: "HTML", + reply_to_message_id: 42, + allow_sending_without_reply: true, + }); + }); + + it("keeps direct-message topic scope for Telegram DM topics", () => { + expect( + buildTelegramThreadReplyParams({ + thread: resolveTelegramSendThreadSpec({ + targetMessageThreadId: 5, + chatType: "direct", + }), + replyToMessageId: 42, + }), + ).toEqual({ + message_thread_id: 5, + reply_to_message_id: 42, + allow_sending_without_reply: true, + }); + }); +}); diff --git a/extensions/telegram/src/reply-parameters.ts b/extensions/telegram/src/reply-parameters.ts new file mode 100644 index 00000000000..a895e4a70ff --- /dev/null +++ b/extensions/telegram/src/reply-parameters.ts @@ -0,0 +1,128 @@ +import type { MessageEntity } from "@grammyjs/types"; +import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helpers.js"; +import { normalizeTelegramReplyToMessageId } from "./outbound-params.js"; + +export type TelegramReplyParameters = { + message_id: number; + allow_sending_without_reply: true; + quote?: string; + quote_position?: number; + quote_entities?: MessageEntity[]; +}; + +export type TelegramThreadReplyParams = { + message_thread_id?: number; + reply_parameters?: TelegramReplyParameters; + reply_to_message_id?: number; + allow_sending_without_reply?: true; +}; + +export function resolveTelegramSendThreadSpec(params: { + targetMessageThreadId?: number; + messageThreadId?: number; + chatType?: "direct" | "group" | "unknown"; +}): TelegramThreadSpec | undefined { + const messageThreadId = + params.messageThreadId != null ? params.messageThreadId : params.targetMessageThreadId; + if (messageThreadId == null) { + return undefined; + } + // Telegram supports DM topics; keep direct chat thread IDs and rely on + // thread-not-found retry fallback when a plain DM rejects them. + return { + id: messageThreadId, + scope: params.chatType === "direct" ? "dm" : "forum", + }; +} + +export function buildTelegramThreadReplyParams(opts?: { + thread?: TelegramThreadSpec | null; + replyToMessageId?: number; + replyQuoteMessageId?: number; + replyQuoteText?: string; + replyQuotePosition?: number; + replyQuoteEntities?: unknown[]; + useReplyIdAsQuoteSource?: boolean; +}): TelegramThreadReplyParams { + const params: TelegramThreadReplyParams = {}; + const threadParams = buildTelegramThreadParams(opts?.thread); + if (threadParams) { + params.message_thread_id = threadParams.message_thread_id; + } + + const replyToMessageId = normalizeTelegramReplyToMessageId(opts?.replyToMessageId); + if (replyToMessageId == null) { + return params; + } + + const defaultQuoteMessageId = + opts?.useReplyIdAsQuoteSource === true ? replyToMessageId : undefined; + const replyQuoteMessageId = normalizeTelegramReplyToMessageId( + opts?.replyQuoteMessageId ?? defaultQuoteMessageId, + ); + const replyQuoteTextRaw = + replyQuoteMessageId === replyToMessageId ? opts?.replyQuoteText : undefined; + const replyQuoteText = replyQuoteTextRaw?.trim() ? replyQuoteTextRaw : undefined; + if (!replyQuoteText) { + params.reply_to_message_id = replyToMessageId; + params.allow_sending_without_reply = true; + return params; + } + + const replyParameters: TelegramReplyParameters = { + message_id: replyToMessageId, + quote: replyQuoteText, + allow_sending_without_reply: true, + }; + if (typeof opts?.replyQuotePosition === "number" && Number.isFinite(opts.replyQuotePosition)) { + replyParameters.quote_position = Math.trunc(opts.replyQuotePosition); + } + if (Array.isArray(opts?.replyQuoteEntities) && opts.replyQuoteEntities.length > 0) { + replyParameters.quote_entities = opts.replyQuoteEntities as MessageEntity[]; + } + params.reply_parameters = replyParameters; + return params; +} + +export function buildTelegramSendParams(opts?: { + replyToMessageId?: number; + replyQuoteMessageId?: number; + replyQuoteText?: string; + replyQuotePosition?: number; + replyQuoteEntities?: unknown[]; + thread?: TelegramThreadSpec | null; + silent?: boolean; + useReplyIdAsQuoteSource?: boolean; +}): Record { + const params: Record = { ...buildTelegramThreadReplyParams(opts) }; + if (opts?.silent === true) { + params.disable_notification = true; + } + return params; +} + +export function getTelegramNativeQuoteReplyMessageId( + params: Record | undefined, +): number | undefined { + const replyParameters = params?.reply_parameters; + if (!replyParameters || typeof replyParameters !== "object") { + return undefined; + } + const messageId = (replyParameters as { message_id?: unknown }).message_id; + return typeof messageId === "number" && Number.isFinite(messageId) ? messageId : undefined; +} + +export function removeTelegramNativeQuoteParam( + params: Record | undefined, +): Record { + if (!params) { + return {}; + } + const replyMessageId = getTelegramNativeQuoteReplyMessageId(params); + const { reply_parameters: _ignored, ...rest } = params; + if (replyMessageId != null) { + rest.reply_to_message_id = replyMessageId; + rest.allow_sending_without_reply = true; + } + return rest; +} diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 30a0dc0da91..eff077108f2 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -2164,6 +2164,34 @@ describe("shared send behaviors", () => { } }); + it("uses native reply parameters for direct quote sends without trimming the quote", async () => { + const chatId = "123"; + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 56, + chat: { id: chatId }, + }); + const api = { sendMessage } as unknown as { + sendMessage: typeof sendMessage; + }; + + await sendMessageTelegram(chatId, "reply text", { + cfg: TELEGRAM_TEST_CFG, + token: "tok", + api, + replyToMessageId: 100, + quoteText: " quoted text\n", + }); + + expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", { + parse_mode: "HTML", + reply_parameters: { + message_id: 100, + quote: " quoted text\n", + allow_sending_without_reply: true, + }, + }); + }); + it("omits invalid reply_to_message_id values before calling Telegram", async () => { const invalidReplyToMessageIds = ["session-meta-id", "123abc", Number.NaN] as const; diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index e654fc70774..7d13eadd71b 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -10,7 +10,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeOptionalString, redactSensitiveText } from "openclaw/plugin-sdk/text-runtime"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; -import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; +import { buildTypingThreadParams } from "./bot/helpers.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; import { resolveTelegramFetch } from "./fetch.js"; @@ -22,8 +22,11 @@ import { isTelegramRateLimitError, isTelegramServerError, } from "./network-errors.js"; -import { normalizeTelegramReplyToMessageId } from "./outbound-params.js"; import { makeProxyFetch } from "./proxy.js"; +import { + buildTelegramThreadReplyParams, + resolveTelegramSendThreadSpec, +} from "./reply-parameters.js"; import { buildOutboundMediaLoadOptions, getImageMetadata, @@ -57,16 +60,6 @@ type TelegramCreateForumTopicParams = NonNullable(params: { label: string; verbose?: boolean; @@ -636,11 +595,14 @@ export async function sendMessageTelegram( const replyMarkup = buildInlineKeyboard(opts.buttons); const threadParams = buildTelegramThreadReplyParams({ - targetMessageThreadId: target.messageThreadId, - messageThreadId: opts.messageThreadId, - chatType: target.chatType, + thread: resolveTelegramSendThreadSpec({ + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, + }), replyToMessageId: opts.replyToMessageId, - quoteText: opts.quoteText, + replyQuoteText: opts.quoteText, + useReplyIdAsQuoteSource: true, }); const hasThreadParams = Object.keys(threadParams).length > 0; const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({ @@ -1498,9 +1460,11 @@ export async function sendStickerTelegram( }); const threadParams = buildTelegramThreadReplyParams({ - targetMessageThreadId: target.messageThreadId, - messageThreadId: opts.messageThreadId, - chatType: target.chatType, + thread: resolveTelegramSendThreadSpec({ + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, + }), replyToMessageId: opts.replyToMessageId, }); const hasThreadParams = Object.keys(threadParams).length > 0; @@ -1584,9 +1548,11 @@ export async function sendPollTelegram( const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 }); const threadParams = buildTelegramThreadReplyParams({ - targetMessageThreadId: target.messageThreadId, - messageThreadId: opts.messageThreadId, - chatType: target.chatType, + thread: resolveTelegramSendThreadSpec({ + targetMessageThreadId: target.messageThreadId, + messageThreadId: opts.messageThreadId, + chatType: target.chatType, + }), replyToMessageId: opts.replyToMessageId, });