diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ff2007352e..2ce3b2393fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index a40f8fb6d84..ff01e085e7d 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -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 = 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", () => { diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index bfc20340413..19e533e43e1 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -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; + runtimeBinding: RuntimeConversationBindingRouteResult["bindingRecord"]; chatType: "direct" | "group" | "channel"; replyToMode: ReturnType; threadContext: ReturnType; @@ -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 ?? ""); diff --git a/src/channels/plugins/binding-routing.ts b/src/channels/plugins/binding-routing.ts index cbfb84b37b2..cf06eb4668f 100644 --- a/src/channels/plugins/binding-routing.ts +++ b/src/channels/plugins/binding-routing.ts @@ -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; diff --git a/src/plugin-sdk/conversation-binding-runtime.ts b/src/plugin-sdk/conversation-binding-runtime.ts index a3ffeab7005..86d84a95450 100644 --- a/src/plugin-sdk/conversation-binding-runtime.ts +++ b/src/plugin-sdk/conversation-binding-runtime.ts @@ -2,6 +2,8 @@ export { ensureConfiguredBindingRouteReady, resolveConfiguredBindingRoute, type ConfiguredBindingRouteResult, + resolveRuntimeConversationBindingRoute, + type RuntimeConversationBindingRouteResult, } from "../channels/plugins/binding-routing.js"; export { type SessionBindingRecord, diff --git a/src/plugin-sdk/conversation-runtime.ts b/src/plugin-sdk/conversation-runtime.ts index 5658036e285..51a7fea2a77 100644 --- a/src/plugin-sdk/conversation-runtime.ts +++ b/src/plugin-sdk/conversation-runtime.ts @@ -13,6 +13,8 @@ export { ensureConfiguredBindingRouteReady, resolveConfiguredBindingRoute, type ConfiguredBindingRouteResult, + resolveRuntimeConversationBindingRoute, + type RuntimeConversationBindingRouteResult, } from "../channels/plugins/binding-routing.js"; export { primeConfiguredBindingRegistry,