From 45c1ccdfcfc21f742d30b99becc80046fc9d0728 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 20:01:12 +0000 Subject: [PATCH] refactor: unify threading contexts --- extensions/slack/src/channel.ts | 14 +- .../reply/agent-runner-utils.test.ts | 17 ++ src/auto-reply/reply/agent-runner-utils.ts | 13 +- src/channels/dock.ts | 57 +++--- src/channels/plugins/types.core.ts | 2 + src/plugin-sdk/index.ts | 1 + src/slack/monitor.test-helpers.ts | 145 +++++++++++++++ ...es-thread-replies-replytoid-is-set.test.ts | 164 +++-------------- ...ends-tool-summaries-responseprefix.test.ts | 174 +++--------------- ...p-level-replies-replytomode-is-all.test.ts | 166 +++-------------- .../prepare.inbound-contract.test.ts | 68 +++++++ src/slack/monitor/message-handler/prepare.ts | 13 +- src/slack/threading-tool-context.ts | 29 +++ src/slack/threading.test.ts | 33 +++- src/slack/threading.ts | 37 +++- 15 files changed, 452 insertions(+), 481 deletions(-) create mode 100644 src/slack/monitor.test-helpers.ts create mode 100644 src/slack/threading-tool-context.ts diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 7aec49daf..9e17ca8bd 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -20,6 +20,7 @@ import { resolveDefaultSlackAccountId, resolveSlackAccount, resolveSlackGroupRequireMention, + buildSlackThreadingToolContext, setAccountEnabledInConfigSection, slackOnboardingAdapter, SlackConfigSchema, @@ -164,18 +165,7 @@ export const slackPlugin: ChannelPlugin = { resolveReplyToMode: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", allowTagsWhenOff: true, - buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => { - const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off"; - const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode; - return { - currentChannelId: context.To?.startsWith("channel:") - ? context.To.slice("channel:".length) - : undefined, - currentThreadTs: context.ReplyToId, - replyToMode: effectiveReplyToMode, - hasRepliedRef, - }; - }, + buildToolContext: (params) => buildSlackThreadingToolContext(params), }, messaging: { normalizeTarget: normalizeSlackMessagingTarget, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index d9a5b5446..1d7d38bbc 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -87,4 +87,21 @@ describe("buildThreadingToolContext", () => { expect(result.currentChannelId).toBe("chat_id:7"); }); + + it("prefers MessageThreadId for Slack tool threading", () => { + const sessionCtx = { + Provider: "slack", + To: "channel:C1", + MessageThreadId: "123.456", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: { channels: { slack: { replyToMode: "all" } } } as ClawdbotConfig, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("C1"); + expect(result.currentThreadTs).toBe("123.456"); + }); }); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index b1b3ae890..0481ce74e 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -24,18 +24,11 @@ export function buildThreadingToolContext(params: { const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); if (!rawProvider) return {}; const provider = normalizeChannelId(rawProvider); - // WhatsApp context isolation keys off conversation id, not the bot's own number. - const threadingTo = - rawProvider === "whatsapp" - ? (sessionCtx.From ?? sessionCtx.To) - : rawProvider === "imessage" && sessionCtx.ChatType === "direct" - ? (sessionCtx.From ?? sessionCtx.To) - : sessionCtx.To; // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init) const dock = provider ? getChannelDock(provider) : undefined; if (!dock?.threading?.buildToolContext) { return { - currentChannelId: threadingTo?.trim() || undefined, + currentChannelId: sessionCtx.To?.trim() || undefined, currentChannelProvider: provider ?? (rawProvider as ChannelId), hasRepliedRef, }; @@ -46,7 +39,9 @@ export function buildThreadingToolContext(params: { accountId: sessionCtx.AccountId, context: { Channel: sessionCtx.Provider, - To: threadingTo, + From: sessionCtx.From, + To: sessionCtx.To, + ChatType: sessionCtx.ChatType, ReplyToId: sessionCtx.ReplyToId, ThreadLabel: sessionCtx.ThreadLabel, MessageThreadId: sessionCtx.MessageThreadId, diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 92199a0f2..45bca496f 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -3,6 +3,7 @@ import { resolveDiscordAccount } from "../discord/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js"; import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSlackAccount } from "../slack/accounts.js"; +import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { resolveTelegramAccount } from "../telegram/accounts.js"; import { normalizeE164 } from "../utils.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; @@ -150,11 +151,14 @@ const DOCKS: Record = { }, }, threading: { - buildToolContext: ({ context, hasRepliedRef }) => ({ - currentChannelId: context.To?.trim() || undefined, - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }), + buildToolContext: ({ context, hasRepliedRef }) => { + const channelId = context.From?.trim() || context.To?.trim() || undefined; + return { + currentChannelId: channelId, + currentThreadTs: context.ReplyToId, + hasRepliedRef, + }; + }, }, }, discord: { @@ -221,18 +225,7 @@ const DOCKS: Record = { resolveReplyToMode: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off", allowTagsWhenOff: true, - buildToolContext: ({ cfg, accountId, context, hasRepliedRef }) => { - const configuredReplyToMode = resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off"; - const effectiveReplyToMode = context.ThreadLabel ? "all" : configuredReplyToMode; - return { - currentChannelId: context.To?.startsWith("channel:") - ? context.To.slice("channel:".length) - : undefined, - currentThreadTs: context.ReplyToId, - replyToMode: effectiveReplyToMode, - hasRepliedRef, - }; - }, + buildToolContext: (params) => buildSlackThreadingToolContext(params), }, }, signal: { @@ -259,11 +252,16 @@ const DOCKS: Record = { .filter(Boolean), }, threading: { - buildToolContext: ({ context, hasRepliedRef }) => ({ - currentChannelId: context.To?.trim() || undefined, - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }), + buildToolContext: ({ context, hasRepliedRef }) => { + const isDirect = context.ChatType?.toLowerCase() === "direct"; + const channelId = + (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined; + return { + currentChannelId: channelId, + currentThreadTs: context.ReplyToId, + hasRepliedRef, + }; + }, }, }, imessage: { @@ -286,11 +284,16 @@ const DOCKS: Record = { resolveRequireMention: resolveIMessageGroupRequireMention, }, threading: { - buildToolContext: ({ context, hasRepliedRef }) => ({ - currentChannelId: context.To?.trim() || undefined, - currentThreadTs: context.ReplyToId, - hasRepliedRef, - }), + buildToolContext: ({ context, hasRepliedRef }) => { + const isDirect = context.ChatType?.toLowerCase() === "direct"; + const channelId = + (isDirect ? (context.From ?? context.To) : context.To)?.trim() || undefined; + return { + currentChannelId: channelId, + currentThreadTs: context.ReplyToId, + hasRepliedRef, + }; + }, }, }, }; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index dced4e14c..af3707712 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -210,7 +210,9 @@ export type ChannelThreadingAdapter = { export type ChannelThreadingContext = { Channel?: string; + From?: string; To?: string; + ChatType?: string; ReplyToId?: string; ReplyToIdFull?: string; ThreadLabel?: string; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 8bef1da37..1afc00aa4 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -235,6 +235,7 @@ export { looksLikeSlackTargetId, normalizeSlackMessagingTarget, } from "../channels/plugins/normalize/slack.js"; +export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; // Channel: Telegram export { diff --git a/src/slack/monitor.test-helpers.ts b/src/slack/monitor.test-helpers.ts new file mode 100644 index 000000000..d1159839e --- /dev/null +++ b/src/slack/monitor.test-helpers.ts @@ -0,0 +1,145 @@ +import { vi } from "vitest"; + +type SlackHandler = (args: unknown) => Promise; + +const slackTestState = vi.hoisted(() => ({ + config: {} as Record, + sendMock: vi.fn(), + replyMock: vi.fn(), + updateLastRouteMock: vi.fn(), + reactMock: vi.fn(), + readAllowFromStoreMock: vi.fn(), + upsertPairingRequestMock: vi.fn(), +})); + +export const getSlackTestState = () => slackTestState; + +export const getSlackHandlers = () => + ( + globalThis as { + __slackHandlers?: Map; + } + ).__slackHandlers; + +export const getSlackClient = () => + (globalThis as { __slackClient?: Record }).__slackClient; + +export const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +export async function waitForSlackEvent(name: string) { + for (let i = 0; i < 10; i += 1) { + if (getSlackHandlers()?.has(name)) return; + await flush(); + } +} + +export const defaultSlackTestConfig = () => ({ + messages: { + responsePrefix: "PFX", + ackReaction: "👀", + ackReactionScope: "group-mentions", + }, + channels: { + slack: { + dm: { enabled: true, policy: "open", allowFrom: ["*"] }, + groupPolicy: "open", + }, + }, +}); + +export function resetSlackTestState(config: Record = defaultSlackTestConfig()) { + slackTestState.config = config; + slackTestState.sendMock.mockReset().mockResolvedValue(undefined); + slackTestState.replyMock.mockReset(); + slackTestState.updateLastRouteMock.mockReset(); + slackTestState.reactMock.mockReset(); + slackTestState.readAllowFromStoreMock.mockReset().mockResolvedValue([]); + slackTestState.upsertPairingRequestMock.mockReset().mockResolvedValue({ + code: "PAIRCODE", + created: true, + }); + getSlackHandlers()?.clear(); +} + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => slackTestState.config, + }; +}); + +vi.mock("../auto-reply/reply.js", () => ({ + getReplyFromConfig: (...args: unknown[]) => slackTestState.replyMock(...args), +})); + +vi.mock("./resolve-channels.js", () => ({ + resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) => + entries.map((input) => ({ input, resolved: false })), +})); + +vi.mock("./resolve-users.js", () => ({ + resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) => + entries.map((input) => ({ input, resolved: false })), +})); + +vi.mock("./send.js", () => ({ + sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args), +})); + +vi.mock("../pairing/pairing-store.js", () => ({ + readChannelAllowFromStore: (...args: unknown[]) => slackTestState.readAllowFromStoreMock(...args), + upsertChannelPairingRequest: (...args: unknown[]) => + slackTestState.upsertPairingRequestMock(...args), +})); + +vi.mock("../config/sessions.js", () => ({ + resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), + updateLastRoute: (...args: unknown[]) => slackTestState.updateLastRouteMock(...args), + resolveSessionKey: vi.fn(), + readSessionUpdatedAt: vi.fn(() => undefined), + recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@slack/bolt", () => { + const handlers = new Map(); + (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; + const client = { + auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, + conversations: { + info: vi.fn().mockResolvedValue({ + channel: { name: "dm", is_im: true }, + }), + replies: vi.fn().mockResolvedValue({ messages: [] }), + }, + users: { + info: vi.fn().mockResolvedValue({ + user: { profile: { display_name: "Ada" } }, + }), + }, + assistant: { + threads: { + setStatus: vi.fn().mockResolvedValue({ ok: true }), + }, + }, + reactions: { + add: (...args: unknown[]) => slackTestState.reactMock(...args), + }, + }; + (globalThis as { __slackClient?: typeof client }).__slackClient = client; + class App { + client = client; + event(name: string, handler: SlackHandler) { + handlers.set(name, handler); + } + command() { + /* no-op */ + } + start = vi.fn().mockResolvedValue(undefined); + stop = vi.fn().mockResolvedValue(undefined); + } + class HTTPReceiver { + requestListener = vi.fn(); + } + return { App, HTTPReceiver, default: { App, HTTPReceiver } }; +}); diff --git a/src/slack/monitor.tool-result.forces-thread-replies-replytoid-is-set.test.ts b/src/slack/monitor.tool-result.forces-thread-replies-replytoid-is-set.test.ts index 5ecb206f0..c4b4b96ed 100644 --- a/src/slack/monitor.tool-result.forces-thread-replies-replytoid-is-set.test.ts +++ b/src/slack/monitor.tool-result.forces-thread-replies-replytoid-is-set.test.ts @@ -1,143 +1,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { + defaultSlackTestConfig, + flush, + getSlackClient, + getSlackHandlers, + getSlackTestState, + resetSlackTestState, + waitForSlackEvent, +} from "./monitor.test-helpers.js"; import { monitorSlackProvider } from "./monitor.js"; -const sendMock = vi.fn(); -const replyMock = vi.fn(); -const updateLastRouteMock = vi.fn(); -const reactMock = vi.fn(); -let config: Record = {}; -const readAllowFromStoreMock = vi.fn(); -const upsertPairingRequestMock = vi.fn(); -const getSlackHandlers = () => - ( - globalThis as { - __slackHandlers?: Map Promise>; - } - ).__slackHandlers; -const getSlackClient = () => - (globalThis as { __slackClient?: Record }).__slackClient; - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => config, - }; -}); - -vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig: (...args: unknown[]) => replyMock(...args), -})); - -vi.mock("./resolve-channels.js", () => ({ - resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) => - entries.map((input) => ({ input, resolved: false })), -})); - -vi.mock("./resolve-users.js", () => ({ - resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) => - entries.map((input) => ({ input, resolved: false })), -})); - -vi.mock("./send.js", () => ({ - sendMessageSlack: (...args: unknown[]) => sendMock(...args), -})); - -vi.mock("../pairing/pairing-store.js", () => ({ - readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), -})); - -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - resolveSessionKey: vi.fn(), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@slack/bolt", () => { - const handlers = new Map Promise>(); - (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; - const client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "dm", is_im: true }, - }), - replies: vi.fn().mockResolvedValue({ messages: [] }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - assistant: { - threads: { - setStatus: vi.fn().mockResolvedValue({ ok: true }), - }, - }, - reactions: { - add: (...args: unknown[]) => reactMock(...args), - }, - }; - (globalThis as { __slackClient?: typeof client }).__slackClient = client; - class App { - client = client; - event(name: string, handler: (args: unknown) => Promise) { - handlers.set(name, handler); - } - command() { - /* no-op */ - } - start = vi.fn().mockResolvedValue(undefined); - stop = vi.fn().mockResolvedValue(undefined); - } - class HTTPReceiver { - requestListener = vi.fn(); - } - return { App, HTTPReceiver, default: { App, HTTPReceiver } }; -}); - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -async function waitForEvent(name: string) { - for (let i = 0; i < 10; i += 1) { - if (getSlackHandlers()?.has(name)) return; - await flush(); - } -} +const slackTestState = getSlackTestState(); +const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState; beforeEach(() => { resetInboundDedupe(); - getSlackHandlers()?.clear(); - config = { - messages: { - responsePrefix: "PFX", - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - groupPolicy: "open", - }, - }, - }; - sendMock.mockReset().mockResolvedValue(undefined); - replyMock.mockReset(); - updateLastRouteMock.mockReset(); - reactMock.mockReset(); - readAllowFromStoreMock.mockReset().mockResolvedValue([]); - upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); + resetSlackTestState(defaultSlackTestConfig()); }); describe("monitorSlackProvider tool results", () => { it("forces thread replies when replyToId is set", async () => { replyMock.mockResolvedValue({ text: "forced reply", replyToId: "555" }); - config = { + slackTestState.config = { messages: { responsePrefix: "PFX", ackReaction: "👀", @@ -158,7 +44,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -199,7 +85,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -226,12 +112,12 @@ describe("monitorSlackProvider tool results", () => { }); it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => { - config = { - ...config, + slackTestState.config = { + ...slackTestState.config, channels: { - ...config.channels, + ...slackTestState.config.channels, slack: { - ...config.channels?.slack, + ...slackTestState.config.channels?.slack, dm: { enabled: true, policy: "pairing", allowFrom: [] }, }, }, @@ -244,7 +130,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -271,12 +157,12 @@ describe("monitorSlackProvider tool results", () => { }); it("does not resend pairing code when a request is already pending", async () => { - config = { - ...config, + slackTestState.config = { + ...slackTestState.config, channels: { - ...config.channels, + ...slackTestState.config.channels, slack: { - ...config.channels?.slack, + ...slackTestState.config.channels?.slack, dm: { enabled: true, policy: "pairing", allowFrom: [] }, }, }, @@ -292,7 +178,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); diff --git a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index 67f72e2e1..7f5ffa03c 100644 --- a/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/src/slack/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -3,137 +3,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; +import { + defaultSlackTestConfig, + flush, + getSlackTestState, + getSlackClient, + getSlackHandlers, + resetSlackTestState, + waitForSlackEvent, +} from "./monitor.test-helpers.js"; import { monitorSlackProvider } from "./monitor.js"; -const sendMock = vi.fn(); -const replyMock = vi.fn(); -const updateLastRouteMock = vi.fn(); -const reactMock = vi.fn(); -let config: Record = {}; -const readAllowFromStoreMock = vi.fn(); -const upsertPairingRequestMock = vi.fn(); -const getSlackHandlers = () => - ( - globalThis as { - __slackHandlers?: Map Promise>; - } - ).__slackHandlers; -const getSlackClient = () => - (globalThis as { __slackClient?: Record }).__slackClient; - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => config, - }; -}); - -vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig: (...args: unknown[]) => replyMock(...args), -})); - -vi.mock("./resolve-channels.js", () => ({ - resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) => - entries.map((input) => ({ input, resolved: false })), -})); - -vi.mock("./resolve-users.js", () => ({ - resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) => - entries.map((input) => ({ input, resolved: false })), -})); - -vi.mock("./send.js", () => ({ - sendMessageSlack: (...args: unknown[]) => sendMock(...args), -})); - -vi.mock("../pairing/pairing-store.js", () => ({ - readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), -})); - -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - resolveSessionKey: vi.fn(), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@slack/bolt", () => { - const handlers = new Map Promise>(); - (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; - const client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "dm", is_im: true }, - }), - replies: vi.fn().mockResolvedValue({ messages: [] }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - assistant: { - threads: { - setStatus: vi.fn().mockResolvedValue({ ok: true }), - }, - }, - reactions: { - add: (...args: unknown[]) => reactMock(...args), - }, - }; - (globalThis as { __slackClient?: typeof client }).__slackClient = client; - class App { - client = client; - event(name: string, handler: (args: unknown) => Promise) { - handlers.set(name, handler); - } - command() { - /* no-op */ - } - start = vi.fn().mockResolvedValue(undefined); - stop = vi.fn().mockResolvedValue(undefined); - } - class HTTPReceiver { - requestListener = vi.fn(); - } - return { App, HTTPReceiver, default: { App, HTTPReceiver } }; -}); - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -async function waitForEvent(name: string) { - for (let i = 0; i < 10; i += 1) { - if (getSlackHandlers()?.has(name)) return; - await flush(); - } -} +const slackTestState = getSlackTestState(); +const { sendMock, replyMock } = slackTestState; beforeEach(() => { resetInboundDedupe(); - getSlackHandlers()?.clear(); - config = { - messages: { - responsePrefix: "PFX", - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - groupPolicy: "open", - }, - }, - }; - sendMock.mockReset().mockResolvedValue(undefined); - replyMock.mockReset(); - updateLastRouteMock.mockReset(); - reactMock.mockReset(); - readAllowFromStoreMock.mockReset().mockResolvedValue([]); - upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); + resetSlackTestState(defaultSlackTestConfig()); }); describe("monitorSlackProvider tool results", () => { @@ -150,7 +36,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -190,7 +76,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -215,7 +101,7 @@ describe("monitorSlackProvider tool results", () => { }); it("does not derive responsePrefix from routed agent identity when unset", async () => { - config = { + slackTestState.config = { agents: { list: [ { @@ -256,7 +142,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -281,7 +167,7 @@ describe("monitorSlackProvider tool results", () => { }); it("preserves RawBody without injecting processed room history", async () => { - config = { + slackTestState.config = { messages: { ackReactionScope: "group-mentions" }, channels: { slack: { @@ -305,7 +191,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -344,7 +230,7 @@ describe("monitorSlackProvider tool results", () => { }); it("scopes thread history to the thread by default", async () => { - config = { + slackTestState.config = { messages: { ackReactionScope: "group-mentions" }, channels: { slack: { @@ -368,7 +254,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -431,7 +317,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -470,7 +356,7 @@ describe("monitorSlackProvider tool results", () => { }); it("accepts channel messages when mentionPatterns match", async () => { - config = { + slackTestState.config = { messages: { responsePrefix: "PFX", groupChat: { mentionPatterns: ["\\bclawd\\b"] }, @@ -491,7 +377,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -515,7 +401,7 @@ describe("monitorSlackProvider tool results", () => { }); it("treats replies to bot threads as implicit mentions", async () => { - config = { + slackTestState.config = { channels: { slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] }, @@ -532,7 +418,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -558,7 +444,7 @@ describe("monitorSlackProvider tool results", () => { }); it("accepts channel messages without mention when channels.slack.requireMention is false", async () => { - config = { + slackTestState.config = { channels: { slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] }, @@ -576,7 +462,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -610,7 +496,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -635,7 +521,7 @@ describe("monitorSlackProvider tool results", () => { it("threads replies when incoming message is in a thread", async () => { replyMock.mockResolvedValue({ text: "thread reply" }); - config = { + slackTestState.config = { messages: { responsePrefix: "PFX", ackReaction: "👀", @@ -656,7 +542,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); diff --git a/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts b/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts index 609d576fd..2ace208d9 100644 --- a/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts +++ b/src/slack/monitor.tool-result.threads-top-level-replies-replytomode-is-all.test.ts @@ -1,143 +1,29 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { + defaultSlackTestConfig, + flush, + getSlackClient, + getSlackHandlers, + getSlackTestState, + resetSlackTestState, + waitForSlackEvent, +} from "./monitor.test-helpers.js"; import { monitorSlackProvider } from "./monitor.js"; -const sendMock = vi.fn(); -const replyMock = vi.fn(); -const updateLastRouteMock = vi.fn(); -const reactMock = vi.fn(); -let config: Record = {}; -const readAllowFromStoreMock = vi.fn(); -const upsertPairingRequestMock = vi.fn(); -const getSlackHandlers = () => - ( - globalThis as { - __slackHandlers?: Map Promise>; - } - ).__slackHandlers; -const getSlackClient = () => - (globalThis as { __slackClient?: Record }).__slackClient; - -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => config, - }; -}); - -vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig: (...args: unknown[]) => replyMock(...args), -})); - -vi.mock("./resolve-channels.js", () => ({ - resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) => - entries.map((input) => ({ input, resolved: false })), -})); - -vi.mock("./resolve-users.js", () => ({ - resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) => - entries.map((input) => ({ input, resolved: false })), -})); - -vi.mock("./send.js", () => ({ - sendMessageSlack: (...args: unknown[]) => sendMock(...args), -})); - -vi.mock("../pairing/pairing-store.js", () => ({ - readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), -})); - -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/clawdbot-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - resolveSessionKey: vi.fn(), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@slack/bolt", () => { - const handlers = new Map Promise>(); - (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; - const client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "dm", is_im: true }, - }), - replies: vi.fn().mockResolvedValue({ messages: [] }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - assistant: { - threads: { - setStatus: vi.fn().mockResolvedValue({ ok: true }), - }, - }, - reactions: { - add: (...args: unknown[]) => reactMock(...args), - }, - }; - (globalThis as { __slackClient?: typeof client }).__slackClient = client; - class App { - client = client; - event(name: string, handler: (args: unknown) => Promise) { - handlers.set(name, handler); - } - command() { - /* no-op */ - } - start = vi.fn().mockResolvedValue(undefined); - stop = vi.fn().mockResolvedValue(undefined); - } - class HTTPReceiver { - requestListener = vi.fn(); - } - return { App, HTTPReceiver, default: { App, HTTPReceiver } }; -}); - -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -async function waitForEvent(name: string) { - for (let i = 0; i < 10; i += 1) { - if (getSlackHandlers()?.has(name)) return; - await flush(); - } -} +const slackTestState = getSlackTestState(); +const { sendMock, replyMock } = slackTestState; beforeEach(() => { resetInboundDedupe(); - getSlackHandlers()?.clear(); - config = { - messages: { - responsePrefix: "PFX", - ackReaction: "👀", - ackReactionScope: "group-mentions", - }, - channels: { - slack: { - dm: { enabled: true, policy: "open", allowFrom: ["*"] }, - groupPolicy: "open", - }, - }, - }; - sendMock.mockReset().mockResolvedValue(undefined); - replyMock.mockReset(); - updateLastRouteMock.mockReset(); - reactMock.mockReset(); - readAllowFromStoreMock.mockReset().mockResolvedValue([]); - upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); + resetSlackTestState(defaultSlackTestConfig()); }); describe("monitorSlackProvider tool results", () => { it("threads top-level replies when replyToMode is all", async () => { replyMock.mockResolvedValue({ text: "thread reply" }); - config = { + slackTestState.config = { messages: { responsePrefix: "PFX", ackReaction: "👀", @@ -158,7 +44,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -191,7 +77,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -224,7 +110,7 @@ describe("monitorSlackProvider tool results", () => { it("keeps thread parent inheritance opt-in", async () => { replyMock.mockResolvedValue({ text: "thread reply" }); - config = { + slackTestState.config = { messages: { responsePrefix: "PFX" }, channels: { slack: { @@ -242,7 +128,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -286,7 +172,7 @@ describe("monitorSlackProvider tool results", () => { }); } - config = { + slackTestState.config = { messages: { responsePrefix: "PFX" }, channels: { slack: { @@ -303,7 +189,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -338,7 +224,7 @@ describe("monitorSlackProvider tool results", () => { it("scopes thread session keys to the routed agent", async () => { replyMock.mockResolvedValue({ text: "ok" }); - config = { + slackTestState.config = { messages: { responsePrefix: "PFX" }, channels: { slack: { @@ -369,7 +255,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -400,7 +286,7 @@ describe("monitorSlackProvider tool results", () => { it("keeps replies in channel root when message is not threaded (replyToMode off)", async () => { replyMock.mockResolvedValue({ text: "root reply" }); - config = { + slackTestState.config = { messages: { responsePrefix: "PFX", ackReaction: "👀", @@ -421,7 +307,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); @@ -446,7 +332,7 @@ describe("monitorSlackProvider tool results", () => { it("threads first reply when replyToMode is first and message is not threaded", async () => { replyMock.mockResolvedValue({ text: "first reply" }); - config = { + slackTestState.config = { messages: { responsePrefix: "PFX", ackReaction: "👀", @@ -467,7 +353,7 @@ describe("monitorSlackProvider tool results", () => { abortSignal: controller.signal, }); - await waitForEvent("message"); + await waitForSlackEvent("message"); const handler = getSlackHandlers()?.get("message"); if (!handler) throw new Error("Slack message handler not registered"); diff --git a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts index f0983d16b..d435aa589 100644 --- a/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts +++ b/src/slack/monitor/message-handler/prepare.inbound-contract.test.ts @@ -77,4 +77,72 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared).toBeTruthy(); expectInboundContextContract(prepared!.ctxPayload as any); }); + + it("sets MessageThreadId for top-level messages when replyToMode=all", async () => { + const slackCtx = createSlackMonitorContext({ + cfg: { + channels: { slack: { enabled: true, replyToMode: "all" } }, + } as ClawdbotConfig, + accountId: "default", + botToken: "token", + app: { client: {} } as App, + runtime: {} as RuntimeEnv, + botUserId: "B1", + teamId: "T1", + apiAppId: "A1", + historyLimit: 0, + sessionScope: "per-sender", + mainKey: "main", + dmEnabled: true, + dmPolicy: "open", + allowFrom: [], + groupDmEnabled: true, + groupDmChannels: [], + defaultRequireMention: true, + groupPolicy: "open", + useAccessGroups: false, + reactionMode: "off", + reactionAllowlist: [], + replyToMode: "all", + threadHistoryScope: "thread", + threadInheritParent: false, + slashCommand: { + enabled: false, + name: "clawd", + sessionPrefix: "slack:slash", + ephemeral: true, + }, + textLimit: 4000, + ackReactionScope: "group-mentions", + mediaMaxBytes: 1024, + removeAckAfterReply: false, + }); + slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any; + + const account: ResolvedSlackAccount = { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + config: { replyToMode: "all" }, + }; + + const message: SlackMessageEvent = { + channel: "D123", + channel_type: "im", + user: "U1", + text: "hi", + ts: "1.000", + } as SlackMessageEvent; + + const prepared = await prepareSlackMessage({ + ctx: slackCtx, + account, + message, + opts: { source: "message" }, + }); + + expect(prepared).toBeTruthy(); + expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000"); + }); }); diff --git a/src/slack/monitor/message-handler/prepare.ts b/src/slack/monitor/message-handler/prepare.ts index b1d9a15c6..c76ae4285 100644 --- a/src/slack/monitor/message-handler/prepare.ts +++ b/src/slack/monitor/message-handler/prepare.ts @@ -33,6 +33,7 @@ import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; import { sendMessageSlack } from "../../send.js"; import type { SlackMessageEvent } from "../../types.js"; +import { resolveSlackThreadContext } from "../../threading.js"; import { resolveSlackAllowListMatch, resolveSlackUserAllowed } from "../allow-list.js"; import { resolveSlackEffectiveAllowFrom } from "../auth.js"; @@ -188,9 +189,9 @@ export async function prepareSlackMessage(params: { }); const baseSessionKey = route.sessionKey; - const threadTs = message.thread_ts; - const hasThreadTs = typeof threadTs === "string" && threadTs.length > 0; - const isThreadReply = hasThreadTs && (threadTs !== message.ts || Boolean(message.parent_user_id)); + const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode }); + const threadTs = threadContext.incomingThreadTs; + const isThreadReply = threadContext.isThreadReply; const threadKeys = resolveThreadSessionKeys({ baseSessionKey, threadId: isThreadReply ? threadTs : undefined, @@ -474,9 +475,9 @@ export async function prepareSlackMessage(params: { Provider: "slack" as const, Surface: "slack" as const, MessageSid: message.ts, - ReplyToId: message.thread_ts ?? message.ts, - // Preserve thread context for routed tool notifications (thread replies only). - MessageThreadId: isThreadReply ? threadTs : undefined, + ReplyToId: threadContext.replyToId, + // Preserve thread context for routed tool notifications. + MessageThreadId: threadContext.messageThreadId, ParentSessionKey: threadKeys.parentSessionKey, ThreadStarterBody: threadStarterBody, ThreadLabel: threadLabel, diff --git a/src/slack/threading-tool-context.ts b/src/slack/threading-tool-context.ts new file mode 100644 index 000000000..eeba12277 --- /dev/null +++ b/src/slack/threading-tool-context.ts @@ -0,0 +1,29 @@ +import type { + ChannelThreadingContext, + ChannelThreadingToolContext, +} from "../channels/plugins/types.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveSlackAccount } from "./accounts.js"; + +export function buildSlackThreadingToolContext(params: { + cfg: ClawdbotConfig; + accountId?: string | null; + context: ChannelThreadingContext; + hasRepliedRef?: { value: boolean }; +}): ChannelThreadingToolContext { + const configuredReplyToMode = + resolveSlackAccount({ + cfg: params.cfg, + accountId: params.accountId, + }).replyToMode ?? "off"; + const effectiveReplyToMode = params.context.ThreadLabel ? "all" : configuredReplyToMode; + const threadId = params.context.MessageThreadId ?? params.context.ReplyToId; + return { + currentChannelId: params.context.To?.startsWith("channel:") + ? params.context.To.slice("channel:".length) + : undefined, + currentThreadTs: threadId != null ? String(threadId) : undefined, + replyToMode: effectiveReplyToMode, + hasRepliedRef: params.hasRepliedRef, + }; +} diff --git a/src/slack/threading.test.ts b/src/slack/threading.test.ts index 08ff766a9..837d3ddbc 100644 --- a/src/slack/threading.test.ts +++ b/src/slack/threading.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveSlackThreadTargets } from "./threading.js"; +import { resolveSlackThreadContext, resolveSlackThreadTargets } from "./threading.js"; describe("resolveSlackThreadTargets", () => { it("threads replies when message is already threaded", () => { @@ -45,4 +45,35 @@ describe("resolveSlackThreadTargets", () => { expect(replyThreadTs).toBeUndefined(); expect(statusThreadTs).toBe("123"); }); + + it("sets messageThreadId for top-level messages when replyToMode is all", () => { + const context = resolveSlackThreadContext({ + replyToMode: "all", + message: { + type: "message", + channel: "C1", + ts: "123", + }, + }); + + expect(context.isThreadReply).toBe(false); + expect(context.messageThreadId).toBe("123"); + expect(context.replyToId).toBe("123"); + }); + + it("prefers thread_ts as messageThreadId for replies", () => { + const context = resolveSlackThreadContext({ + replyToMode: "off", + message: { + type: "message", + channel: "C1", + ts: "123", + thread_ts: "456", + }, + }); + + expect(context.isThreadReply).toBe(true); + expect(context.messageThreadId).toBe("456"); + expect(context.replyToId).toBe("456"); + }); }); diff --git a/src/slack/threading.ts b/src/slack/threading.ts index f02de2aa4..3a95beb2e 100644 --- a/src/slack/threading.ts +++ b/src/slack/threading.ts @@ -1,13 +1,44 @@ import type { ReplyToMode } from "../config/types.js"; import type { SlackAppMentionEvent, SlackMessageEvent } from "./types.js"; +export type SlackThreadContext = { + incomingThreadTs?: string; + messageTs?: string; + isThreadReply: boolean; + replyToId?: string; + messageThreadId?: string; +}; + +export function resolveSlackThreadContext(params: { + message: SlackMessageEvent | SlackAppMentionEvent; + replyToMode: ReplyToMode; +}): SlackThreadContext { + const incomingThreadTs = params.message.thread_ts; + const eventTs = params.message.event_ts; + const messageTs = params.message.ts ?? eventTs; + const hasThreadTs = typeof incomingThreadTs === "string" && incomingThreadTs.length > 0; + const isThreadReply = + hasThreadTs && (incomingThreadTs !== messageTs || Boolean(params.message.parent_user_id)); + const replyToId = incomingThreadTs ?? messageTs; + const messageThreadId = isThreadReply + ? incomingThreadTs + : params.replyToMode === "all" + ? messageTs + : undefined; + return { + incomingThreadTs, + messageTs, + isThreadReply, + replyToId, + messageThreadId, + }; +} + export function resolveSlackThreadTargets(params: { message: SlackMessageEvent | SlackAppMentionEvent; replyToMode: ReplyToMode; }) { - const incomingThreadTs = params.message.thread_ts; - const eventTs = params.message.event_ts; - const messageTs = params.message.ts ?? eventTs; + const { incomingThreadTs, messageTs } = resolveSlackThreadContext(params); const replyThreadTs = incomingThreadTs ?? (params.replyToMode === "all" ? messageTs : undefined); const statusThreadTs = replyThreadTs ?? messageTs; return { replyThreadTs, statusThreadTs };