diff --git a/extensions/slack/src/account-reply-mode.ts b/extensions/slack/src/account-reply-mode.ts new file mode 100644 index 00000000000..33f02bad493 --- /dev/null +++ b/extensions/slack/src/account-reply-mode.ts @@ -0,0 +1,37 @@ +import type { SlackAccountConfig } from "./runtime-api.js"; + +export type SlackReplyToMode = "off" | "first" | "all" | "batched"; + +export type SlackReplyToModeAccount = { + replyToMode?: SlackReplyToMode; + replyToModeByChatType?: SlackAccountConfig["replyToModeByChatType"]; + dm?: { replyToMode?: SlackReplyToMode }; +}; + +function normalizeSlackChatType(raw?: string): "direct" | "group" | "channel" | undefined { + const value = raw?.trim().toLowerCase(); + if (!value) { + return undefined; + } + if (value === "direct" || value === "dm") { + return "direct"; + } + if (value === "group" || value === "channel") { + return value; + } + return undefined; +} + +export function resolveSlackReplyToMode( + account: SlackReplyToModeAccount, + chatType?: string | null, +): SlackReplyToMode { + const normalized = normalizeSlackChatType(chatType ?? undefined); + if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) { + return account.replyToModeByChatType[normalized] ?? "off"; + } + if (normalized === "direct" && account.dm?.replyToMode !== undefined) { + return account.dm.replyToMode; + } + return account.replyToMode ?? "off"; +} diff --git a/extensions/slack/src/accounts.ts b/extensions/slack/src/accounts.ts index d8a6eae1023..27042067876 100644 --- a/extensions/slack/src/accounts.ts +++ b/extensions/slack/src/accounts.ts @@ -2,7 +2,6 @@ import { createAccountListHelpers, DEFAULT_ACCOUNT_ID, normalizeAccountId, - normalizeChatType, resolveMergedAccountConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; @@ -11,6 +10,8 @@ import type { SlackAccountSurfaceFields } from "./account-surface-fields.js"; import type { SlackAccountConfig } from "./runtime-api.js"; import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js"; +export { resolveSlackReplyToMode } from "./account-reply-mode.js"; + export type SlackTokenSource = "env" | "config" | "none"; export type ResolvedSlackAccount = { @@ -109,17 +110,3 @@ export function listEnabledSlackAccounts(cfg: OpenClawConfig): ResolvedSlackAcco .map((accountId) => resolveSlackAccount({ cfg, accountId })) .filter((account) => account.enabled); } - -export function resolveSlackReplyToMode( - account: ResolvedSlackAccount, - chatType?: string | null, -): "off" | "first" | "all" | "batched" { - const normalized = normalizeChatType(chatType ?? undefined); - if (normalized && account.replyToModeByChatType?.[normalized] !== undefined) { - return account.replyToModeByChatType[normalized] ?? "off"; - } - if (normalized === "direct" && account.dm?.replyToMode !== undefined) { - return account.dm.replyToMode; - } - return account.replyToMode ?? "off"; -} diff --git a/extensions/slack/src/monitor/message-handler.debounce-key.test.ts b/extensions/slack/src/monitor/message-handler.debounce-key.test.ts index 17c677b4e37..db64b5b0d16 100644 --- a/extensions/slack/src/monitor/message-handler.debounce-key.test.ts +++ b/extensions/slack/src/monitor/message-handler.debounce-key.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { SlackMessageEvent } from "../types.js"; -import { buildSlackDebounceKey } from "./message-handler.js"; +import { buildSlackDebounceKey } from "./message-handler/debounce-key.js"; function makeMessage(overrides: Partial = {}): SlackMessageEvent { return { diff --git a/extensions/slack/src/monitor/message-handler.ts b/extensions/slack/src/monitor/message-handler.ts index d4c404e62bf..f1ddb4520db 100644 --- a/extensions/slack/src/monitor/message-handler.ts +++ b/extensions/slack/src/monitor/message-handler.ts @@ -7,10 +7,21 @@ import type { ResolvedSlackAccount } from "../accounts.js"; import type { SlackMessageEvent } from "../types.js"; import { stripSlackMentionsForCommandDetection } from "./commands.js"; import type { SlackMonitorContext } from "./context.js"; -import { dispatchPreparedSlackMessage } from "./message-handler/dispatch.js"; -import { prepareSlackMessage } from "./message-handler/prepare.js"; +import { + buildSlackDebounceKey, + buildTopLevelSlackConversationKey, +} from "./message-handler/debounce-key.js"; import { createSlackThreadTsResolver } from "./thread-resolution.js"; +type SlackMessagePipeline = typeof import("./message-handler/pipeline.runtime.js"); + +let slackMessagePipelinePromise: Promise | undefined; + +function loadSlackMessagePipeline(): Promise { + slackMessagePipelinePromise ??= import("./message-handler/pipeline.runtime.js"); + return slackMessagePipelinePromise; +} + export type SlackMessageHandler = ( message: SlackMessageEvent, opts: { source: "message" | "app_mention"; wasMentioned?: boolean }, @@ -25,32 +36,6 @@ export class SlackRetryableInboundError extends Error { } } -function resolveSlackSenderId(message: SlackMessageEvent): string | null { - return message.user ?? message.bot_id ?? null; -} - -function isSlackDirectMessageChannel(channelId: string): boolean { - return channelId.startsWith("D"); -} - -function isTopLevelSlackMessage(message: SlackMessageEvent): boolean { - return !message.thread_ts && !message.parent_user_id; -} - -function buildTopLevelSlackConversationKey( - message: SlackMessageEvent, - accountId: string, -): string | null { - if (!isTopLevelSlackMessage(message)) { - return null; - } - const senderId = resolveSlackSenderId(message); - if (!senderId) { - return null; - } - return `slack:${accountId}:${message.channel}:${senderId}`; -} - function shouldDebounceSlackMessage(message: SlackMessageEvent, cfg: SlackMonitorContext["cfg"]) { const text = message.text ?? ""; const textForCommandDetection = stripSlackMentionsForCommandDetection(text); @@ -68,33 +53,6 @@ function buildSeenMessageKey(channelId: string | undefined, ts: string | undefin return `${channelId}:${ts}`; } -/** - * Build a debounce key that isolates messages by thread (or by message timestamp - * for top-level non-DM channel messages). Without per-message scoping, concurrent - * top-level messages from the same sender can share a key and get merged - * into a single reply on the wrong thread. - * - * DMs intentionally stay channel-scoped to preserve short-message batching. - */ -export function buildSlackDebounceKey( - message: SlackMessageEvent, - accountId: string, -): string | null { - const senderId = resolveSlackSenderId(message); - if (!senderId) { - return null; - } - const messageTs = message.ts ?? message.event_ts; - const threadKey = message.thread_ts - ? `${message.channel}:${message.thread_ts}` - : message.parent_user_id && messageTs - ? `${message.channel}:maybe-thread:${messageTs}` - : messageTs && !isSlackDirectMessageChannel(message.channel) - ? `${message.channel}:${messageTs}` - : message.channel; - return `slack:${accountId}:${threadKey}:${senderId}`; -} - export function createSlackMessageHandler(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; @@ -143,6 +101,8 @@ export function createSlackMessageHandler(params: { }; const seenMessageKey = buildSeenMessageKey(last.message.channel, last.message.ts); try { + const { prepareSlackMessage, dispatchPreparedSlackMessage } = + await loadSlackMessagePipeline(); const prepared = await prepareSlackMessage({ ctx, account, diff --git a/extensions/slack/src/monitor/message-handler/debounce-key.ts b/extensions/slack/src/monitor/message-handler/debounce-key.ts new file mode 100644 index 00000000000..e7730fecab9 --- /dev/null +++ b/extensions/slack/src/monitor/message-handler/debounce-key.ts @@ -0,0 +1,46 @@ +import type { SlackMessageEvent } from "../../types.js"; + +function resolveSlackSenderId(message: SlackMessageEvent): string | null { + return message.user ?? message.bot_id ?? null; +} + +function isSlackDirectMessageChannel(channelId: string): boolean { + return channelId.startsWith("D"); +} + +function isTopLevelSlackMessage(message: SlackMessageEvent): boolean { + return !message.thread_ts && !message.parent_user_id; +} + +export function buildTopLevelSlackConversationKey( + message: SlackMessageEvent, + accountId: string, +): string | null { + if (!isTopLevelSlackMessage(message)) { + return null; + } + const senderId = resolveSlackSenderId(message); + if (!senderId) { + return null; + } + return `slack:${accountId}:${message.channel}:${senderId}`; +} + +export function buildSlackDebounceKey( + message: SlackMessageEvent, + accountId: string, +): string | null { + const senderId = resolveSlackSenderId(message); + if (!senderId) { + return null; + } + const messageTs = message.ts ?? message.event_ts; + const threadKey = message.thread_ts + ? `${message.channel}:${message.thread_ts}` + : message.parent_user_id && messageTs + ? `${message.channel}:maybe-thread:${messageTs}` + : messageTs && !isSlackDirectMessageChannel(message.channel) + ? `${message.channel}:${messageTs}` + : message.channel; + return `slack:${accountId}:${threadKey}:${senderId}`; +} diff --git a/extensions/slack/src/monitor/message-handler/pipeline.runtime.ts b/extensions/slack/src/monitor/message-handler/pipeline.runtime.ts new file mode 100644 index 00000000000..afc3b70ab1c --- /dev/null +++ b/extensions/slack/src/monitor/message-handler/pipeline.runtime.ts @@ -0,0 +1,2 @@ +export { dispatchPreparedSlackMessage } from "./dispatch.js"; +export { prepareSlackMessage } from "./prepare.js"; diff --git a/extensions/slack/src/monitor/message-handler/prepare-routing.ts b/extensions/slack/src/monitor/message-handler/prepare-routing.ts new file mode 100644 index 00000000000..7d9a4556270 --- /dev/null +++ b/extensions/slack/src/monitor/message-handler/prepare-routing.ts @@ -0,0 +1,130 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveRuntimeConversationBindingRoute, + type RuntimeConversationBindingRouteResult, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; +import { resolveSlackReplyToMode } from "../../account-reply-mode.js"; +import type { ResolvedSlackAccount } from "../../accounts.js"; +import { resolveSlackThreadContext } from "../../threading.js"; +import type { SlackMessageEvent } from "../../types.js"; + +export type SlackRoutingContextDeps = { + cfg: OpenClawConfig; + teamId: string; + threadInheritParent: boolean; + threadHistoryScope: "thread" | "channel"; +}; + +export type SlackRoutingContext = { + route: ReturnType; + runtimeBinding: RuntimeConversationBindingRouteResult["bindingRecord"]; + chatType: "direct" | "group" | "channel"; + replyToMode: ReturnType; + threadContext: ReturnType; + threadTs: string | undefined; + isThreadReply: boolean; + threadKeys: ReturnType; + sessionKey: string; + historyKey: string; +}; + +function resolveSlackBaseConversationId(params: { + message: SlackMessageEvent; + isDirectMessage: boolean; +}): string { + return params.isDirectMessage + ? `user:${params.message.user ?? "unknown"}` + : params.message.channel; +} + +export function resolveSlackRoutingContext(params: { + ctx: SlackRoutingContextDeps; + account: ResolvedSlackAccount; + message: SlackMessageEvent; + isDirectMessage: boolean; + isGroupDm: boolean; + isRoom: boolean; + isRoomish: boolean; +}): SlackRoutingContext { + const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params; + let route = resolveAgentRoute({ + cfg: ctx.cfg, + channel: "slack", + accountId: account.accountId, + teamId: ctx.teamId || undefined, + peer: { + kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group", + id: isDirectMessage ? (message.user ?? "unknown") : message.channel, + }, + }); + + const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel"; + const replyToMode = resolveSlackReplyToMode(account, chatType); + const threadContext = resolveSlackThreadContext({ message, replyToMode }); + const threadTs = threadContext.incomingThreadTs; + const isThreadReply = threadContext.isThreadReply; + // Keep true thread replies thread-scoped, but preserve channel-level sessions + // for top-level room turns when replyToMode is off. + // For DMs, preserve existing auto-thread behavior when replyToMode="all". + const autoThreadId = + !isThreadReply && replyToMode === "all" && threadContext.messageTs + ? threadContext.messageTs + : undefined; + // Only fork channel/group messages into thread-specific sessions when they are + // actual thread replies (thread_ts present, different from message ts). + // Top-level channel messages must stay on the per-channel session for continuity. + // Before this fix, every channel message used its own ts as threadId, creating + // isolated sessions per message (regression from #10686). + const roomThreadId = isThreadReply && threadTs ? threadTs : undefined; + const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId; + const baseConversationId = resolveSlackBaseConversationId({ message, isDirectMessage }); + const boundThreadRoute = canonicalThreadId + ? resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "slack", + accountId: account.accountId, + conversationId: canonicalThreadId, + parentConversationId: baseConversationId, + }, + }) + : null; + const runtimeRoute = + boundThreadRoute?.boundSessionKey || boundThreadRoute?.bindingRecord + ? boundThreadRoute + : resolveRuntimeConversationBindingRoute({ + route, + conversation: { + channel: "slack", + accountId: account.accountId, + conversationId: baseConversationId, + }, + }); + route = runtimeRoute.route; + const threadKeys = runtimeRoute.boundSessionKey + ? { sessionKey: route.sessionKey, parentSessionKey: undefined } + : resolveThreadSessionKeys({ + baseSessionKey: route.sessionKey, + threadId: canonicalThreadId, + parentSessionKey: + canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : undefined, + }); + const sessionKey = threadKeys.sessionKey; + const historyKey = + isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel; + + return { + route, + runtimeBinding: runtimeRoute.bindingRecord, + chatType, + replyToMode, + threadContext, + threadTs, + isThreadReply, + threadKeys, + sessionKey, + historyKey, + }; +} diff --git a/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts b/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts index c4839923003..79d3680d25b 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.thread-session-key.test.ts @@ -1,26 +1,33 @@ -import type { App } from "@slack/bolt"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { describe, expect, it } from "vitest"; +import type { ResolvedSlackAccount } from "../../accounts.js"; import type { SlackMessageEvent } from "../../types.js"; - -const [{ prepareSlackMessage }, helpers] = await Promise.all([ - import("./prepare.js"), - import("./prepare.test-helpers.js"), -]); -const { createInboundSlackTestContext, createSlackTestAccount } = helpers; +import { resolveSlackRoutingContext, type SlackRoutingContextDeps } from "./prepare-routing.js"; function buildCtx(overrides?: { replyToMode?: "all" | "first" | "off" }) { const replyToMode = overrides?.replyToMode ?? "all"; - return createInboundSlackTestContext({ + return { cfg: { channels: { slack: { enabled: true, replyToMode }, }, } as OpenClawConfig, - appClient: {} as App["client"], - defaultRequireMention: false, + teamId: "T1", + threadInheritParent: false, + threadHistoryScope: "thread", + } satisfies SlackRoutingContextDeps; +} + +function buildAccount(replyToMode: "all" | "first" | "off"): ResolvedSlackAccount { + return { + accountId: "default", + enabled: true, + botTokenSource: "config", + appTokenSource: "config", + userTokenSource: "none", + config: { replyToMode }, replyToMode, - }); + }; } function buildChannelMessage(overrides?: Partial): SlackMessageEvent { @@ -35,36 +42,38 @@ function buildChannelMessage(overrides?: Partial): SlackMessa } describe("thread-level session keys", () => { - it("keeps top-level channel turns in one session when replyToMode=off", async () => { + it("keeps top-level channel turns in one session when replyToMode=off", () => { const ctx = buildCtx({ replyToMode: "off" }); - ctx.resolveUserName = async () => ({ name: "Alice" }); - const account = createSlackTestAccount({ replyToMode: "off" }); + const account = buildAccount("off"); - const first = await prepareSlackMessage({ + const first = resolveSlackRoutingContext({ ctx, account, message: buildChannelMessage({ ts: "1770408518.451689" }), - opts: { source: "message" }, + isDirectMessage: false, + isGroupDm: false, + isRoom: true, + isRoomish: true, }); - const second = await prepareSlackMessage({ + const second = resolveSlackRoutingContext({ ctx, account, message: buildChannelMessage({ ts: "1770408520.000001" }), - opts: { source: "message" }, + isDirectMessage: false, + isGroupDm: false, + isRoom: true, + isRoomish: true, }); - expect(first).toBeTruthy(); - expect(second).toBeTruthy(); - const firstSessionKey = first!.ctxPayload.SessionKey as string; - const secondSessionKey = second!.ctxPayload.SessionKey as string; + const firstSessionKey = first.sessionKey; + const secondSessionKey = second.sessionKey; expect(firstSessionKey).toBe(secondSessionKey); expect(firstSessionKey).not.toContain(":thread:"); }); - it("uses parent thread_ts for thread replies even when replyToMode=off", async () => { + it("uses parent thread_ts for thread replies even when replyToMode=off", () => { const ctx = buildCtx({ replyToMode: "off" }); - ctx.resolveUserName = async () => ({ name: "Bob" }); - const account = createSlackTestAccount({ replyToMode: "off" }); + const account = buildAccount("off"); const message = buildChannelMessage({ user: "U2", @@ -73,52 +82,55 @@ describe("thread-level session keys", () => { thread_ts: "1770408518.451689", }); - const prepared = await prepareSlackMessage({ + const routing = resolveSlackRoutingContext({ ctx, account, message, - opts: { source: "message" }, + isDirectMessage: false, + isGroupDm: false, + isRoom: true, + isRoomish: true, }); - expect(prepared).toBeTruthy(); - // Thread replies should use the parent thread_ts, not the reply ts - const sessionKey = prepared!.ctxPayload.SessionKey as string; + const sessionKey = routing.sessionKey; expect(sessionKey).toContain(":thread:1770408518.451689"); expect(sessionKey).not.toContain("1770408522.168859"); }); - it("keeps top-level channel messages on the per-channel session regardless of replyToMode", async () => { + it("keeps top-level channel messages on the per-channel session regardless of replyToMode", () => { for (const mode of ["all", "first", "off"] as const) { const ctx = buildCtx({ replyToMode: mode }); - ctx.resolveUserName = async () => ({ name: "Carol" }); - const account = createSlackTestAccount({ replyToMode: mode }); + const account = buildAccount(mode); - const first = await prepareSlackMessage({ + const first = resolveSlackRoutingContext({ ctx, account, message: buildChannelMessage({ ts: "1770408530.000000" }), - opts: { source: "message" }, + isDirectMessage: false, + isGroupDm: false, + isRoom: true, + isRoomish: true, }); - const second = await prepareSlackMessage({ + const second = resolveSlackRoutingContext({ ctx, account, message: buildChannelMessage({ ts: "1770408531.000000" }), - opts: { source: "message" }, + isDirectMessage: false, + isGroupDm: false, + isRoom: true, + isRoomish: true, }); - expect(first).toBeTruthy(); - expect(second).toBeTruthy(); - const firstKey = first!.ctxPayload.SessionKey as string; - const secondKey = second!.ctxPayload.SessionKey as string; + const firstKey = first.sessionKey; + const secondKey = second.sessionKey; expect(firstKey).toBe(secondKey); expect(firstKey).not.toContain(":thread:"); } }); - it("does not add thread suffix for DMs when replyToMode=off", async () => { + it("does not add thread suffix for DMs when replyToMode=off", () => { const ctx = buildCtx({ replyToMode: "off" }); - ctx.resolveUserName = async () => ({ name: "Carol" }); - const account = createSlackTestAccount({ replyToMode: "off" }); + const account = buildAccount("off"); const message: SlackMessageEvent = { channel: "D456", @@ -128,16 +140,17 @@ describe("thread-level session keys", () => { ts: "1770408530.000000", } as SlackMessageEvent; - const prepared = await prepareSlackMessage({ + const routing = resolveSlackRoutingContext({ ctx, account, message, - opts: { source: "message" }, + isDirectMessage: true, + isGroupDm: false, + isRoom: false, + isRoomish: false, }); - expect(prepared).toBeTruthy(); - // DMs should NOT have :thread: in the session key - const sessionKey = prepared!.ctxPayload.SessionKey as string; + const sessionKey = routing.sessionKey; expect(sessionKey).not.toContain(":thread:"); }); }); diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 08730d1e4aa..10c2d468c33 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -15,10 +15,6 @@ import { import { hasControlCommand } from "openclaw/plugin-sdk/command-detection"; import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-gating"; import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-surface"; -import { - resolveRuntimeConversationBindingRoute, - type RuntimeConversationBindingRouteResult, -} from "openclaw/plugin-sdk/conversation-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; import { @@ -26,18 +22,15 @@ import { recordPendingHistoryEntryIfEnabled, } from "openclaw/plugin-sdk/reply-history"; import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime"; -import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; -import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/text-runtime"; -import { resolveSlackReplyToMode, type ResolvedSlackAccount } from "../../accounts.js"; +import type { ResolvedSlackAccount } from "../../accounts.js"; import { reactSlackMessage } from "../../actions.js"; import { hasSlackThreadParticipation } from "../../sent-thread-cache.js"; -import { resolveSlackThreadContext } from "../../threading.js"; import type { SlackMessageEvent } from "../../types.js"; import { normalizeAllowListLower, @@ -61,6 +54,7 @@ import { finalizeInboundContext } from "../reply.runtime.js"; import { resolveSlackRoomContextHints } from "../room-context.js"; import { sendMessageSlack } from "../send.runtime.js"; import { resolveSlackMessageContent } from "./prepare-content.js"; +import { resolveSlackRoutingContext } from "./prepare-routing.js"; import { resolveSlackThreadContextData } from "./prepare-thread-context.js"; import type { PreparedSlackMessage } from "./types.js"; @@ -108,28 +102,6 @@ type SlackAuthorizationContext = { allowFromLower: string[]; }; -type SlackRoutingContext = { - route: ReturnType; - runtimeBinding: RuntimeConversationBindingRouteResult["bindingRecord"]; - chatType: "direct" | "group" | "channel"; - replyToMode: ReturnType; - threadContext: ReturnType; - threadTs: string | undefined; - isThreadReply: boolean; - threadKeys: ReturnType; - sessionKey: string; - historyKey: string; -}; - -function resolveSlackBaseConversationId(params: { - message: SlackMessageEvent; - isDirectMessage: boolean; -}): string { - return params.isDirectMessage - ? `user:${params.message.user ?? "unknown"}` - : params.message.channel; -} - async function resolveSlackConversationContext(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; @@ -276,96 +248,6 @@ async function authorizeSlackInboundMessage(params: { }; } -function resolveSlackRoutingContext(params: { - ctx: SlackMonitorContext; - account: ResolvedSlackAccount; - message: SlackMessageEvent; - isDirectMessage: boolean; - isGroupDm: boolean; - isRoom: boolean; - isRoomish: boolean; -}): SlackRoutingContext { - const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params; - let route = resolveAgentRoute({ - cfg: ctx.cfg, - channel: "slack", - accountId: account.accountId, - teamId: ctx.teamId || undefined, - peer: { - kind: isDirectMessage ? "direct" : isRoom ? "channel" : "group", - id: isDirectMessage ? (message.user ?? "unknown") : message.channel, - }, - }); - - const chatType = isDirectMessage ? "direct" : isGroupDm ? "group" : "channel"; - const replyToMode = resolveSlackReplyToMode(account, chatType); - const threadContext = resolveSlackThreadContext({ message, replyToMode }); - const threadTs = threadContext.incomingThreadTs; - const isThreadReply = threadContext.isThreadReply; - // Keep true thread replies thread-scoped, but preserve channel-level sessions - // for top-level room turns when replyToMode is off. - // For DMs, preserve existing auto-thread behavior when replyToMode="all". - const autoThreadId = - !isThreadReply && replyToMode === "all" && threadContext.messageTs - ? threadContext.messageTs - : undefined; - // Only fork channel/group messages into thread-specific sessions when they are - // actual thread replies (thread_ts present, different from message ts). - // Top-level channel messages must stay on the per-channel session for continuity. - // Before this fix, every channel message used its own ts as threadId, creating - // isolated sessions per message (regression from #10686). - const roomThreadId = isThreadReply && threadTs ? threadTs : undefined; - const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId; - const baseConversationId = resolveSlackBaseConversationId({ message, isDirectMessage }); - const boundThreadRoute = canonicalThreadId - ? resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "slack", - accountId: account.accountId, - conversationId: canonicalThreadId, - parentConversationId: baseConversationId, - }, - }) - : null; - const runtimeRoute = - boundThreadRoute?.boundSessionKey || boundThreadRoute?.bindingRecord - ? boundThreadRoute - : resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "slack", - accountId: account.accountId, - conversationId: baseConversationId, - }, - }); - route = runtimeRoute.route; - const threadKeys = runtimeRoute.boundSessionKey - ? { sessionKey: route.sessionKey, parentSessionKey: undefined } - : resolveThreadSessionKeys({ - baseSessionKey: route.sessionKey, - threadId: canonicalThreadId, - parentSessionKey: - canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : undefined, - }); - const sessionKey = threadKeys.sessionKey; - const historyKey = - isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel; - - return { - route, - runtimeBinding: runtimeRoute.bindingRecord, - chatType, - replyToMode, - threadContext, - threadTs, - isThreadReply, - threadKeys, - sessionKey, - historyKey, - }; -} - export async function prepareSlackMessage(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 0044dfdab9f..86577581697 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -1,5 +1,4 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import SlackBolt, * as SlackBoltNamespace from "@slack/bolt"; import { addAllowlistUserEntriesFromConfigEntry, buildAllowlistResolutionSummary, @@ -68,11 +67,12 @@ import type { MonitorSlackOpts } from "./types.js"; let slackBoltInterop: SlackBoltResolvedExports | undefined; -function getSlackBoltInterop(): SlackBoltResolvedExports { +async function getSlackBoltInterop(): Promise { if (!slackBoltInterop) { + const slackBoltModule = await import("@slack/bolt"); slackBoltInterop = resolveSlackBoltInterop({ - defaultImport: SlackBolt, - namespaceImport: SlackBoltNamespace, + defaultImport: slackBoltModule.default, + namespaceImport: slackBoltModule, }); } return slackBoltInterop; @@ -185,7 +185,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const clientOptions = resolveSlackWebClientOptions(); const { app, receiver } = createSlackBoltApp({ - interop: getSlackBoltInterop(), + interop: await getSlackBoltInterop(), slackMode, botToken, appToken: appToken ?? undefined,