fix(slack): honor focused thread bindings

This commit is contained in:
Peter Steinberger
2026-04-22 22:29:35 +01:00
parent cc1e843c90
commit 8a3e130db8
6 changed files with 201 additions and 7 deletions

View File

@@ -1,6 +1,12 @@
import fs from "node:fs";
import type { App } from "@slack/bolt";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
registerSessionBindingAdapter,
unregisterSessionBindingAdapter,
type SessionBindingAdapter,
type SessionBindingRecord,
} from "openclaw/plugin-sdk/conversation-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { expectChannelInboundContextContract as expectInboundContextContract } from "openclaw/plugin-sdk/testing";
@@ -592,6 +598,76 @@ describe("slack prepareSlackMessage inbound contract", () => {
// MessageThreadId should be set for the reply
expect(prepared!.ctxPayload.MessageThreadId).toBe("500.000");
});
it("routes Slack thread replies through runtime conversation bindings", async () => {
const targetSessionKey = "agent:review:acp:session-67739";
const binding: SessionBindingRecord = {
bindingId: "test-binding",
targetSessionKey,
targetKind: "session",
conversation: {
channel: "slack",
accountId: "default",
conversationId: "100.000",
parentConversationId: "C123",
},
status: "active",
boundAt: Date.now(),
metadata: {},
};
const resolveByConversation: SessionBindingAdapter["resolveByConversation"] = vi.fn((ref) =>
ref.channel === "slack" &&
ref.accountId === "default" &&
ref.conversationId === "100.000" &&
ref.parentConversationId === "C123"
? binding
: null,
);
const touch: NonNullable<SessionBindingAdapter["touch"]> = vi.fn();
const adapter: SessionBindingAdapter = {
channel: "slack",
accountId: "default",
listBySession: () => [],
resolveByConversation,
touch,
};
registerSessionBindingAdapter(adapter);
try {
const replies = vi.fn().mockResolvedValue({
messages: [{ text: "starter", user: "U2", ts: "100.000" }],
response_metadata: { next_cursor: "" },
});
const slackCtx = createThreadSlackCtx({
cfg: {
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig,
replies,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareThreadMessage(slackCtx, {
text: "bound reply",
ts: "101.000",
thread_ts: "100.000",
});
expect(prepared).toBeTruthy();
expect(prepared!.route.sessionKey).toBe(targetSessionKey);
expect(prepared!.route.agentId).toBe("review");
expect(prepared!.ctxPayload.SessionKey).toBe(targetSessionKey);
expect(prepared!.ctxPayload.ParentSessionKey).toBeUndefined();
expect(resolveByConversation).toHaveBeenCalledWith({
channel: "slack",
accountId: "default",
conversationId: "100.000",
parentConversationId: "C123",
});
expect(touch).toHaveBeenCalledWith("test-binding", undefined);
} finally {
unregisterSessionBindingAdapter({ channel: "slack", accountId: "default", adapter });
}
});
});
describe("prepareSlackMessage sender prefix", () => {

View File

@@ -15,6 +15,10 @@ import {
import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth";
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 {
@@ -106,6 +110,7 @@ type SlackAuthorizationContext = {
type SlackRoutingContext = {
route: ReturnType<typeof resolveAgentRoute>;
runtimeBinding: RuntimeConversationBindingRouteResult["bindingRecord"];
chatType: "direct" | "group" | "channel";
replyToMode: ReturnType<typeof resolveSlackReplyToMode>;
threadContext: ReturnType<typeof resolveSlackThreadContext>;
@@ -116,6 +121,15 @@ type SlackRoutingContext = {
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;
@@ -272,7 +286,7 @@ function resolveSlackRoutingContext(params: {
isRoomish: boolean;
}): SlackRoutingContext {
const { ctx, account, message, isDirectMessage, isGroupDm, isRoom, isRoomish } = params;
const route = resolveAgentRoute({
let route = resolveAgentRoute({
cfg: ctx.cfg,
channel: "slack",
accountId: account.accountId,
@@ -302,17 +316,45 @@ function resolveSlackRoutingContext(params: {
// isolated sessions per message (regression from #10686).
const roomThreadId = isThreadReply && threadTs ? threadTs : undefined;
const canonicalThreadId = isRoomish ? roomThreadId : isThreadReply ? threadTs : autoThreadId;
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: canonicalThreadId,
parentSessionKey: canonicalThreadId && ctx.threadInheritParent ? route.sessionKey : undefined,
});
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,
@@ -364,6 +406,7 @@ export async function prepareSlackMessage(params: {
});
const {
route,
runtimeBinding,
replyToMode,
threadContext,
threadTs,
@@ -372,6 +415,11 @@ export async function prepareSlackMessage(params: {
sessionKey,
historyKey,
} = routing;
if (runtimeBinding && shouldLogVerbose()) {
logVerbose(
`slack: routed via bound conversation ${runtimeBinding.conversation.conversationId} -> ${runtimeBinding.targetSessionKey}`,
);
}
const mentionRegexes = resolveCachedMentionRegexes(ctx, route.agentId);
const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");