test(telegram): cover debounce topic keys at seam

This commit is contained in:
Peter Steinberger
2026-04-22 23:56:58 +01:00
parent 9b1f1036ac
commit bee491f439
4 changed files with 48 additions and 88 deletions

View File

@@ -7,3 +7,12 @@ export function buildTelegramInboundDebounceKey(params: {
const resolvedAccountId = params.accountId?.trim() || "default";
return `telegram:${resolvedAccountId}:${params.conversationKey}:${params.senderId}:${params.debounceLane}`;
}
export function buildTelegramInboundDebounceConversationKey(params: {
chatId: number | string;
threadId?: number | null;
}): string {
return params.threadId != null
? `${params.chatId}:topic:${params.threadId}`
: String(params.chatId);
}

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInboundDebounceKey } from "./bot-handlers.debounce-key.js";
import {
buildTelegramInboundDebounceConversationKey,
buildTelegramInboundDebounceKey,
} from "./bot-handlers.debounce-key.js";
describe("buildTelegramInboundDebounceKey", () => {
it("uses the resolved account id instead of literal default when provided", () => {
@@ -23,4 +26,31 @@ describe("buildTelegramInboundDebounceKey", () => {
}),
).toBe("telegram:default:12345:67890:forward");
});
it("keeps direct topic thread ids in the conversation key", () => {
const topic100 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 100 });
const topic200 = buildTelegramInboundDebounceConversationKey({ chatId: 7, threadId: 200 });
expect(topic100).toBe("7:topic:100");
expect(topic200).toBe("7:topic:200");
expect(
buildTelegramInboundDebounceKey({
accountId: "default",
conversationKey: topic100,
senderId: "42",
debounceLane: "default",
}),
).not.toBe(
buildTelegramInboundDebounceKey({
accountId: "default",
conversationKey: topic200,
senderId: "42",
debounceLane: "default",
}),
);
});
it("uses the chat id as the conversation key when no thread is present", () => {
expect(buildTelegramInboundDebounceConversationKey({ chatId: 7 })).toBe("7");
});
});

View File

@@ -43,7 +43,10 @@ import {
resolveDefaultAgentId,
resolveDefaultModelForAgent,
} from "./bot-handlers.agent.runtime.js";
import { buildTelegramInboundDebounceKey } from "./bot-handlers.debounce-key.js";
import {
buildTelegramInboundDebounceConversationKey,
buildTelegramInboundDebounceKey,
} from "./bot-handlers.debounce-key.js";
import {
hasInboundMedia,
hasReplyTargetMedia,
@@ -1165,9 +1168,10 @@ export const registerTelegramHandlers = ({
]
: [];
const senderId = msg.from?.id ? String(msg.from.id) : "";
const conversationThreadId = resolvedThreadId ?? dmThreadId;
const conversationKey =
conversationThreadId != null ? `${chatId}:topic:${conversationThreadId}` : String(chatId);
const conversationKey = buildTelegramInboundDebounceConversationKey({
chatId,
threadId: resolvedThreadId ?? dmThreadId,
});
const debounceLane = resolveTelegramDebounceLane(msg);
const debounceKey = senderId
? buildTelegramInboundDebounceKey({

View File

@@ -1676,89 +1676,6 @@ describe("createTelegramBot", () => {
}
});
it("isolates inbound debounce by DM topic thread id", async () => {
const DEBOUNCE_MS = 4321;
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
messages: {
inbound: {
debounceMs: DEBOUNCE_MS,
},
},
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const repliesDelivered = waitForReplyCalls(2);
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 7, type: "private" },
text: "topic-100",
date: 1736380800,
message_id: 201,
message_thread_id: 100,
from: { id: 42, first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
await handler({
message: {
chat: { id: 7, type: "private" },
text: "topic-200",
date: 1736380801,
message_id: 202,
message_thread_id: 200,
from: { id: 42, first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
});
expect(replySpy).not.toHaveBeenCalled();
const debounceTimerIndexes = setTimeoutSpy.mock.calls
.map((call, index) => ({ index, delay: call[1] }))
.filter((entry) => entry.delay === DEBOUNCE_MS)
.map((entry) => entry.index);
expect(debounceTimerIndexes.length).toBeGreaterThanOrEqual(2);
for (const index of debounceTimerIndexes) {
clearTimeout(setTimeoutSpy.mock.results[index]?.value as ReturnType<typeof setTimeout>);
}
for (const index of debounceTimerIndexes) {
const flushTimer = setTimeoutSpy.mock.calls[index]?.[0] as (() => unknown) | undefined;
await flushTimer?.();
}
await repliesDelivered;
const threadIds = replySpy.mock.calls
.map(
(call: [unknown, ...unknown[]]) =>
(call[0] as { MessageThreadId?: number }).MessageThreadId,
)
.toSorted((a: number | undefined, b: number | undefined) => (a ?? 0) - (b ?? 0));
expect(threadIds).toEqual([100, 200]);
} finally {
setTimeoutSpy.mockRestore();
}
});
it("handles quote-only replies without reply metadata", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();