diff --git a/extensions/telegram/src/bot-handlers.debounce-key.ts b/extensions/telegram/src/bot-handlers.debounce-key.ts index 530230cc1f8..de1aa55ac5f 100644 --- a/extensions/telegram/src/bot-handlers.debounce-key.ts +++ b/extensions/telegram/src/bot-handlers.debounce-key.ts @@ -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); +} diff --git a/extensions/telegram/src/bot-handlers.runtime.test.ts b/extensions/telegram/src/bot-handlers.runtime.test.ts index 08d2c6ef6c8..2a9ea1195a5 100644 --- a/extensions/telegram/src/bot-handlers.runtime.test.ts +++ b/extensions/telegram/src/bot-handlers.runtime.test.ts @@ -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"); + }); }); diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index 2d6c5a2cd92..62b00ad8630 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -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({ diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 1e64cd8fc7c..854df5e15bd 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -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) => Promise; - - 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); - } - 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();