perf: narrow slack monitor imports

This commit is contained in:
Peter Steinberger
2026-04-24 01:09:25 +01:00
parent 716a3a5865
commit 7e16e3d077
10 changed files with 302 additions and 245 deletions

View File

@@ -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";
}

View File

@@ -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";
}

View File

@@ -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> = {}): SlackMessageEvent {
return {

View File

@@ -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<SlackMessagePipeline> | undefined;
function loadSlackMessagePipeline(): Promise<SlackMessagePipeline> {
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,

View File

@@ -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}`;
}

View File

@@ -0,0 +1,2 @@
export { dispatchPreparedSlackMessage } from "./dispatch.js";
export { prepareSlackMessage } from "./prepare.js";

View File

@@ -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<typeof resolveAgentRoute>;
runtimeBinding: RuntimeConversationBindingRouteResult["bindingRecord"];
chatType: "direct" | "group" | "channel";
replyToMode: ReturnType<typeof resolveSlackReplyToMode>;
threadContext: ReturnType<typeof resolveSlackThreadContext>;
threadTs: string | undefined;
isThreadReply: boolean;
threadKeys: ReturnType<typeof resolveThreadSessionKeys>;
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,
};
}

View File

@@ -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>): SlackMessageEvent {
@@ -35,36 +42,38 @@ function buildChannelMessage(overrides?: Partial<SlackMessageEvent>): 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:");
});
});

View File

@@ -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<typeof resolveAgentRoute>;
runtimeBinding: RuntimeConversationBindingRouteResult["bindingRecord"];
chatType: "direct" | "group" | "channel";
replyToMode: ReturnType<typeof resolveSlackReplyToMode>;
threadContext: ReturnType<typeof resolveSlackThreadContext>;
threadTs: string | undefined;
isThreadReply: boolean;
threadKeys: ReturnType<typeof resolveThreadSessionKeys>;
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;

View File

@@ -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<SlackBoltResolvedExports> {
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,