From 89b7fee352f74dea89599cc2f9bc06d5b368e064 Mon Sep 17 00:00:00 2001 From: VACInc Date: Wed, 25 Mar 2026 02:01:01 -0400 Subject: [PATCH] fix: preserve Telegram forum topic last-route delivery (#53052) (thanks @VACInc) * fix(telegram): preserve forum topic thread in last-route delivery * style(telegram): format last-route update * test(telegram): cover General topic last-route thread * test(telegram): align topic route helper * fix(telegram): skip bound-topic last-route writes --------- Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com> Co-authored-by: Ayaan Zaidi --- .../bot-message-context.acp-bindings.test.ts | 22 +++++--- ...-message-context.dm-topic-threadid.test.ts | 33 ++++++++++-- .../src/bot-message-context.session.ts | 54 +++++++++++-------- ...bot-message-context.thread-binding.test.ts | 7 +++ 4 files changed, 84 insertions(+), 32 deletions(-) diff --git a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts index 3fad041383c..32209deb5b6 100644 --- a/extensions/telegram/src/bot-message-context.acp-bindings.test.ts +++ b/extensions/telegram/src/bot-message-context.acp-bindings.test.ts @@ -2,16 +2,20 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createConfiguredBindingConversationRuntimeModuleMock } from "../../../test/helpers/extensions/configured-binding-runtime.js"; const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn()); +const recordInboundSessionMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { - return await createConfiguredBindingConversationRuntimeModuleMock( - { - ensureConfiguredBindingRouteReadyMock, - resolveConfiguredBindingRouteMock, - }, - importOriginal, - ); + return { + ...(await createConfiguredBindingConversationRuntimeModuleMock( + { + ensureConfiguredBindingRouteReadyMock, + resolveConfiguredBindingRouteMock, + }, + importOriginal, + )), + recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), + }; }); let buildTelegramMessageContextForTest: typeof import("./bot-message-context.test-harness.js").buildTelegramMessageContextForTest; @@ -136,6 +140,7 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { beforeEach(() => { ensureConfiguredBindingRouteReadyMock.mockReset(); + recordInboundSessionMock.mockClear(); resolveConfiguredBindingRouteMock.mockReset(); resolveConfiguredBindingRouteMock.mockReturnValue(createConfiguredTelegramRoute()); ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true }); @@ -155,6 +160,9 @@ describe("buildTelegramMessageContext ACP configured bindings", () => { expect(ctx?.route.accountId).toBe("work"); expect(ctx?.route.matchedBy).toBe("binding.channel"); expect(ctx?.route.sessionKey).toBe("agent:codex:acp:binding:telegram:work:abc123"); + expect(recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({ + updateLastRoute: undefined, + }); expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1); }); diff --git a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts index ff6e78968d9..696f74888ea 100644 --- a/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts +++ b/extensions/telegram/src/bot-message-context.dm-topic-threadid.test.ts @@ -74,10 +74,10 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 expect(updateLastRoute?.threadId).toBeUndefined(); }); - it("does not set updateLastRoute for group messages", async () => { + it("passes threadId to updateLastRoute for forum topic group messages", async () => { const ctx = await buildCtx({ message: { - chat: { id: -1001234567890, type: "supergroup", title: "Test Group" }, + chat: { id: -1001234567890, type: "supergroup", title: "Test Group", is_forum: true }, text: "@bot hello", message_thread_id: 99, }, @@ -88,7 +88,32 @@ describe("buildTelegramMessageContext DM topic threadId in deliveryContext (#889 expect(ctx).not.toBeNull(); expect(recordInboundSessionMock).toHaveBeenCalled(); - // Check that updateLastRoute is undefined for groups - expect(getRecordedUpdateLastRoute(0)).toBeUndefined(); + const updateLastRoute = getRecordedUpdateLastRoute(0) as + | { threadId?: string; to?: string } + | undefined; + expect(updateLastRoute).toBeDefined(); + expect(updateLastRoute?.to).toBe("telegram:-1001234567890"); + expect(updateLastRoute?.threadId).toBe("99"); + }); + + it("passes threadId to updateLastRoute for the forum General topic", async () => { + const ctx = await buildCtx({ + message: { + chat: { id: -1001234567890, type: "supergroup", title: "Test Group", is_forum: true }, + text: "@bot hello", + }, + options: { forceWasMentioned: true }, + resolveGroupActivation: () => true, + }); + + expect(ctx).not.toBeNull(); + expect(recordInboundSessionMock).toHaveBeenCalled(); + + const updateLastRoute = getRecordedUpdateLastRoute(0) as + | { threadId?: string; to?: string } + | undefined; + expect(updateLastRoute).toBeDefined(); + expect(updateLastRoute?.to).toBe("telegram:-1001234567890"); + expect(updateLastRoute?.threadId).toBe("1"); }); }); diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index 2581e1d398b..7782b4fe0cb 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -261,32 +261,44 @@ export async function buildTelegramInboundContextPayload(params: { route, sessionKey: route.sessionKey, }); + const shouldPersistGroupLastRouteThread = isGroup && route.matchedBy !== "binding.channel"; + const updateLastRouteThreadId = isGroup + ? shouldPersistGroupLastRouteThread && resolvedThreadId != null + ? String(resolvedThreadId) + : undefined + : dmThreadId != null + ? String(dmThreadId) + : undefined; await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey, ctx: ctxPayload, - updateLastRoute: !isGroup - ? { - sessionKey: updateLastRouteSessionKey, - channel: "telegram", - to: `telegram:${chatId}`, - accountId: route.accountId, - threadId: dmThreadId != null ? String(dmThreadId) : undefined, - mainDmOwnerPin: - updateLastRouteSessionKey === route.mainSessionKey && pinnedMainDmOwner && senderId - ? { - ownerRecipient: pinnedMainDmOwner, - senderRecipient: senderId, - onSkip: ({ ownerRecipient, senderRecipient }) => { - logVerbose( - `telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, - ); - }, - } - : undefined, - } - : undefined, + updateLastRoute: + !isGroup || updateLastRouteThreadId != null + ? { + sessionKey: updateLastRouteSessionKey, + channel: "telegram", + to: `telegram:${chatId}`, + accountId: route.accountId, + threadId: updateLastRouteThreadId, + mainDmOwnerPin: + !isGroup && + updateLastRouteSessionKey === route.mainSessionKey && + pinnedMainDmOwner && + senderId + ? { + ownerRecipient: pinnedMainDmOwner, + senderRecipient: senderId, + onSkip: ({ ownerRecipient, senderRecipient }) => { + logVerbose( + `telegram: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, + ); + }, + } + : undefined, + } + : undefined, onRecordError: (err) => { logVerbose(`telegram: failed updating session meta: ${String(err)}`); }, diff --git a/extensions/telegram/src/bot-message-context.thread-binding.test.ts b/extensions/telegram/src/bot-message-context.thread-binding.test.ts index 48205c77f1e..de4a3d08ad7 100644 --- a/extensions/telegram/src/bot-message-context.thread-binding.test.ts +++ b/extensions/telegram/src/bot-message-context.thread-binding.test.ts @@ -2,8 +2,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => { const resolveByConversationMock = vi.fn(); + const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined); const touchMock = vi.fn(); return { + recordInboundSessionMock, resolveByConversationMock, touchMock, }; @@ -13,6 +15,7 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + recordInboundSession: (...args: unknown[]) => hoisted.recordInboundSessionMock(...args), getSessionBindingService: () => ({ bind: vi.fn(), getCapabilities: vi.fn(), @@ -34,6 +37,7 @@ describe("buildTelegramMessageContext bound conversation override", () => { }); beforeEach(() => { + hoisted.recordInboundSessionMock.mockClear(); hoisted.resolveByConversationMock.mockReset().mockReturnValue(null); hoisted.touchMock.mockReset(); }); @@ -63,6 +67,9 @@ describe("buildTelegramMessageContext bound conversation override", () => { conversationId: "-100200300:topic:77", }); expect(ctx?.ctxPayload?.SessionKey).toBe("agent:codex-acp:session-1"); + expect(hoisted.recordInboundSessionMock.mock.calls[0]?.[0]).toMatchObject({ + updateLastRoute: undefined, + }); expect(hoisted.touchMock).toHaveBeenCalledWith("default:-100200300:topic:77", undefined); });