mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
94cdb6c46e
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>
161 lines
5.6 KiB
TypeScript
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"',
|
|
]);
|
|
});
|
|
});
|