From 70c8abdca19126e23ea96a9426f1bd9882bd6702 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 10:25:54 +0100 Subject: [PATCH] refactor(telegram): keep topic thread mapping plugin-local * refactor(telegram): keep topic thread mapping plugin-local * fix(telegram): preserve native topic ids for username targets --- extensions/telegram/src/channel.ts | 48 +++++++++++++++---- extensions/telegram/src/session-route.test.ts | 45 +++++++++++++++++ 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 013cc5f3693..dd6d7c21897 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -553,28 +553,60 @@ function resolveTelegramOutboundSessionRoute(params: { if (isGroup) { return baseRoute; } - const outboundSessionThreadId = - resolvedThreadId !== undefined ? `${chatId}:${resolvedThreadId}` : undefined; + const canonicalThreadId = + resolvedThreadId !== undefined + ? buildTelegramCanonicalTopicThreadId({ chatId, topicId: resolvedThreadId }) + : undefined; const route = buildThreadAwareOutboundSessionRoute({ route: baseRoute, - threadId: outboundSessionThreadId, + threadId: canonicalThreadId, currentSessionKey: params.currentSessionKey, precedence: ["threadId", "currentSession"], canRecoverCurrentThread: ({ route }) => route.chatType !== "direct" || (params.cfg.session?.dmScope ?? "main") !== "main", }); - const deliveryThreadId = - resolvedThreadId ?? parseTelegramThreadId(route.threadId) ?? route.threadId; + const routeThreadId = resolveTelegramNativeTopicThreadId(route.threadId, resolvedThreadId); return { ...route, - ...(deliveryThreadId !== undefined ? { threadId: deliveryThreadId } : {}), + ...(routeThreadId !== undefined ? { threadId: routeThreadId } : {}), from: - deliveryThreadId !== undefined - ? `telegram:${chatId}:topic:${deliveryThreadId}` + routeThreadId !== undefined + ? `telegram:${chatId}:topic:${routeThreadId}` : `telegram:${chatId}`, }; } +function buildTelegramCanonicalTopicThreadId(params: { chatId: string; topicId: number }): string { + // Core session routing sees one canonical thread id. Telegram topic ids are + // chat-scoped, so direct-topic sessions include the chat id to avoid collisions. + return `${params.chatId}:${params.topicId}`; +} + +function resolveTelegramNativeTopicThreadId( + threadId?: string | number, + nativeTopicId?: number, +): string | number | undefined { + if (nativeTopicId !== undefined) { + return nativeTopicId; + } + // Keep the chat-scoped canonical id inside OpenClaw state; translate it back + // only when returning Telegram route metadata used by send/typing paths. + if (threadId === undefined) { + return undefined; + } + const parsedThreadId = parseTelegramThreadId(threadId); + if (parsedThreadId !== undefined) { + return parsedThreadId; + } + if (typeof threadId === "string") { + const canonicalMatch = /:(\d+)$/.exec(threadId.trim()); + if (canonicalMatch?.[1]) { + return Number(canonicalMatch[1]); + } + } + return threadId; +} + async function resolveTelegramTargets(params: { cfg: OpenClawConfig; accountId?: string | null; diff --git a/extensions/telegram/src/session-route.test.ts b/extensions/telegram/src/session-route.test.ts index 7180d2a5c5b..302ca70d410 100644 --- a/extensions/telegram/src/session-route.test.ts +++ b/extensions/telegram/src/session-route.test.ts @@ -14,6 +14,37 @@ describe("telegram session route", () => { expect(route?.threadId).toBe(99); }); + it("keeps same direct topic ids distinct across chats", async () => { + const first = await telegramPlugin.messaging?.resolveOutboundSessionRoute?.({ + cfg: {}, + agentId: "main", + target: "12345:topic:99", + }); + const second = await telegramPlugin.messaging?.resolveOutboundSessionRoute?.({ + cfg: {}, + agentId: "main", + target: "67890:topic:99", + }); + + expect(first?.sessionKey).toBe("agent:main:main:thread:12345:99"); + expect(second?.sessionKey).toBe("agent:main:main:thread:67890:99"); + expect(first?.threadId).toBe(99); + expect(second?.threadId).toBe(99); + }); + + it("returns native topic ids for username direct topic targets", async () => { + const route = await telegramPlugin.messaging?.resolveOutboundSessionRoute?.({ + cfg: {}, + agentId: "main", + target: "@alice:topic:99", + }); + + expect(route?.sessionKey).toBe("agent:main:main:thread:@alice:99"); + expect(route?.baseSessionKey).toBe("agent:main:main"); + expect(route?.threadId).toBe(99); + expect(route?.from).toBe("telegram:@alice:topic:99"); + }); + it("aligns isolated direct topic sessions with inbound reply routing", async () => { const route = await telegramPlugin.messaging?.resolveOutboundSessionRoute?.({ cfg: { session: { dmScope: "per-account-channel-peer" } }, @@ -44,6 +75,20 @@ describe("telegram session route", () => { expect(route?.from).toBe("telegram:12345:topic:99"); }); + it("recovers username direct topic thread routes from currentSessionKey", async () => { + const route = await telegramPlugin.messaging?.resolveOutboundSessionRoute?.({ + cfg: { session: { dmScope: "per-channel-peer" } }, + agentId: "main", + target: "@alice", + currentSessionKey: "agent:main:telegram:direct:@alice:thread:@alice:99", + }); + + expect(route?.sessionKey).toBe("agent:main:telegram:direct:@alice:thread:@alice:99"); + expect(route?.baseSessionKey).toBe("agent:main:telegram:direct:@alice"); + expect(route?.threadId).toBe(99); + expect(route?.from).toBe("telegram:@alice:topic:99"); + }); + it('does not recover currentSessionKey threads for shared dmScope "main" DMs', async () => { const route = await telegramPlugin.messaging?.resolveOutboundSessionRoute?.({ cfg: {},