From 337eef55d7a93366c3a5d33ac67fb191538bc065 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Sun, 22 Feb 2026 12:53:56 -0300 Subject: [PATCH] fix(telegram): link forwarded messages with comments (#9720) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 5f81061b5f613903422a624d95aab8b0fc04027a Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com> Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com> Reviewed-by: @obviyus --- CHANGELOG.md | 1 + src/auto-reply/templating.ts | 7 ++ src/telegram/bot-message-context.ts | 21 ++++- src/telegram/bot.test.ts | 50 +++++++++++ src/telegram/bot/helpers.test.ts | 132 ++++++++++++++++++++++++++++ src/telegram/bot/helpers.ts | 8 ++ 6 files changed, 217 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee39602d5a..633b1f0838b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. - Telegram/Network: default Node 22+ DNS result ordering to `ipv4first` for Telegram fetch paths and add `OPENCLAW_TELEGRAM_DNS_RESULT_ORDER`/`channels.telegram.network.dnsResultOrder` overrides to reduce IPv6-path fetch failures. (#5405) Thanks @Glucksberg. - Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. +- Telegram/Replies: extract forwarded-origin context from unified reply targets (`reply_to_message` and `external_reply`) so forward+comment metadata is preserved across partial reply shapes. (#9720) thanks @mcaxtr. - Telegram/Polling: persist a safe update-offset watermark bounded by pending updates so crash/restart cannot skip queued lower `update_id` updates after out-of-order completion. (#23284) thanks @frankekn. - Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. - Slack/Telegram slash sessions: await session metadata persistence before dispatch so first-turn native slash runs do not race session-origin metadata updates. (#23065) thanks @hydro13. diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 4bc9b517549..1193490ff26 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -59,6 +59,13 @@ export type MsgContext = { ReplyToBody?: string; ReplyToSender?: string; ReplyToIsQuote?: boolean; + /** Forward origin from the reply target (when reply_to_message is a forwarded message). */ + ReplyToForwardedFrom?: string; + ReplyToForwardedFromType?: string; + ReplyToForwardedFromId?: string; + ReplyToForwardedFromUsername?: string; + ReplyToForwardedFromTitle?: string; + ReplyToForwardedDate?: number; ForwardedFrom?: string; ForwardedFromType?: string; ForwardedFromId?: string; diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts index e6d5bf9ad8b..b3fa5b9f60f 100644 --- a/src/telegram/bot-message-context.ts +++ b/src/telegram/bot-message-context.ts @@ -615,14 +615,22 @@ export const buildTelegramMessageContext = async ({ const replyTarget = describeReplyTarget(msg); const forwardOrigin = normalizeForwardedContext(msg); + // Build forward annotation for reply target if it was itself a forwarded message (issue #9619) + const replyForwardAnnotation = replyTarget?.forwardedFrom + ? `[Forwarded from ${replyTarget.forwardedFrom.from}${ + replyTarget.forwardedFrom.date + ? ` at ${new Date(replyTarget.forwardedFrom.date * 1000).toISOString()}` + : "" + }]\n` + : ""; const replySuffix = replyTarget ? replyTarget.kind === "quote" ? `\n\n[Quoting ${replyTarget.sender}${ replyTarget.id ? ` id:${replyTarget.id}` : "" - }]\n"${replyTarget.body}"\n[/Quoting]` + }]\n${replyForwardAnnotation}"${replyTarget.body}"\n[/Quoting]` : `\n\n[Replying to ${replyTarget.sender}${ replyTarget.id ? ` id:${replyTarget.id}` : "" - }]\n${replyTarget.body}\n[/Replying]` + }]\n${replyForwardAnnotation}${replyTarget.body}\n[/Replying]` : ""; const forwardPrefix = forwardOrigin ? `[Forwarded from ${forwardOrigin.from}${ @@ -714,6 +722,15 @@ export const buildTelegramMessageContext = async ({ ReplyToBody: replyTarget?.body, ReplyToSender: replyTarget?.sender, ReplyToIsQuote: replyTarget?.kind === "quote" ? true : undefined, + // Forward context from reply target (issue #9619: forward + comment bundling) + ReplyToForwardedFrom: replyTarget?.forwardedFrom?.from, + ReplyToForwardedFromType: replyTarget?.forwardedFrom?.fromType, + ReplyToForwardedFromId: replyTarget?.forwardedFrom?.fromId, + ReplyToForwardedFromUsername: replyTarget?.forwardedFrom?.fromUsername, + ReplyToForwardedFromTitle: replyTarget?.forwardedFrom?.fromTitle, + ReplyToForwardedDate: replyTarget?.forwardedFrom?.date + ? replyTarget.forwardedFrom.date * 1000 + : undefined, ForwardedFrom: forwardOrigin?.from, ForwardedFromType: forwardOrigin?.fromType, ForwardedFromId: forwardOrigin?.fromId, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index b4a49686c1c..03380dbbf62 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -425,6 +425,56 @@ describe("createTelegramBot", () => { expect(payload.ReplyToSender).toBe("Ada"); }); + it("propagates forwarded origin from external_reply targets", async () => { + onSpy.mockReset(); + sendMessageSpy.mockReset(); + replySpy.mockReset(); + + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; + + await handler({ + message: { + chat: { id: 7, type: "private" }, + text: "Thoughts?", + date: 1736380800, + external_reply: { + message_id: 9003, + text: "forwarded text", + from: { first_name: "Ada" }, + quote: { + text: "forwarded snippet", + }, + forward_origin: { + type: "user", + sender_user: { + id: 999, + first_name: "Bob", + last_name: "Smith", + username: "bobsmith", + is_bot: false, + }, + date: 500, + }, + }, + }, + me: { username: "openclaw_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.ReplyToForwardedFrom).toBe("Bob Smith (@bobsmith)"); + expect(payload.ReplyToForwardedFromType).toBe("user"); + expect(payload.ReplyToForwardedFromId).toBe("999"); + expect(payload.ReplyToForwardedFromUsername).toBe("bobsmith"); + expect(payload.ReplyToForwardedFromTitle).toBe("Bob Smith"); + expect(payload.ReplyToForwardedDate).toBe(500000); + expect(payload.Body).toContain( + "[Forwarded from Bob Smith (@bobsmith) at 1970-01-01T00:08:20.000Z]", + ); + }); + it("accepts group replies to the bot without explicit mention when requireMention is enabled", async () => { onSpy.mockClear(); replySpy.mockClear(); diff --git a/src/telegram/bot/helpers.test.ts b/src/telegram/bot/helpers.test.ts index f500f245fba..ffbd0c3efff 100644 --- a/src/telegram/bot/helpers.test.ts +++ b/src/telegram/bot/helpers.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildTelegramThreadParams, buildTypingThreadParams, + describeReplyTarget, expandTextLinks, normalizeForwardedContext, resolveTelegramForumThreadId, @@ -199,6 +200,137 @@ describe("normalizeForwardedContext", () => { }); }); +describe("describeReplyTarget", () => { + it("returns null when no reply_to_message", () => { + const result = describeReplyTarget( + // oxlint-disable-next-line typescript/no-explicit-any + { message_id: 1, date: 1000, chat: { id: 1, type: "private" } } as any, + ); + expect(result).toBeNull(); + }); + + it("extracts basic reply info", () => { + const result = describeReplyTarget({ + message_id: 2, + date: 1000, + chat: { id: 1, type: "private" }, + reply_to_message: { + message_id: 1, + date: 900, + chat: { id: 1, type: "private" }, + text: "Original message", + from: { id: 42, first_name: "Alice", is_bot: false }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + expect(result).not.toBeNull(); + expect(result?.body).toBe("Original message"); + expect(result?.sender).toBe("Alice"); + expect(result?.id).toBe("1"); + expect(result?.kind).toBe("reply"); + }); + + it("extracts forwarded context from reply_to_message (issue #9619)", () => { + // When user forwards a message with a comment, the comment message has + // reply_to_message pointing to the forwarded message. We should extract + // the forward_origin from the reply target. + const result = describeReplyTarget({ + message_id: 3, + date: 1100, + chat: { id: 1, type: "private" }, + text: "Here is my comment about this forwarded content", + reply_to_message: { + message_id: 2, + date: 1000, + chat: { id: 1, type: "private" }, + text: "This is the forwarded content", + forward_origin: { + type: "user", + sender_user: { + id: 999, + first_name: "Bob", + last_name: "Smith", + username: "bobsmith", + is_bot: false, + }, + date: 500, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + expect(result).not.toBeNull(); + expect(result?.body).toBe("This is the forwarded content"); + expect(result?.id).toBe("2"); + // The reply target's forwarded context should be included + expect(result?.forwardedFrom).toBeDefined(); + expect(result?.forwardedFrom?.from).toBe("Bob Smith (@bobsmith)"); + expect(result?.forwardedFrom?.fromType).toBe("user"); + expect(result?.forwardedFrom?.fromId).toBe("999"); + expect(result?.forwardedFrom?.date).toBe(500); + }); + + it("extracts forwarded context from channel forward in reply_to_message", () => { + const result = describeReplyTarget({ + message_id: 4, + date: 1200, + chat: { id: 1, type: "private" }, + text: "Interesting article!", + reply_to_message: { + message_id: 3, + date: 1100, + chat: { id: 1, type: "private" }, + text: "Channel post content here", + forward_origin: { + type: "channel", + chat: { id: -1001234567, title: "Tech News", username: "technews", type: "channel" }, + date: 800, + message_id: 456, + author_signature: "Editor", + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + expect(result).not.toBeNull(); + expect(result?.forwardedFrom).toBeDefined(); + expect(result?.forwardedFrom?.from).toBe("Tech News (Editor)"); + expect(result?.forwardedFrom?.fromType).toBe("channel"); + expect(result?.forwardedFrom?.fromMessageId).toBe(456); + }); + + it("extracts forwarded context from external_reply", () => { + const result = describeReplyTarget({ + message_id: 5, + date: 1300, + chat: { id: 1, type: "private" }, + text: "Comment on forwarded message", + external_reply: { + message_id: 4, + date: 1200, + chat: { id: 1, type: "private" }, + text: "Forwarded from elsewhere", + forward_origin: { + type: "user", + sender_user: { + id: 123, + first_name: "Eve", + last_name: "Stone", + username: "eve", + is_bot: false, + }, + date: 700, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); + expect(result).not.toBeNull(); + expect(result?.id).toBe("4"); + expect(result?.forwardedFrom?.from).toBe("Eve Stone (@eve)"); + expect(result?.forwardedFrom?.fromType).toBe("user"); + expect(result?.forwardedFrom?.fromId).toBe("123"); + expect(result?.forwardedFrom?.date).toBe(700); + }); +}); + describe("expandTextLinks", () => { it("returns text unchanged when no entities are provided", () => { expect(expandTextLinks("Hello world")).toBe("Hello world"); diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts index d8e9560ce18..493ad010082 100644 --- a/src/telegram/bot/helpers.ts +++ b/src/telegram/bot/helpers.ts @@ -321,6 +321,8 @@ export type TelegramReplyTarget = { sender: string; body: string; kind: "reply" | "quote"; + /** Forward context if the reply target was itself a forwarded message (issue #9619). */ + forwardedFrom?: TelegramForwardedContext; }; export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { @@ -359,11 +361,17 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null { const sender = replyLike ? buildSenderName(replyLike) : undefined; const senderLabel = sender ?? "unknown sender"; + // Extract forward context from the resolved reply target (reply_to_message or external_reply). + const forwardedFrom = replyLike?.forward_origin + ? (resolveForwardOrigin(replyLike.forward_origin) ?? undefined) + : undefined; + return { id: replyLike?.message_id ? String(replyLike.message_id) : undefined, sender: senderLabel, body, kind, + forwardedFrom, }; }