mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:10:51 +00:00
perf: narrow slack monitor imports
This commit is contained in:
37
extensions/slack/src/account-reply-mode.ts
Normal file
37
extensions/slack/src/account-reply-mode.ts
Normal 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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
46
extensions/slack/src/monitor/message-handler/debounce-key.ts
Normal file
46
extensions/slack/src/monitor/message-handler/debounce-key.ts
Normal 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}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { dispatchPreparedSlackMessage } from "./dispatch.js";
|
||||
export { prepareSlackMessage } from "./prepare.js";
|
||||
130
extensions/slack/src/monitor/message-handler/prepare-routing.ts
Normal file
130
extensions/slack/src/monitor/message-handler/prepare-routing.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user