From 27c52205f9fdeac0483600b2663f1c4d80aba45d Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 17 Apr 2026 18:30:38 +0300 Subject: [PATCH] fix(channels): bridge openDM delegates to adapter directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat.openDM dispatches via inferAdapterFromUserId, which only recognizes Discord/Slack/Teams/gChat formats and throws for everything else — breaking approval delivery on Telegram (numeric IDs) and the other direct-addressable channels the bridge now wraps. Delegate straight to adapter.openDM + channelIdFromThreadId, and only expose openDM when the underlying adapter implements it. Preserves the adapter's native platform_id encoding (e.g. "telegram:") so user_dms caches align with the messaging_groups rows onInbound wrote. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/access.test.ts | 31 ++++++++++++++++++----- src/channels/chat-sdk-bridge.test.ts | 38 ++++++++++++++++++++++++++++ src/channels/chat-sdk-bridge.ts | 37 ++++++++++++++------------- 3 files changed, 83 insertions(+), 23 deletions(-) create mode 100644 src/channels/chat-sdk-bridge.test.ts diff --git a/src/access.test.ts b/src/access.test.ts index 08ae73c..ff59656 100644 --- a/src/access.test.ts +++ b/src/access.test.ts @@ -193,21 +193,40 @@ describe('pickApprover', () => { }); describe('ensureUserDm', () => { - it('direct-addressable channels: lazily creates a messaging_group using the handle', async () => { - await mountMockAdapter('telegram'); // no openDM → direct-addressable - seedUser('telegram:123', 'telegram'); + it('adapter without openDM: falls through to using the bare handle as platform_id', async () => { + await mountMockAdapter('nodm'); // no openDM → direct-addressable fallback + seedUser('nodm:123', 'nodm'); - const mg = await ensureUserDm('telegram:123'); + const mg = await ensureUserDm('nodm:123'); expect(mg).toBeDefined(); - expect(mg!.channel_type).toBe('telegram'); + expect(mg!.channel_type).toBe('nodm'); expect(mg!.platform_id).toBe('123'); expect(mg!.is_group).toBe(0); // Cache row written - const cached = getUserDm('telegram:123', 'telegram'); + const cached = getUserDm('nodm:123', 'nodm'); expect(cached?.messaging_group_id).toBe(mg!.id); }); + it('Telegram via chat-sdk-bridge: adapter.openDM returns prefixed platform_id', async () => { + // Post-fix bridge behavior: the bridged Telegram adapter exposes openDM + // that delegates to the underlying @chat-adapter/telegram adapter, whose + // channelIdFromThreadId returns "telegram:". That's the same + // encoding onInbound stores in messaging_groups, so cache hits on repeat. + const mock = await mountMockAdapter('telegram', async (handle) => `telegram:${handle}`); + seedUser('telegram:6037840640', 'telegram'); + + const mg = await ensureUserDm('telegram:6037840640'); + expect(mg).toBeDefined(); + expect(mg!.platform_id).toBe('telegram:6037840640'); + expect(mock.openDMCalls).toEqual(['6037840640']); + + // Second call hits the user_dms cache, not openDM again. + const mg2 = await ensureUserDm('telegram:6037840640'); + expect(mg2!.id).toBe(mg!.id); + expect(mock.openDMCalls).toEqual(['6037840640']); + }); + it('resolution-required channels: calls adapter.openDM, uses its result, caches', async () => { const mock = await mountMockAdapter('discord', async (handle) => `dm-channel-${handle}`); seedUser('discord:user-1', 'discord'); diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts new file mode 100644 index 0000000..e71ccb2 --- /dev/null +++ b/src/channels/chat-sdk-bridge.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import type { Adapter } from 'chat'; + +import { createChatSdkBridge } from './chat-sdk-bridge.js'; + +function stubAdapter(partial: Partial): Adapter { + return { name: 'stub', ...partial } as unknown as Adapter; +} + +describe('createChatSdkBridge', () => { + it('omits openDM when the underlying Chat SDK adapter has none', () => { + const bridge = createChatSdkBridge({ + adapter: stubAdapter({}), + supportsThreads: false, + }); + expect(bridge.openDM).toBeUndefined(); + }); + + it('exposes openDM when the underlying adapter has one, and delegates directly', async () => { + const openDMCalls: string[] = []; + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ + openDM: async (userId: string) => { + openDMCalls.push(userId); + return `thread::${userId}`; + }, + channelIdFromThreadId: (threadId: string) => `stub:${threadId.replace(/^thread::/, '')}`, + }), + supportsThreads: false, + }); + expect(bridge.openDM).toBeDefined(); + const platformId = await bridge.openDM!('user-42'); + // Delegation: adapter.openDM → adapter.channelIdFromThreadId, no chat.openDM in between. + expect(openDMCalls).toEqual(['user-42']); + expect(platformId).toBe('stub:user-42'); + }); +}); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 6c0a392..30ba0e8 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -141,7 +141,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter }; } - return { + const bridge: ChannelAdapter = { name: adapter.name, channelType: adapter.name, supportsThreads: config.supportsThreads, @@ -348,22 +348,6 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter await adapter.startTyping(tid); }, - /** - * Open (or fetch) a DM with a user via Chat SDK's chat.openDM. The - * returned Thread's id is encoded platform-specifically (e.g. Discord - * encodes @me:channelId:threadId), so we unwrap with - * channelIdFromThreadId to get the plain DM channel id — that's what - * the rest of NanoClaw uses as `platform_id`. - * - * Throws if Chat SDK's underlying adapter doesn't implement openDM. - * Channels without DM support (Telegram, WhatsApp native) don't go - * through chat-sdk-bridge at all, so this path isn't invoked for them. - */ - async openDM(userHandle: string): Promise { - const thread = await chat.openDM(userHandle); - return adapter.channelIdFromThreadId(thread.id); - }, - async teardown() { gatewayAbort?.abort(); await chat.shutdown(); @@ -378,6 +362,25 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter conversations = buildConversationMap(configs); }, }; + + // Only expose openDM when the underlying Chat SDK adapter implements it. + // Delegate straight to adapter.openDM rather than going through chat.openDM: + // the latter dispatches via inferAdapterFromUserId, which only recognizes + // Discord snowflakes, Slack U-ids, Teams 29:-ids, and gChat users/-ids, and + // throws for everything else (Telegram numeric ids, iMessage, Matrix, …). + // Calling adapter.openDM directly also preserves the adapter's native + // platform_id encoding via channelIdFromThreadId (e.g. "telegram:"), + // which matches what onInbound stores in messaging_groups — avoiding a + // duplicate-row / decode-error cascade at delivery time. See user-dm.ts for + // the direct-addressable fallback when the adapter has no openDM at all. + if (adapter.openDM) { + bridge.openDM = async (userHandle: string): Promise => { + const threadId = await adapter.openDM!(userHandle); + return adapter.channelIdFromThreadId(threadId); + }; + } + + return bridge; } /**