Files
openclaw/extensions/telegram/src/bot-message-context.reply-batch.test.ts
iridescentWen 94cdb6c46e fix(telegram): preserve batched reply and quote context (#121907)
Preserve reply and selected-quote metadata from later Telegram debounce and fragment-batch messages while keeping reply-chain recovery bounded.

Co-authored-by: iridescentWen <whatmakesbetter@outlook.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-08-14 17:27:11 +05:30

161 lines
5.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
import { TELEGRAM_REPLY_CHAIN_MAX_DEPTH } from "./message-cache.js";
const CHAT = { id: 999, type: "private" as const, first_name: "Alice" };
const SENDER = { id: 42, first_name: "Alice", is_bot: false as const };
const QUOTED_LINE = "the quoted source line";
// `ReplyMessage` is a Message pinned to `reply_to_message: undefined`, so the
// embedded reply target cannot itself nest another one.
const REPLY_TARGET = {
message_id: 90,
date: 1_699_999_000,
chat: CHAT,
text: QUOTED_LINE,
from: { id: 7, first_name: "Bob", is_bot: false as const },
reply_to_message: undefined,
};
function quotingMessage(messageId: number, text: string) {
return {
message_id: messageId,
date: 1_700_000_000 + messageId,
chat: CHAT,
from: SENDER,
text,
reply_to_message: REPLY_TARGET,
quote: { text: QUOTED_LINE, position: 0 },
};
}
/** A quoting message whose reply target is unique, so none of them dedupe away. */
function quotingDistinctSource(messageId: number) {
const sourceText = `distinct source ${messageId}`;
return {
message_id: messageId,
date: 1_700_000_000 + messageId,
chat: CHAT,
from: SENDER,
text: `ask ${messageId}`,
reply_to_message: { ...REPLY_TARGET, message_id: 500 + messageId, text: sourceText },
quote: { text: sourceText, position: 0 },
};
}
function countReplyChainEntries(body: string): number {
const block = body.match(/\[Reply chain - nearest first\]\n([\s\S]*?)\n\[\/Reply chain\]/)?.[1];
if (!block) {
return 0;
}
// Entries render as `[1. Bob id:501]` followed by their body lines.
return block.split("\n").filter((line) => /^\[\d+\. /.test(line)).length;
}
function plainMessage(messageId: number, text: string) {
return { message_id: messageId, date: 1_700_000_000 + messageId, chat: CHAT, from: SENDER, text };
}
describe("buildTelegramMessageContext reply/quote debounce batches", () => {
it("preserves a quote carried by a non-first buffered message", async () => {
const context = await buildTelegramMessageContextForTest({
message: plainMessage(2, "plain note\nquoting note"),
options: {
bufferedMessages: [plainMessage(1, "plain note"), quotingMessage(2, "quoting note")],
},
});
expect(context?.ctxPayload.Body).toContain(QUOTED_LINE);
});
it("keeps cached first-message ancestry after a quote-only follow-up", async () => {
const first = quotingMessage(1, "first ask");
const context = await buildTelegramMessageContextForTest({
message: { ...first, text: "first ask\nfollow-up" },
replyChain: [
{
messageId: "90",
replyToId: "89",
sender: "Cached Bob",
senderId: "7",
body: QUOTED_LINE,
timestamp: 1_699_999_000_000,
},
],
options: {
bufferedMessages: [
first,
{
...plainMessage(2, "follow-up"),
quote: { text: "quote-only follow-up", position: 0 },
},
],
},
});
expect(context?.ctxPayload.ReplyChain).toMatchObject([
{ sender: "unknown sender", body: "quote-only follow-up", isQuote: true },
{
messageId: "90",
replyToId: "89",
sender: "Cached Bob",
senderId: "7",
timestamp: 1_699_999_000_000,
},
]);
});
it("lists a source re-quoted by two buffered messages only once", async () => {
const context = await buildTelegramMessageContextForTest({
message: plainMessage(3, "first ask\nsecond ask"),
options: {
bufferedMessages: [quotingMessage(1, "first ask"), quotingMessage(2, "second ask")],
},
});
const body = context?.ctxPayload.Body ?? "";
expect(body).toContain(QUOTED_LINE);
expect(body.split(QUOTED_LINE).length - 1).toBe(1);
});
// The cap must not spend all its slots on the first message's inherited
// ancestry, or it suppresses exactly the later quote this path recovers.
it("keeps a later quote when the first message already has a full reply ancestry", async () => {
const ancestry = Array.from({ length: TELEGRAM_REPLY_CHAIN_MAX_DEPTH }, (_, i) => ({
messageId: String(900 + i),
sender: "Bob",
body: `ancestor ${i}`,
}));
const context = await buildTelegramMessageContextForTest({
message: plainMessage(2, "plain note\nquoting note"),
replyChain: ancestry,
options: {
bufferedMessages: [plainMessage(1, "plain note"), quotingMessage(2, "quoting note")],
},
});
const body = context?.ctxPayload.Body ?? "";
expect(body).toContain(QUOTED_LINE);
expect(countReplyChainEntries(body)).toBeLessThanOrEqual(TELEGRAM_REPLY_CHAIN_MAX_DEPTH);
});
// A debounce window has no per-item cap of its own. Keep the nearest targets,
// not the oldest messages that happened to enter the window first.
it("keeps the newest batch reply targets in nearest-first order", async () => {
const burst = Array.from({ length: TELEGRAM_REPLY_CHAIN_MAX_DEPTH * 2 + 2 }, (_, i) =>
quotingDistinctSource(i + 1),
);
const context = await buildTelegramMessageContextForTest({
message: plainMessage(99, burst.map((entry) => entry.text).join("\n")),
options: { bufferedMessages: burst },
});
const body = context?.ctxPayload.Body ?? "";
expect(Array.from(body.matchAll(/"distinct source \d+"/g), ([source]) => source)).toEqual([
'"distinct source 10"',
'"distinct source 9"',
'"distinct source 8"',
'"distinct source 7"',
]);
});
});