mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:30:47 +00:00
feat(plugin-sdk): add conversation binding hooks
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
96905c33f4498446f612ae17dee6affdf84ef0e2e5a0f25bf7191c315f5b826f plugin-sdk-api-baseline.json
|
||||
d8eb6331562fde29531eaac18409bb7fabcc70623bf25395f8e5710a49765f0f plugin-sdk-api-baseline.jsonl
|
||||
5949119eccfa6ccc1bca232b9cf6bb1df0bd4b5eb53f8314db59c95bd8fcb2b0 plugin-sdk-api-baseline.json
|
||||
f2827b8c1078eef3ba84b12cafab560c42516bfc8af20c8a5bdd4b6fcee5158a plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -50,4 +50,33 @@ describe("buildCommandContext", () => {
|
||||
|
||||
expect(result.commandBodyNormalized).toBe("/reset soft re-read persona files");
|
||||
});
|
||||
|
||||
it("maps explicit gateway origin into command context", () => {
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "internal",
|
||||
Surface: "internal",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "user:U123",
|
||||
SenderId: "gateway-client",
|
||||
From: undefined,
|
||||
To: undefined,
|
||||
Body: "/codex bind",
|
||||
RawBody: "/codex bind",
|
||||
CommandBody: "/codex bind",
|
||||
BodyForCommands: "/codex bind",
|
||||
});
|
||||
|
||||
const result = buildCommandContext({
|
||||
ctx,
|
||||
cfg: {} as OpenClawConfig,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: "/codex bind",
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.channel).toBe("slack");
|
||||
expect(result.channelId).toBe("slack");
|
||||
expect(result.from).toBe("gateway-client");
|
||||
expect(result.to).toBe("user:U123");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { normalizeAnyChannelId } from "../../channels/registry.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "../../shared/string-coerce.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import { normalizeCommandBody } from "../commands-registry-normalize.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
@@ -22,8 +26,15 @@ export function buildCommandContext(params: {
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
});
|
||||
const surface = normalizeLowercaseStringOrEmpty(ctx.Surface ?? ctx.Provider);
|
||||
const channel = normalizeLowercaseStringOrEmpty(ctx.Provider ?? surface);
|
||||
const abortKey = sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
|
||||
const channel = normalizeLowercaseStringOrEmpty(
|
||||
ctx.OriginatingChannel ?? ctx.Provider ?? surface,
|
||||
);
|
||||
const from = auth.from ?? normalizeOptionalString(ctx.SenderId);
|
||||
const to = auth.to ?? normalizeOptionalString(ctx.OriginatingTo);
|
||||
const abortKey = sessionKey ?? from ?? to;
|
||||
const channelId =
|
||||
normalizeAnyChannelId(channel) ??
|
||||
(channel ? (channel as CommandContext["channelId"]) : undefined);
|
||||
const rawBodyNormalized = triggerBodyNormalized;
|
||||
const commandBodyNormalized = normalizeCommandBody(
|
||||
isGroup ? stripMentions(rawBodyNormalized, ctx, cfg, agentId) : rawBodyNormalized,
|
||||
@@ -33,7 +44,7 @@ export function buildCommandContext(params: {
|
||||
return {
|
||||
surface,
|
||||
channel,
|
||||
channelId: auth.providerId,
|
||||
channelId: channelId ?? auth.providerId,
|
||||
ownerList: auth.ownerList,
|
||||
senderIsOwner: auth.senderIsOwner,
|
||||
isAuthorizedSender: auth.isAuthorizedSender,
|
||||
@@ -41,7 +52,7 @@ export function buildCommandContext(params: {
|
||||
abortKey,
|
||||
rawBodyNormalized,
|
||||
commandBodyNormalized,
|
||||
from: auth.from,
|
||||
to: auth.to,
|
||||
from,
|
||||
to,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -302,6 +302,7 @@ vi.mock("../../plugins/conversation-binding.js", () => ({
|
||||
pluginId?: string;
|
||||
pluginName?: string;
|
||||
pluginRoot?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
return {
|
||||
bindingId: record.bindingId,
|
||||
@@ -312,6 +313,7 @@ vi.mock("../../plugins/conversation-binding.js", () => ({
|
||||
accountId: record.conversation.accountId,
|
||||
conversationId: record.conversation.conversationId,
|
||||
parentConversationId: record.conversation.parentConversationId,
|
||||
data: metadata.data,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -2545,6 +2547,12 @@ describe("dispatchReplyFromConfig", () => {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/workspace/openclaw",
|
||||
},
|
||||
},
|
||||
} satisfies SessionBindingRecord);
|
||||
const cfg = emptyConfig;
|
||||
@@ -2584,12 +2592,74 @@ describe("dispatchReplyFromConfig", () => {
|
||||
channelId: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:1481858418548412579",
|
||||
pluginBinding: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
kind: "codex-app-server-session",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(hookMocks.runner.runInboundClaim).not.toHaveBeenCalled();
|
||||
expect(replyResolver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers plugin-owned binding replies returned by the owning inbound claim hook", async () => {
|
||||
setNoAbort();
|
||||
hookMocks.runner.hasHooks.mockImplementation(
|
||||
((hookName?: string) =>
|
||||
hookName === "inbound_claim" || hookName === "message_received") as () => boolean,
|
||||
);
|
||||
hookMocks.registry.plugins = [{ id: "codex", status: "loaded" }];
|
||||
hookMocks.runner.runInboundClaimForPluginOutcome.mockResolvedValue({
|
||||
status: "handled",
|
||||
result: { handled: true, reply: { text: "Codex native reply" } },
|
||||
});
|
||||
sessionBindingMocks.resolveByConversation.mockReturnValue({
|
||||
bindingId: "binding-reply-1",
|
||||
targetSessionKey: "plugin-binding:codex:reply123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "channel:1481858418548412579",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 1710000000000,
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugins/codex",
|
||||
},
|
||||
} satisfies SessionBindingRecord);
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "discord:channel:1481858418548412579",
|
||||
To: "discord:channel:1481858418548412579",
|
||||
AccountId: "default",
|
||||
SenderId: "user-9",
|
||||
SenderUsername: "ada",
|
||||
CommandAuthorized: true,
|
||||
WasMentioned: false,
|
||||
CommandBody: "who are you",
|
||||
RawBody: "who are you",
|
||||
Body: "who are you",
|
||||
MessageSid: "msg-claim-plugin-reply",
|
||||
SessionKey: "agent:main:discord:channel:1481858418548412579",
|
||||
});
|
||||
const replyResolver = vi.fn(async () => ({ text: "should not run" }) satisfies ReplyPayload);
|
||||
|
||||
const result = await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(result).toEqual({ queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } });
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "Codex native reply" });
|
||||
expect(replyResolver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes plugin-owned Discord DM bindings to the owning plugin before generic inbound claim broadcast", async () => {
|
||||
setNoAbort();
|
||||
hookMocks.runner.hasHooks.mockImplementation(
|
||||
|
||||
@@ -512,7 +512,7 @@ export async function dispatchReplyFromConfig(
|
||||
? await hookRunner.runInboundClaimForPluginOutcome(
|
||||
pluginOwnedBinding.pluginId,
|
||||
inboundClaimEvent,
|
||||
inboundClaimContext,
|
||||
{ ...inboundClaimContext, pluginBinding: pluginOwnedBinding },
|
||||
)
|
||||
: (() => {
|
||||
const pluginLoaded =
|
||||
@@ -526,6 +526,9 @@ export async function dispatchReplyFromConfig(
|
||||
|
||||
switch (targetedClaimOutcome.status) {
|
||||
case "handled": {
|
||||
if (targetedClaimOutcome.result.reply) {
|
||||
await sendBindingNotice(targetedClaimOutcome.result.reply, "terminal");
|
||||
}
|
||||
markIdle("plugin_binding_dispatch");
|
||||
recordProcessed("completed", { reason: "plugin-bound-handled" });
|
||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||
|
||||
@@ -163,10 +163,12 @@ export function buildFastReplyCommandContext(params: {
|
||||
}): CommandContext {
|
||||
const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized, commandAuthorized } =
|
||||
params;
|
||||
const originatingChannel = normalizeOptionalLowercaseString(ctx.OriginatingChannel);
|
||||
const surface = normalizeOptionalLowercaseString(ctx.Surface ?? ctx.Provider) ?? "";
|
||||
const channel = normalizeOptionalLowercaseString(ctx.Provider ?? surface) ?? "";
|
||||
const from = normalizeOptionalString(ctx.From);
|
||||
const to = normalizeOptionalString(ctx.To);
|
||||
const channel =
|
||||
originatingChannel ?? normalizeOptionalLowercaseString(ctx.Provider ?? surface) ?? "";
|
||||
const from = normalizeOptionalString(ctx.From ?? ctx.SenderId);
|
||||
const to = normalizeOptionalString(ctx.To ?? ctx.OriginatingTo);
|
||||
return {
|
||||
surface,
|
||||
channel,
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
buildFastReplyCommandContext,
|
||||
initFastReplySessionState,
|
||||
markCompleteReplyConfig,
|
||||
withFastReplyConfig,
|
||||
@@ -146,6 +147,30 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
expect(result.sessionCtx.SessionKey).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
it("maps explicit gateway origin into command context", () => {
|
||||
const command = buildFastReplyCommandContext({
|
||||
ctx: buildGetReplyCtx({
|
||||
Provider: "internal",
|
||||
Surface: "internal",
|
||||
OriginatingChannel: "slack",
|
||||
OriginatingTo: "user:U123",
|
||||
From: undefined,
|
||||
To: undefined,
|
||||
SenderId: "gateway-client",
|
||||
}),
|
||||
cfg: {} as OpenClawConfig,
|
||||
sessionKey: "main",
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: "/codex bind",
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(command.channel).toBe("slack");
|
||||
expect(command.channelId).toBe("slack");
|
||||
expect(command.from).toBe("gateway-client");
|
||||
expect(command.to).toBe("user:U123");
|
||||
});
|
||||
|
||||
it("keeps the existing session for /reset newline soft during fast bootstrap", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-newline-soft-"));
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
@@ -24,6 +24,7 @@ import { isAudioFileName } from "../../media/mime.js";
|
||||
import type { PromptImageOrderEntry } from "../../media/prompt-image-order.js";
|
||||
import { type SavedMedia, saveMediaBuffer } from "../../media/store.js";
|
||||
import { createChannelReplyPipeline } from "../../plugin-sdk/channel-reply-pipeline.js";
|
||||
import { isPluginOwnedSessionBindingRecord } from "../../plugins/conversation-binding.js";
|
||||
import { normalizeInputProvenance, type InputProvenance } from "../../sessions/input-provenance.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
||||
@@ -603,6 +604,22 @@ function explicitOriginTargetsAcpSession(origin: ChatSendExplicitOrigin | undefi
|
||||
return isAcpSessionKey(binding?.targetSessionKey);
|
||||
}
|
||||
|
||||
function explicitOriginTargetsPluginBinding(origin: ChatSendExplicitOrigin | undefined): boolean {
|
||||
if (!origin?.originatingChannel || !origin.originatingTo || !origin.accountId) {
|
||||
return false;
|
||||
}
|
||||
const channel = normalizeMessageChannel(origin.originatingChannel);
|
||||
if (!channel || channel === INTERNAL_MESSAGE_CHANNEL) {
|
||||
return false;
|
||||
}
|
||||
const binding = getSessionBindingService().resolveByConversation({
|
||||
channel,
|
||||
accountId: origin.accountId,
|
||||
conversationId: origin.originatingTo,
|
||||
});
|
||||
return isPluginOwnedSessionBindingRecord(binding);
|
||||
}
|
||||
|
||||
function stripDisallowedChatControlChars(message: string): string {
|
||||
let output = "";
|
||||
for (const char of message) {
|
||||
@@ -2181,6 +2198,9 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const explicitOriginTargetsPlugin = explicitOriginTargetsPluginBinding(
|
||||
explicitOriginResult.value,
|
||||
);
|
||||
if (normalizedAttachments.length > 0) {
|
||||
const modelRef = resolveSessionModelRef(cfg, entry, agentId);
|
||||
const supportsSessionModelImages = await resolveGatewayModelSupportsImages({
|
||||
@@ -2188,8 +2208,12 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.model,
|
||||
});
|
||||
// Bound plugin sessions own the real recipient model, so keep image
|
||||
// attachments even when the parent OpenClaw session model is text-only.
|
||||
const supportsImages =
|
||||
supportsSessionModelImages || explicitOriginTargetsAcpSession(explicitOriginResult.value);
|
||||
supportsSessionModelImages ||
|
||||
explicitOriginTargetsAcpSession(explicitOriginResult.value) ||
|
||||
explicitOriginTargetsPlugin;
|
||||
try {
|
||||
const parsed = await parseMessageWithAttachments(inboundMessage, normalizedAttachments, {
|
||||
maxBytes: 5_000_000,
|
||||
@@ -2236,6 +2260,10 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
client,
|
||||
logGateway: context.logGateway,
|
||||
});
|
||||
const pluginBoundMediaFields =
|
||||
explicitOriginTargetsPlugin && parsedImages.length > 0
|
||||
? resolveChatSendTranscriptMediaFields(await persistedImagesPromise)
|
||||
: {};
|
||||
|
||||
const trimmedMessage = parsedMessage.trim();
|
||||
const injectThinking = Boolean(
|
||||
@@ -2288,6 +2316,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
SenderName: clientInfo?.displayName,
|
||||
SenderUsername: clientInfo?.displayName,
|
||||
GatewayClientScopes: client?.connect?.scopes ?? [],
|
||||
...pluginBoundMediaFields,
|
||||
};
|
||||
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
|
||||
@@ -109,22 +109,31 @@ describe("message hook mappers", () => {
|
||||
const canonical = deriveInboundMessageHookContext(
|
||||
makeInboundCtx({
|
||||
MediaPath: undefined,
|
||||
MediaUrl: undefined,
|
||||
MediaType: undefined,
|
||||
MediaPaths: ["/tmp/tree.jpg", "/tmp/ramp.jpg"],
|
||||
MediaUrls: ["https://example.test/tree.jpg", "https://example.test/ramp.jpg"],
|
||||
MediaTypes: ["image/jpeg", "image/jpeg"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(canonical.mediaPath).toBe("/tmp/tree.jpg");
|
||||
expect(canonical.mediaUrl).toBe("https://example.test/tree.jpg");
|
||||
expect(canonical.mediaType).toBe("image/jpeg");
|
||||
expect(canonical.mediaPaths).toEqual(["/tmp/tree.jpg", "/tmp/ramp.jpg"]);
|
||||
expect(canonical.mediaUrls).toEqual([
|
||||
"https://example.test/tree.jpg",
|
||||
"https://example.test/ramp.jpg",
|
||||
]);
|
||||
expect(canonical.mediaTypes).toEqual(["image/jpeg", "image/jpeg"]);
|
||||
expect(toPluginInboundClaimEvent(canonical)).toEqual(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
mediaPath: "/tmp/tree.jpg",
|
||||
mediaUrl: "https://example.test/tree.jpg",
|
||||
mediaType: "image/jpeg",
|
||||
mediaPaths: ["/tmp/tree.jpg", "/tmp/ramp.jpg"],
|
||||
mediaUrls: ["https://example.test/tree.jpg", "https://example.test/ramp.jpg"],
|
||||
mediaTypes: ["image/jpeg", "image/jpeg"],
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -38,9 +38,13 @@ export type CanonicalInboundMessageHookContext = {
|
||||
provider?: string;
|
||||
surface?: string;
|
||||
threadId?: string | number;
|
||||
// `mediaPath(s)` are files OpenClaw has already staged locally. `mediaUrl(s)`
|
||||
// are provider/media-server references that may not exist on this host.
|
||||
mediaPath?: string;
|
||||
mediaUrl?: string;
|
||||
mediaType?: string;
|
||||
mediaPaths?: string[];
|
||||
mediaUrls?: string[];
|
||||
mediaTypes?: string[];
|
||||
originatingChannel?: string;
|
||||
originatingTo?: string;
|
||||
@@ -95,6 +99,11 @@ export function deriveInboundMessageHookContext(
|
||||
(value): value is string => typeof value === "string" && value.length > 0,
|
||||
)
|
||||
: undefined;
|
||||
const mediaUrls = Array.isArray(ctx.MediaUrls)
|
||||
? ctx.MediaUrls.filter(
|
||||
(value): value is string => typeof value === "string" && value.length > 0,
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
from: ctx.From ?? "",
|
||||
to: ctx.To,
|
||||
@@ -123,8 +132,10 @@ export function deriveInboundMessageHookContext(
|
||||
surface: ctx.Surface,
|
||||
threadId: ctx.MessageThreadId,
|
||||
mediaPath: ctx.MediaPath ?? mediaPaths?.[0],
|
||||
mediaUrl: ctx.MediaUrl ?? mediaUrls?.[0],
|
||||
mediaType: ctx.MediaType ?? mediaTypes?.[0],
|
||||
mediaPaths,
|
||||
mediaUrls,
|
||||
mediaTypes,
|
||||
originatingChannel: ctx.OriginatingChannel,
|
||||
originatingTo: ctx.OriginatingTo,
|
||||
@@ -262,8 +273,10 @@ export function toPluginInboundClaimEvent(
|
||||
originatingTo: canonical.originatingTo,
|
||||
senderE164: canonical.senderE164,
|
||||
mediaPath: canonical.mediaPath,
|
||||
mediaUrl: canonical.mediaUrl,
|
||||
mediaType: canonical.mediaType,
|
||||
mediaPaths: canonical.mediaPaths,
|
||||
mediaUrls: canonical.mediaUrls,
|
||||
mediaTypes: canonical.mediaTypes,
|
||||
guildId: canonical.guildId,
|
||||
channelName: canonical.channelName,
|
||||
|
||||
@@ -70,6 +70,7 @@ import type {
|
||||
ProviderWrapStreamFnContext,
|
||||
SpeechProviderPlugin,
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
} from "../plugins/types.js";
|
||||
import { createCachedLazyValueGetter } from "./lazy-value.js";
|
||||
|
||||
@@ -85,6 +86,7 @@ export type {
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
PluginCommandContext,
|
||||
PluginCommandResult,
|
||||
OpenClawPluginConfigSchema,
|
||||
ProviderDiscoveryContext,
|
||||
ProviderCatalogContext,
|
||||
@@ -143,6 +145,17 @@ export type {
|
||||
OpenClawPluginDefinition,
|
||||
PluginLogger,
|
||||
};
|
||||
export type {
|
||||
PluginConversationBinding,
|
||||
PluginConversationBindingResolvedEvent,
|
||||
PluginConversationBindingRequestParams,
|
||||
PluginConversationBindingRequestResult,
|
||||
} from "../plugins/conversation-binding.types.js";
|
||||
export type {
|
||||
PluginHookInboundClaimContext,
|
||||
PluginHookInboundClaimEvent,
|
||||
PluginHookInboundClaimResult,
|
||||
} from "../plugins/hook-types.js";
|
||||
export type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
|
||||
export type { OpenClawConfig };
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const tsFilesCache = new Map<string, string[]>();
|
||||
const BUNDLED_TYPED_HOOK_REGISTRATION_FILES = [
|
||||
"extensions/acpx/index.ts",
|
||||
"extensions/active-memory/index.ts",
|
||||
"extensions/codex/index.ts",
|
||||
"extensions/diffs/src/plugin.ts",
|
||||
"extensions/discord/subagent-hooks-api.ts",
|
||||
"extensions/feishu/subagent-hooks-api.ts",
|
||||
@@ -22,6 +23,7 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_FILES = [
|
||||
const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = {
|
||||
"extensions/acpx/index.ts": ["reply_dispatch"],
|
||||
"extensions/active-memory/index.ts": ["before_prompt_build"],
|
||||
"extensions/codex/index.ts": ["inbound_claim"],
|
||||
"extensions/diffs/src/plugin.ts": ["before_prompt_build"],
|
||||
"extensions/discord/subagent-hooks-api.ts": [
|
||||
"subagent_delivery_target",
|
||||
@@ -48,6 +50,7 @@ const BUNDLED_TYPED_HOOK_REGISTRATION_GUARDS = {
|
||||
>;
|
||||
const BUNDLED_LIVE_CONFIG_HOOK_GUARDS = {
|
||||
"extensions/active-memory/index.ts": ["resolveLivePluginConfigObject(", '"active-memory"'],
|
||||
"extensions/codex/index.ts": ["resolveLivePluginConfigObject(", '"codex"'],
|
||||
"extensions/diffs/src/plugin.ts": [
|
||||
"resolveLivePluginConfigObject(",
|
||||
'"diffs"',
|
||||
|
||||
@@ -218,6 +218,7 @@ function createCodexBindRequest(params: {
|
||||
parentConversationId?: string;
|
||||
threadId?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) {
|
||||
return {
|
||||
pluginId: params.pluginId ?? "codex",
|
||||
@@ -234,6 +235,7 @@ function createCodexBindRequest(params: {
|
||||
binding: {
|
||||
summary: params.summary,
|
||||
...(params.detachHint ? { detachHint: params.detachHint } : {}),
|
||||
...(params.data ? { data: params.data } : {}),
|
||||
},
|
||||
} satisfies PluginBindingRequestInput;
|
||||
}
|
||||
@@ -621,6 +623,37 @@ describe("plugin conversation binding approvals", () => {
|
||||
expect(currentBinding?.detachHint).toBe("/codex_detach");
|
||||
});
|
||||
|
||||
it("persists plugin-owned binding data on approved plugin bindings", async () => {
|
||||
const data = {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile: "/tmp/openclaw/session.jsonl",
|
||||
workspaceDir: "/workspace/openclaw",
|
||||
};
|
||||
const binding = await requestResolvedBinding(
|
||||
createCodexBindRequest({
|
||||
channel: "discord",
|
||||
accountId: "isolated",
|
||||
conversationId: "channel:binding-data",
|
||||
summary: "Bind this conversation to Codex thread 999.",
|
||||
data,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(binding.data).toEqual(data);
|
||||
|
||||
const currentBinding = await getCurrentPluginConversationBinding({
|
||||
pluginRoot: "/plugins/codex-a",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "isolated",
|
||||
conversationId: "channel:binding-data",
|
||||
},
|
||||
});
|
||||
|
||||
expect(currentBinding?.data).toEqual(data);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "notifies the owning plugin when a bind approval is approved",
|
||||
|
||||
@@ -74,6 +74,7 @@ type PendingPluginBindingRequest = {
|
||||
requestedBySenderId?: string;
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type PluginBindingApprovalAction = {
|
||||
@@ -94,6 +95,7 @@ type PluginBindingMetadata = {
|
||||
pluginRoot: string;
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type PluginBindingResolveResult =
|
||||
@@ -174,6 +176,13 @@ function normalizeConversation(params: PluginBindingConversation): PluginBinding
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBindingData(data: unknown): Record<string, unknown> | undefined {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
return undefined;
|
||||
}
|
||||
return { ...(data as Record<string, unknown>) };
|
||||
}
|
||||
|
||||
function toConversationRef(params: PluginBindingConversation): ConversationRef {
|
||||
const normalized = normalizeConversation(params);
|
||||
const channelId = normalizeChannelId(normalized.channel);
|
||||
@@ -425,6 +434,7 @@ function buildBindingMetadata(params: {
|
||||
pluginRoot: string;
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}): PluginBindingMetadata {
|
||||
return {
|
||||
pluginBindingOwner: PLUGIN_BINDING_OWNER,
|
||||
@@ -433,6 +443,7 @@ function buildBindingMetadata(params: {
|
||||
pluginRoot: params.pluginRoot,
|
||||
summary: normalizeOptionalString(params.summary),
|
||||
detachHint: normalizeOptionalString(params.detachHint),
|
||||
data: normalizeBindingData(params.data),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -486,6 +497,7 @@ export function toPluginConversationBinding(
|
||||
boundAt: record.boundAt,
|
||||
summary: metadata.summary,
|
||||
detachHint: metadata.detachHint,
|
||||
data: metadata.data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -532,19 +544,21 @@ function bindConversationFromIdentity(params: {
|
||||
conversation: PluginBindingConversation;
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}): Promise<PluginConversationBinding> {
|
||||
return bindConversationNow({
|
||||
identity: buildPluginBindingIdentity(params.identity),
|
||||
conversation: params.conversation,
|
||||
summary: params.summary,
|
||||
detachHint: params.detachHint,
|
||||
data: params.data,
|
||||
});
|
||||
}
|
||||
|
||||
function bindConversationFromRequest(
|
||||
request: Pick<
|
||||
PendingPluginBindingRequest,
|
||||
"pluginId" | "pluginName" | "pluginRoot" | "conversation" | "summary" | "detachHint"
|
||||
"pluginId" | "pluginName" | "pluginRoot" | "conversation" | "summary" | "detachHint" | "data"
|
||||
>,
|
||||
): Promise<PluginConversationBinding> {
|
||||
return bindConversationFromIdentity({
|
||||
@@ -552,6 +566,7 @@ function bindConversationFromRequest(
|
||||
conversation: request.conversation,
|
||||
summary: request.summary,
|
||||
detachHint: request.detachHint,
|
||||
data: request.data,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -577,6 +592,7 @@ async function bindConversationNow(params: {
|
||||
conversation: PluginBindingConversation;
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}): Promise<PluginConversationBinding> {
|
||||
const ref = toConversationRef(params.conversation);
|
||||
const targetSessionKey = buildPluginBindingSessionKey({
|
||||
@@ -596,6 +612,7 @@ async function bindConversationNow(params: {
|
||||
pluginRoot: params.identity.pluginRoot,
|
||||
summary: params.summary,
|
||||
detachHint: params.detachHint,
|
||||
data: params.data,
|
||||
}),
|
||||
});
|
||||
const binding = toPluginConversationBinding(record);
|
||||
@@ -765,6 +782,7 @@ export async function requestPluginConversationBinding(params: {
|
||||
conversation,
|
||||
summary: params.binding?.summary,
|
||||
detachHint: params.binding?.detachHint,
|
||||
data: params.binding?.data,
|
||||
});
|
||||
logPluginBindingLifecycleEvent({
|
||||
event: "auto-refresh",
|
||||
@@ -789,6 +807,7 @@ export async function requestPluginConversationBinding(params: {
|
||||
conversation,
|
||||
summary: params.binding?.summary,
|
||||
detachHint: params.binding?.detachHint,
|
||||
data: params.binding?.data,
|
||||
});
|
||||
logPluginBindingLifecycleEvent({
|
||||
event: "auto-approved",
|
||||
@@ -811,6 +830,7 @@ export async function requestPluginConversationBinding(params: {
|
||||
requestedBySenderId: normalizeOptionalString(params.requestedBySenderId),
|
||||
summary: normalizeOptionalString(params.binding?.summary),
|
||||
detachHint: normalizeOptionalString(params.binding?.detachHint),
|
||||
data: normalizeBindingData(params.binding?.data),
|
||||
};
|
||||
pendingRequests.set(request.id, request);
|
||||
logPluginBindingLifecycleEvent({
|
||||
@@ -955,6 +975,7 @@ async function notifyPluginConversationBindingResolved(params: {
|
||||
request: {
|
||||
summary: params.request.summary,
|
||||
detachHint: params.request.detachHint,
|
||||
data: params.request.data,
|
||||
requestedBySenderId: params.request.requestedBySenderId,
|
||||
conversation: params.request.conversation,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReplyPayload } from "../auto-reply/reply-payload.js";
|
||||
export type PluginConversationBindingRequestParams = {
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PluginConversationBindingResolutionDecision = "allow-once" | "allow-always" | "deny";
|
||||
@@ -20,6 +21,7 @@ export type PluginConversationBinding = {
|
||||
boundAt: number;
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PluginConversationBindingRequestResult =
|
||||
@@ -44,6 +46,7 @@ export type PluginConversationBindingResolvedEvent = {
|
||||
request: {
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
data?: Record<string, unknown>;
|
||||
requestedBySenderId?: string;
|
||||
conversation: {
|
||||
channel: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PluginConversationBinding } from "./conversation-binding.types.js";
|
||||
|
||||
export type PluginHookMessageContext = {
|
||||
channelId: string;
|
||||
accountId?: string;
|
||||
@@ -8,6 +10,7 @@ export type PluginHookInboundClaimContext = PluginHookMessageContext & {
|
||||
parentConversationId?: string;
|
||||
senderId?: string;
|
||||
messageId?: string;
|
||||
pluginBinding?: PluginConversationBinding;
|
||||
};
|
||||
|
||||
export type PluginHookInboundClaimEvent = {
|
||||
|
||||
@@ -231,6 +231,7 @@ export type PluginHookAfterCompactionEvent = {
|
||||
|
||||
export type PluginHookInboundClaimResult = {
|
||||
handled: boolean;
|
||||
reply?: ReplyPayload;
|
||||
};
|
||||
|
||||
export type PluginHookBeforeDispatchEvent = {
|
||||
|
||||
Reference in New Issue
Block a user