mirror of https://github.com/openclaw/openclaw.git
fix(telegram): link forwarded messages with comments (#9720)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 5f81061b5f
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
parent
40a68a8936
commit
337eef55d7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => Promise<void>;
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue