mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:40:44 +00:00
fix(slack): honor focused thread bindings
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,6 +2,8 @@ export {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
type ConfiguredBindingRouteResult,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
type RuntimeConversationBindingRouteResult,
|
||||
} from "../channels/plugins/binding-routing.js";
|
||||
export {
|
||||
type SessionBindingRecord,
|
||||
|
||||
@@ -13,6 +13,8 @@ export {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
type ConfiguredBindingRouteResult,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
type RuntimeConversationBindingRouteResult,
|
||||
} from "../channels/plugins/binding-routing.js";
|
||||
export {
|
||||
primeConfiguredBindingRegistry,
|
||||
|
||||
Reference in New Issue
Block a user