feat(plugin-sdk): add conversation binding hooks

This commit is contained in:
Peter Steinberger
2026-04-24 04:17:01 +01:00
parent ffa5f4514f
commit 5d0887574b
17 changed files with 282 additions and 14 deletions

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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,
};
}

View File

@@ -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(

View File

@@ -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() };

View File

@@ -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,

View File

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

View File

@@ -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({

View File

@@ -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"],
}),
}),

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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"',

View File

@@ -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",

View File

@@ -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,
},

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -231,6 +231,7 @@ export type PluginHookAfterCompactionEvent = {
export type PluginHookInboundClaimResult = {
handled: boolean;
reply?: ReplyPayload;
};
export type PluginHookBeforeDispatchEvent = {