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, });