From 81f11a54db1e9fe1d3aa21704096f7f0df40b71e Mon Sep 17 00:00:00 2001 From: John Pham Date: Sat, 14 Feb 2026 17:07:19 -0800 Subject: [PATCH 1/6] feat(slack): enable DM support with per-thread IDs and implicit mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DMs now use per-message threadTs (same as channels) instead of collapsing all DM messages into one thread. Each top-level DM starts a new conversation; thread replies continue the same conversation. DM messages automatically set isMention=true so bots process them without requiring @mention — natural for 1:1 conversations. --- packages/adapter-slack/src/index.test.ts | 140 +++++++++++++++++++++++ packages/adapter-slack/src/index.ts | 14 ++- 2 files changed, 149 insertions(+), 5 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 390f06d64..48342e784 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -1273,3 +1273,143 @@ describe("withBotToken", () => { expect(tokens).toContain("B"); }); }); + +// ============================================================================ +// DM Message Handling Tests +// ============================================================================ + +describe("DM message handling", () => { + const secret = "test-signing-secret"; + + it("DM messages use per-message threadTs (not empty)", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "event_callback", + team_id: "T123", + event: { + type: "message", + user: "U_USER", + channel: "D_DM_CHAN", + channel_type: "im", + text: "hello from DM", + ts: "1234567890.111111", + }, + }); + const request = createWebhookRequest(body, secret); + await adapter.handleWebhook(request); + + expect(chatInstance.processMessage).toHaveBeenCalledWith( + adapter, + "slack:D_DM_CHAN:1234567890.111111", + expect.any(Function), + undefined, + ); + }); + + it("DM thread replies use parent thread_ts", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "event_callback", + team_id: "T123", + event: { + type: "message", + user: "U_USER", + channel: "D_DM_CHAN", + channel_type: "im", + text: "reply in DM thread", + ts: "1234567890.222222", + thread_ts: "1234567890.111111", + }, + }); + const request = createWebhookRequest(body, secret); + await adapter.handleWebhook(request); + + expect(chatInstance.processMessage).toHaveBeenCalledWith( + adapter, + "slack:D_DM_CHAN:1234567890.111111", + expect.any(Function), + undefined, + ); + }); + + it("DM messages have isMention set to true", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + // Capture the factory function to invoke it + chatInstance.processMessage = vi.fn(); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "event_callback", + team_id: "T123", + event: { + type: "message", + user: "U_USER", + channel: "D_DM_CHAN", + channel_type: "im", + text: "hello from DM", + ts: "1234567890.333333", + }, + }); + const request = createWebhookRequest(body, secret); + await adapter.handleWebhook(request); + + // Get the factory function passed to processMessage + const factory = (chatInstance.processMessage as ReturnType) + .mock.calls[0][2]; + const message = await factory(); + expect(message.isMention).toBe(true); + }); + + it("channel messages do NOT have isMention auto-set", async () => { + const state = createMockState(); + const chatInstance = createMockChatInstance(state); + chatInstance.processMessage = vi.fn(); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + logger: mockLogger, + }); + await adapter.initialize(chatInstance); + + const body = JSON.stringify({ + type: "event_callback", + team_id: "T123", + event: { + type: "message", + user: "U_USER", + channel: "C_CHANNEL", + text: "hello from channel", + ts: "1234567890.444444", + }, + }); + const request = createWebhookRequest(body, secret); + await adapter.handleWebhook(request); + + const factory = (chatInstance.processMessage as ReturnType) + .mock.calls[0][2]; + const message = await factory(); + expect(message.isMention).toBeUndefined(); + }); +}); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 0e738d0ba..35fde4188 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -950,11 +950,10 @@ export class SlackAdapter implements Adapter { return; } - // For DMs (channel_type: "im"), use empty threadTs so all messages in the DM - // match the DM subscription created by openDM(). This treats the entire DM - // conversation as a single "thread" for subscription purposes. + // For DMs, each top-level message starts a new thread (same model as channels). + // Thread replies use thread_ts to stay in the same conversation. const isDM = event.channel_type === "im"; - const threadTs = isDM ? "" : event.thread_ts || event.ts; + const threadTs = event.thread_ts || event.ts; const threadId = this.encodeThreadId({ channel: event.channel, threadTs, @@ -965,7 +964,12 @@ export class SlackAdapter implements Adapter { this.chat.processMessage( this, threadId, - () => this.parseSlackMessage(event, threadId), + async () => { + const msg = await this.parseSlackMessage(event, threadId); + // In DMs, all messages are implicitly directed at the bot + if (isDM) msg.isMention = true; + return msg; + }, options, ); } From f607a5c3382626a45e1297c7db8d5325203bdc8a Mon Sep 17 00:00:00 2001 From: John Pham Date: Sat, 14 Feb 2026 17:13:06 -0800 Subject: [PATCH 2/6] fix: use empty threadTs for top-level DMs, thread_ts for replies Top-level DM messages use empty threadTs to match openDM() subscriptions. DM thread replies use thread_ts for per-conversation isolation. Fixes integration test replay-dm.test.ts. --- packages/adapter-slack/src/index.test.ts | 4 ++-- packages/adapter-slack/src/index.ts | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 48342e784..507c4d930 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -1281,7 +1281,7 @@ describe("withBotToken", () => { describe("DM message handling", () => { const secret = "test-signing-secret"; - it("DM messages use per-message threadTs (not empty)", async () => { + it("top-level DM messages use empty threadTs (matches openDM subscriptions)", async () => { const state = createMockState(); const chatInstance = createMockChatInstance(state); const adapter = createSlackAdapter({ @@ -1308,7 +1308,7 @@ describe("DM message handling", () => { expect(chatInstance.processMessage).toHaveBeenCalledWith( adapter, - "slack:D_DM_CHAN:1234567890.111111", + "slack:D_DM_CHAN:", expect.any(Function), undefined, ); diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 35fde4188..f13065ff4 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -950,10 +950,13 @@ export class SlackAdapter implements Adapter { return; } - // For DMs, each top-level message starts a new thread (same model as channels). - // Thread replies use thread_ts to stay in the same conversation. + // For DMs: top-level messages use empty threadTs (matches openDM subscriptions), + // thread replies use thread_ts for per-conversation isolation. + // For channels: always use thread_ts or ts for per-thread IDs. const isDM = event.channel_type === "im"; - const threadTs = event.thread_ts || event.ts; + const threadTs = isDM + ? (event.thread_ts || "") + : (event.thread_ts || event.ts); const threadId = this.encodeThreadId({ channel: event.channel, threadTs, From ed42a36e3cd92f4dd0228791daf4e249f92ba937 Mon Sep 17 00:00:00 2001 From: John Pham Date: Sat, 14 Feb 2026 17:15:20 -0800 Subject: [PATCH 3/6] style: fix biome formatting --- packages/adapter-slack/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index f13065ff4..08e2c7e22 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -954,9 +954,7 @@ export class SlackAdapter implements Adapter { // thread replies use thread_ts for per-conversation isolation. // For channels: always use thread_ts or ts for per-thread IDs. const isDM = event.channel_type === "im"; - const threadTs = isDM - ? (event.thread_ts || "") - : (event.thread_ts || event.ts); + const threadTs = isDM ? event.thread_ts || "" : event.thread_ts || event.ts; const threadId = this.encodeThreadId({ channel: event.channel, threadTs, From 857bbd7fedcd4012a3405dc60a5859b8fe080548 Mon Sep 17 00:00:00 2001 From: John Pham Date: Wed, 18 Feb 2026 10:46:04 -0800 Subject: [PATCH 4/6] test: add e2e replay tests for direct DM implicit mention --- .../integration-tests/src/replay-dm.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/packages/integration-tests/src/replay-dm.test.ts b/packages/integration-tests/src/replay-dm.test.ts index 183a14ecf..335057d72 100644 --- a/packages/integration-tests/src/replay-dm.test.ts +++ b/packages/integration-tests/src/replay-dm.test.ts @@ -235,6 +235,140 @@ describe("DM Replay Tests", () => { }); }); + describe("Slack - Direct DM (implicit mention)", () => { + let chat: Chat<{ slack: SlackAdapter }>; + let mockSlackClient: MockSlackClient; + let tracker: ReturnType; + let state: DMFlowState; + + beforeEach(() => { + vi.clearAllMocks(); + state = createDMFlowState(); + + const slackAdapter = createSlackAdapter({ + botToken: SLACK_BOT_TOKEN, + signingSecret: SLACK_SIGNING_SECRET, + logger: mockLogger, + }); + mockSlackClient = createMockSlackClient(); + mockSlackClient.auth.test.mockResolvedValue({ + ok: true, + user_id: slackFixtures.botUserId, + user: slackFixtures.botName, + }); + injectMockSlackClient(slackAdapter, mockSlackClient); + + chat = new Chat({ + userName: slackFixtures.botName, + adapters: { slack: slackAdapter }, + state: createMemoryState(), + logger: "error", + }); + + chat.onNewMention(async (thread, message) => { + state.mentionMessage = message; + await thread.subscribe(); + await thread.post(`Hi! You said: ${message.text}`); + }); + + chat.onSubscribedMessage(async (thread, message) => { + state.dmMessage = message; + await thread.post(`Follow-up: ${message.text}`); + }); + + tracker = createWaitUntilTracker(); + }); + + afterEach(async () => { + await chat.shutdown(); + }); + + const sendWebhook = async (fixture: unknown) => { + await chat.webhooks.slack( + createSignedSlackRequest(JSON.stringify(fixture)), + { waitUntil: tracker.waitUntil }, + ); + await tracker.waitForAll(); + }; + + it("should treat direct DM as mention (no prior channel interaction)", async () => { + // Send a DM directly — no channel mention or openDM flow needed + await sendWebhook(slackFixtures.dmMessage); + + // DM messages have isMention=true, so onNewMention fires + expect(state.mentionMessage).not.toBeNull(); + expect(state.mentionMessage?.text).toBe("Hey!"); + }); + + it("should use empty threadTs for top-level DM messages", async () => { + await sendWebhook(slackFixtures.dmMessage); + + expect(state.mentionMessage).not.toBeNull(); + // Top-level DM → threadId is "slack::" with empty threadTs + expect(state.mentionMessage?.threadId).toBe( + `slack:${slackFixtures.dmChannelId}:`, + ); + }); + + it("should respond to direct DM", async () => { + await sendWebhook(slackFixtures.dmMessage); + + expect(mockSlackClient.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: slackFixtures.dmChannelId, + text: expect.stringContaining("Hi! You said: Hey!"), + }), + ); + }); + + it("should receive follow-up DM as subscribed message", async () => { + // First DM triggers onNewMention and subscribes + await sendWebhook(slackFixtures.dmMessage); + expect(state.mentionMessage).not.toBeNull(); + + // Second DM in same thread triggers onSubscribedMessage + const followUp = { + ...slackFixtures.dmMessage, + event: { + ...slackFixtures.dmMessage.event, + ts: "1767377010.000000", + text: "Another message", + }, + }; + await sendWebhook(followUp); + + expect(state.dmMessage).not.toBeNull(); + expect(state.dmMessage?.text).toBe("Another message"); + + expect(mockSlackClient.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channel: slackFixtures.dmChannelId, + text: expect.stringContaining("Follow-up: Another message"), + }), + ); + }); + + it("should use thread_ts for DM thread replies", async () => { + // A DM reply with thread_ts should use that as the threadTs + const dmReply = { + ...slackFixtures.dmMessage, + event: { + ...slackFixtures.dmMessage.event, + ts: "1767377010.000000", + thread_ts: slackFixtures.dmMessage.event.ts, + text: "Threaded reply", + }, + }; + await sendWebhook(dmReply); + + expect(state.mentionMessage).not.toBeNull(); + // DM reply with thread_ts → threadId includes the parent ts + expect(state.mentionMessage?.threadId).toBe( + `slack:${slackFixtures.dmChannelId}:${slackFixtures.dmMessage.event.ts}`, + ); + }); + }); + describe("Google Chat", () => { let chat: Chat<{ gchat: GoogleChatAdapter }>; let gchatAdapter: GoogleChatAdapter; From adf66b82eec75ac7f07896edbc91c33c2903d6e2 Mon Sep 17 00:00:00 2001 From: John Pham Date: Wed, 18 Feb 2026 10:50:30 -0800 Subject: [PATCH 5/6] style: fix extra blank line lint error --- packages/adapter-slack/src/index.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index cf8298b94..332fdcaad 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -1284,7 +1284,6 @@ describe("withBotToken", () => { }); }); - // ============================================================================ // DM Message Handling Tests // ============================================================================ From e2b6e04e7b71a37161ba55dfb746b269c137ef79 Mon Sep 17 00:00:00 2001 From: John Pham Date: Wed, 18 Feb 2026 11:24:07 -0800 Subject: [PATCH 6/6] test: use real recorded DM fixtures for e2e tests --- .../fixtures/replay/dm/slack-direct.json | 91 +++++++++++++++++++ .../integration-tests/src/replay-dm.test.ts | 54 +++++------ 2 files changed, 113 insertions(+), 32 deletions(-) create mode 100644 packages/integration-tests/fixtures/replay/dm/slack-direct.json diff --git a/packages/integration-tests/fixtures/replay/dm/slack-direct.json b/packages/integration-tests/fixtures/replay/dm/slack-direct.json new file mode 100644 index 000000000..f723ab4a4 --- /dev/null +++ b/packages/integration-tests/fixtures/replay/dm/slack-direct.json @@ -0,0 +1,91 @@ +{ + "botName": "Chat SDK Bot", + "botUserId": "U0ADXR1N57A", + "dmChannelId": "D0ACX51K95H", + "directDM": { + "token": "AtFTDngNDE7k3a6rriShF4bJ", + "team_id": "T0ADGE2G4EM", + "context_team_id": "T0ADGE2G4EM", + "context_enterprise_id": null, + "api_app_id": "A0ACN407A3Z", + "event": { + "type": "message", + "user": "U0ADXQT6CRW", + "ts": "1771442483.260129", + "client_msg_id": "7f846ada-9111-41d4-983b-e5eda731bc0c", + "text": "hello hello", + "team": "T0ADGE2G4EM", + "blocks": [ + { + "type": "rich_text", + "block_id": "yrdrR", + "elements": [ + { + "type": "rich_text_section", + "elements": [{ "type": "text", "text": "hello hello" }] + } + ] + } + ], + "channel": "D0ACX51K95H", + "event_ts": "1771442483.260129", + "channel_type": "im" + }, + "type": "event_callback", + "event_id": "Ev0AFKSS1NKD", + "event_time": 1771442483, + "authorizations": [ + { + "enterprise_id": null, + "team_id": "T0ADGE2G4EM", + "user_id": "U0ADXR1N57A", + "is_bot": true, + "is_enterprise_install": false + } + ], + "is_ext_shared_channel": false + }, + "followUp": { + "token": "AtFTDngNDE7k3a6rriShF4bJ", + "team_id": "T0ADGE2G4EM", + "context_team_id": "T0ADGE2G4EM", + "context_enterprise_id": null, + "api_app_id": "A0ACN407A3Z", + "event": { + "type": "message", + "user": "U0ADXQT6CRW", + "ts": "1771442498.377609", + "client_msg_id": "2630505d-ec8a-4b79-98fc-7d1dac059223", + "text": "cool!!", + "team": "T0ADGE2G4EM", + "blocks": [ + { + "type": "rich_text", + "block_id": "h4YYM", + "elements": [ + { + "type": "rich_text_section", + "elements": [{ "type": "text", "text": "cool!!" }] + } + ] + } + ], + "channel": "D0ACX51K95H", + "event_ts": "1771442498.377609", + "channel_type": "im" + }, + "type": "event_callback", + "event_id": "Ev0AFVSPHSLU", + "event_time": 1771442498, + "authorizations": [ + { + "enterprise_id": null, + "team_id": "T0ADGE2G4EM", + "user_id": "U0ADXR1N57A", + "is_bot": true, + "is_enterprise_install": false + } + ], + "is_ext_shared_channel": false + } +} diff --git a/packages/integration-tests/src/replay-dm.test.ts b/packages/integration-tests/src/replay-dm.test.ts index 335057d72..cdd7b0b60 100644 --- a/packages/integration-tests/src/replay-dm.test.ts +++ b/packages/integration-tests/src/replay-dm.test.ts @@ -21,6 +21,7 @@ import { Chat, type Logger, type Message } from "chat"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import gchatFixtures from "../fixtures/replay/dm/gchat.json"; import slackFixtures from "../fixtures/replay/dm/slack.json"; +import slackDirectFixtures from "../fixtures/replay/dm/slack-direct.json"; import teamsFixtures from "../fixtures/replay/dm/teams.json"; import { createMockGoogleChatApi, @@ -253,13 +254,13 @@ describe("DM Replay Tests", () => { mockSlackClient = createMockSlackClient(); mockSlackClient.auth.test.mockResolvedValue({ ok: true, - user_id: slackFixtures.botUserId, - user: slackFixtures.botName, + user_id: slackDirectFixtures.botUserId, + user: slackDirectFixtures.botName, }); injectMockSlackClient(slackAdapter, mockSlackClient); chat = new Chat({ - userName: slackFixtures.botName, + userName: slackDirectFixtures.botName, adapters: { slack: slackAdapter }, state: createMemoryState(), logger: "error", @@ -292,71 +293,60 @@ describe("DM Replay Tests", () => { }; it("should treat direct DM as mention (no prior channel interaction)", async () => { - // Send a DM directly — no channel mention or openDM flow needed - await sendWebhook(slackFixtures.dmMessage); + await sendWebhook(slackDirectFixtures.directDM); // DM messages have isMention=true, so onNewMention fires expect(state.mentionMessage).not.toBeNull(); - expect(state.mentionMessage?.text).toBe("Hey!"); + expect(state.mentionMessage?.text).toBe("hello hello"); }); it("should use empty threadTs for top-level DM messages", async () => { - await sendWebhook(slackFixtures.dmMessage); + await sendWebhook(slackDirectFixtures.directDM); expect(state.mentionMessage).not.toBeNull(); // Top-level DM → threadId is "slack::" with empty threadTs expect(state.mentionMessage?.threadId).toBe( - `slack:${slackFixtures.dmChannelId}:`, + `slack:${slackDirectFixtures.dmChannelId}:`, ); }); it("should respond to direct DM", async () => { - await sendWebhook(slackFixtures.dmMessage); + await sendWebhook(slackDirectFixtures.directDM); expect(mockSlackClient.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ - channel: slackFixtures.dmChannelId, - text: expect.stringContaining("Hi! You said: Hey!"), + channel: slackDirectFixtures.dmChannelId, + text: expect.stringContaining("Hi! You said: hello hello"), }), ); }); it("should receive follow-up DM as subscribed message", async () => { // First DM triggers onNewMention and subscribes - await sendWebhook(slackFixtures.dmMessage); + await sendWebhook(slackDirectFixtures.directDM); expect(state.mentionMessage).not.toBeNull(); - // Second DM in same thread triggers onSubscribedMessage - const followUp = { - ...slackFixtures.dmMessage, - event: { - ...slackFixtures.dmMessage.event, - ts: "1767377010.000000", - text: "Another message", - }, - }; - await sendWebhook(followUp); + // Second DM (real recorded follow-up) triggers onSubscribedMessage + await sendWebhook(slackDirectFixtures.followUp); expect(state.dmMessage).not.toBeNull(); - expect(state.dmMessage?.text).toBe("Another message"); + expect(state.dmMessage?.text).toBe("cool!!"); expect(mockSlackClient.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ - channel: slackFixtures.dmChannelId, - text: expect.stringContaining("Follow-up: Another message"), + channel: slackDirectFixtures.dmChannelId, + text: expect.stringContaining("Follow-up: cool!!"), }), ); }); it("should use thread_ts for DM thread replies", async () => { - // A DM reply with thread_ts should use that as the threadTs + // Construct a DM reply with thread_ts pointing to the first message const dmReply = { - ...slackFixtures.dmMessage, + ...slackDirectFixtures.followUp, event: { - ...slackFixtures.dmMessage.event, - ts: "1767377010.000000", - thread_ts: slackFixtures.dmMessage.event.ts, - text: "Threaded reply", + ...slackDirectFixtures.followUp.event, + thread_ts: slackDirectFixtures.directDM.event.ts, }, }; await sendWebhook(dmReply); @@ -364,7 +354,7 @@ describe("DM Replay Tests", () => { expect(state.mentionMessage).not.toBeNull(); // DM reply with thread_ts → threadId includes the parent ts expect(state.mentionMessage?.threadId).toBe( - `slack:${slackFixtures.dmChannelId}:${slackFixtures.dmMessage.event.ts}`, + `slack:${slackDirectFixtures.dmChannelId}:${slackDirectFixtures.directDM.event.ts}`, ); }); });