diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 627b848d38c..daacb10133e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/src/auto-reply/reply/commands-context.test.ts b/src/auto-reply/reply/commands-context.test.ts index f58fec5e075..49cb84ed7ea 100644 --- a/src/auto-reply/reply/commands-context.test.ts +++ b/src/auto-reply/reply/commands-context.test.ts @@ -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"); + }); }); diff --git a/src/auto-reply/reply/commands-context.ts b/src/auto-reply/reply/commands-context.ts index 326704afd2c..1322301eaf2 100644 --- a/src/auto-reply/reply/commands-context.ts +++ b/src/auto-reply/reply/commands-context.ts @@ -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, }; } diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 6355c62825d..95f880e5ccf 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -302,6 +302,7 @@ vi.mock("../../plugins/conversation-binding.js", () => ({ pluginId?: string; pluginName?: string; pluginRoot?: string; + data?: Record; }; 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( diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 9e1380030c4..da8d672c9c8 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -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() }; diff --git a/src/auto-reply/reply/get-reply-fast-path.ts b/src/auto-reply/reply/get-reply-fast-path.ts index 3be3315fca4..9162a8e2163 100644 --- a/src/auto-reply/reply/get-reply-fast-path.ts +++ b/src/auto-reply/reply/get-reply-fast-path.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 8b277b8868a..c79e918c5ca 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -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"); diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 80e79a26ac6..cea6b4707f4 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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({ diff --git a/src/hooks/message-hook-mappers.test.ts b/src/hooks/message-hook-mappers.test.ts index c4cb5491348..f54101a1f5d 100644 --- a/src/hooks/message-hook-mappers.test.ts +++ b/src/hooks/message-hook-mappers.test.ts @@ -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"], }), }), diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index e3a09756212..25b73cd60af 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -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, diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index a444040bd00..f00e1e8788f 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -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 }; diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index f6378cb94b0..2d80c34597c 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -10,6 +10,7 @@ const tsFilesCache = new Map(); 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"', diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 0ae7f16edbe..b34c398cf49 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -218,6 +218,7 @@ function createCodexBindRequest(params: { parentConversationId?: string; threadId?: string; detachHint?: string; + data?: Record; }) { 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", diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index 79bd6094b46..8b124fecdb3 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -74,6 +74,7 @@ type PendingPluginBindingRequest = { requestedBySenderId?: string; summary?: string; detachHint?: string; + data?: Record; }; type PluginBindingApprovalAction = { @@ -94,6 +95,7 @@ type PluginBindingMetadata = { pluginRoot: string; summary?: string; detachHint?: string; + data?: Record; }; type PluginBindingResolveResult = @@ -174,6 +176,13 @@ function normalizeConversation(params: PluginBindingConversation): PluginBinding }; } +function normalizeBindingData(data: unknown): Record | undefined { + if (!data || typeof data !== "object" || Array.isArray(data)) { + return undefined; + } + return { ...(data as Record) }; +} + 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; }): 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; }): Promise { 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 { 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; }): Promise { 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, }, diff --git a/src/plugins/conversation-binding.types.ts b/src/plugins/conversation-binding.types.ts index efe5f444b94..a59dd7921e0 100644 --- a/src/plugins/conversation-binding.types.ts +++ b/src/plugins/conversation-binding.types.ts @@ -3,6 +3,7 @@ import type { ReplyPayload } from "../auto-reply/reply-payload.js"; export type PluginConversationBindingRequestParams = { summary?: string; detachHint?: string; + data?: Record; }; export type PluginConversationBindingResolutionDecision = "allow-once" | "allow-always" | "deny"; @@ -20,6 +21,7 @@ export type PluginConversationBinding = { boundAt: number; summary?: string; detachHint?: string; + data?: Record; }; export type PluginConversationBindingRequestResult = @@ -44,6 +46,7 @@ export type PluginConversationBindingResolvedEvent = { request: { summary?: string; detachHint?: string; + data?: Record; requestedBySenderId?: string; conversation: { channel: string; diff --git a/src/plugins/hook-message.types.ts b/src/plugins/hook-message.types.ts index ddbf30b059c..0d7ec454683 100644 --- a/src/plugins/hook-message.types.ts +++ b/src/plugins/hook-message.types.ts @@ -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 = { diff --git a/src/plugins/hook-types.ts b/src/plugins/hook-types.ts index 05e8b567b02..2e798c88433 100644 --- a/src/plugins/hook-types.ts +++ b/src/plugins/hook-types.ts @@ -231,6 +231,7 @@ export type PluginHookAfterCompactionEvent = { export type PluginHookInboundClaimResult = { handled: boolean; + reply?: ReplyPayload; }; export type PluginHookBeforeDispatchEvent = {