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

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Media delivery: strip persisted base64 audio payloads from webchat history, resolve stored `media://inbound/*` attachments before local-root checks, suppress duplicate Telegram voice/audio sends when TTS emits the same media twice, and support custom image-model IDs that already include their provider prefix.
- Slack/files: resolve `downloadFile` bot tokens from the runtime config when callers provide `cfg` without an explicit token or prebuilt client, preserving cfg-only file downloads outside the action runtime path. (#70160) Thanks @martingarramon.
- Slack/HTTP: dispatch registered Request URL webhooks through the same handler registry used by Slack monitor setup, so HTTP-mode Slack events no longer 404 after successful route registration. (#70275) Thanks @FroeMic.
- Slack/runtime bindings: route focused Slack thread replies through their bound ACP session instead of preparing replies against the default agent shell. Fixes #67739. Thanks @Frankla20.
- CLI/Claude: verify stored Claude CLI session ids have a readable project transcript before resuming, clearing phantom bindings with `reason=transcript-missing` instead of silently starting fresh under `--resume`. Fixes #70177.
- CLI sessions: persist CLI session clearing through the atomic session-store merge path, so expired Claude/Codex CLI bindings are actually removed before retrying without the stale session id. (#70298) Thanks @HFConsultant.
- ACP/sessions_spawn: honor explicit `model` overrides for ACP child sessions instead of silently falling back to the target agent default model. (#70210) Thanks @felix-miao.

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 ?? "");

View File

@@ -1,5 +1,9 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
import {
getSessionBindingService,
type ConversationRef,
type SessionBindingRecord,
} from "../../infra/outbound/session-binding-service.js";
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
import { deriveLastRoutePolicy } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
@@ -14,6 +18,13 @@ export type ConfiguredBindingRouteResult = {
boundAgentId?: string;
};
export type RuntimeConversationBindingRouteResult = {
bindingRecord: SessionBindingRecord | null;
route: ResolvedAgentRoute;
boundSessionKey?: string;
boundAgentId?: string;
};
type ConfiguredBindingRouteConversationInput =
| {
conversation: ConversationRef;
@@ -39,6 +50,18 @@ function resolveConfiguredBindingConversationRef(
};
}
function isPluginOwnedRuntimeBindingRecord(record: SessionBindingRecord | null): boolean {
const metadata = record?.metadata;
if (!metadata || typeof metadata !== "object") {
return false;
}
return (
metadata.pluginBindingOwner === "plugin" &&
typeof metadata.pluginId === "string" &&
typeof metadata.pluginRoot === "string"
);
}
export function resolveConfiguredBindingRoute(
params: {
cfg: OpenClawConfig;
@@ -83,6 +106,48 @@ export function resolveConfiguredBindingRoute(
};
}
export function resolveRuntimeConversationBindingRoute(
params: {
route: ResolvedAgentRoute;
} & ConfiguredBindingRouteConversationInput,
): RuntimeConversationBindingRouteResult {
const bindingRecord = getSessionBindingService().resolveByConversation(
resolveConfiguredBindingConversationRef(params),
);
const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
if (!bindingRecord || !boundSessionKey) {
return {
bindingRecord: null,
route: params.route,
};
}
getSessionBindingService().touch(bindingRecord.bindingId);
if (isPluginOwnedRuntimeBindingRecord(bindingRecord)) {
return {
bindingRecord,
route: params.route,
};
}
const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId;
return {
bindingRecord,
boundSessionKey,
boundAgentId,
route: {
...params.route,
sessionKey: boundSessionKey,
agentId: boundAgentId,
lastRoutePolicy: deriveLastRoutePolicy({
sessionKey: boundSessionKey,
mainSessionKey: params.route.mainSessionKey,
}),
matchedBy: "binding.channel",
},
};
}
export async function ensureConfiguredBindingRouteReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;

View File

@@ -2,6 +2,8 @@ export {
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
type ConfiguredBindingRouteResult,
resolveRuntimeConversationBindingRoute,
type RuntimeConversationBindingRouteResult,
} from "../channels/plugins/binding-routing.js";
export {
type SessionBindingRecord,

View File

@@ -13,6 +13,8 @@ export {
ensureConfiguredBindingRouteReady,
resolveConfiguredBindingRoute,
type ConfiguredBindingRouteResult,
resolveRuntimeConversationBindingRoute,
type RuntimeConversationBindingRouteResult,
} from "../channels/plugins/binding-routing.js";
export {
primeConfiguredBindingRegistry,