diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index b76270b671e2..97340a1502c6 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -99,7 +99,7 @@ ea56ea0455c62f292e1c7b3e56cac6eb3fea00e548f9ab2f4a69e0b41b0a2d96 module/memory- 8d8c0c4ebfc6e0c6125df3ae64ec66c10cd797a325de5ea7869fcd84068ab290 module/persistent-dedupe 987648ebe317cc4d6c0505a52d344b12ce9c3a8261a5bd969cee3423c0c53529 module/plugin-config-runtime d7dd3c82a4b1df9e4144b078caf0e6ab95e7b11c5046b53a38f01143a7fa0eec module/plugin-entry -8bc85c9f7df434c87cd7d35cc33080e2577a1893353f92bff8a0cc63d2c4eb0e module/plugin-runtime +c0894007b842d68f1dcf0ba6e9a61486d06ef820e78e194939b70a5a4b28e9cb module/plugin-runtime de508df6d9cfa9d21c4524989dd8bd142fb60cb4ae980193e0907767d76e6022 module/provider-auth 71bebeac51e701cd7c8e63d22754b9bcbd82b024aced303aa055d229781129d7 module/provider-catalog-runtime 56151035047a69e6163d5578023d00f51a2413b777f3784af88e06261c039345 module/proxy-capture diff --git a/extensions/discord/src/interactive-dispatch.ts b/extensions/discord/src/interactive-dispatch.ts index 6b98251dd281..a6321d4f7a7d 100644 --- a/extensions/discord/src/interactive-dispatch.ts +++ b/extensions/discord/src/interactive-dispatch.ts @@ -1,8 +1,7 @@ // Discord plugin module implements interactive dispatch behavior. import type { ChannelStructuredComponents } from "openclaw/plugin-sdk/channel-contract"; import { - createInteractiveConversationBindingHelpers, - dispatchPluginInteractiveHandler, + createChannelInteractiveDispatcher, type PluginConversationBinding, type PluginConversationBindingRequestParams, type PluginConversationBindingRequestResult, @@ -52,61 +51,24 @@ export type DiscordInteractiveHandlerRegistration = PluginInteractiveRegistratio "discord" >; -type DiscordInteractiveDispatchContext = Omit< - DiscordInteractiveHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - interaction: Omit< - DiscordInteractiveHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; -}; +const dispatchDiscordInteractive = createChannelInteractiveDispatcher< + "discord", + "interaction", + DiscordInteractiveHandlerContext +>({ + channel: "discord", + interactiveKey: "interaction", +}); export async function dispatchDiscordPluginInteractiveHandler(params: { data: string; interactionId: string; - ctx: DiscordInteractiveDispatchContext; + ctx: Parameters[0]["ctx"]; respond: DiscordInteractiveHandlerContext["respond"]; onMatched?: () => Promise | void; }) { - return await dispatchPluginInteractiveHandler({ - channel: "discord", - data: params.data, + return await dispatchDiscordInteractive({ + ...params, dedupeId: params.interactionId, - onMatched: params.onMatched, - invoke: ({ registration, namespace, payload }) => - registration.handler({ - ...params.ctx, - channel: "discord", - interaction: { - ...params.ctx.interaction, - data: params.data, - namespace, - payload, - }, - respond: params.respond, - ...createInteractiveConversationBindingHelpers({ - // Untrusted callbacks may reach their plugin, but never inherit conversation-binding authority. - registration: - params.ctx.auth?.isAuthorizedSender && - params.ctx.senderId?.trim() && - params.ctx.accountId?.trim() && - params.ctx.conversationId?.trim() - ? registration - : { ...registration, pluginRoot: undefined }, - senderId: params.ctx.senderId, - conversation: { - channel: "discord", - accountId: params.ctx.accountId, - conversationId: params.ctx.conversationId, - parentConversationId: params.ctx.parentConversationId, - }, - }), - }), }); } diff --git a/extensions/slack/src/interactive-dispatch.ts b/extensions/slack/src/interactive-dispatch.ts index d5618393945a..152d01977a71 100644 --- a/extensions/slack/src/interactive-dispatch.ts +++ b/extensions/slack/src/interactive-dispatch.ts @@ -1,7 +1,6 @@ // Slack plugin module implements interactive dispatch behavior. import { - createInteractiveConversationBindingHelpers, - dispatchPluginInteractiveHandler, + createChannelInteractiveDispatcher, type PluginConversationBinding, type PluginConversationBindingRequestParams, type PluginConversationBindingRequestResult, @@ -86,25 +85,21 @@ export type SlackInteractiveHandlerRegistration = PluginInteractiveRegistration< SlackInteractiveHandlerResult >; -type SlackInteractiveDispatchContext = Omit< +const dispatchSlackInteractive = createChannelInteractiveDispatcher< + "slack", + "interaction", SlackInteractiveHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - interaction: - | Omit - | Omit; -}; + SlackInteractiveHandlerResult +>({ + channel: "slack", + interactiveKey: "interaction", +}); export async function dispatchSlackPluginInteractiveHandler(params: { data: string; interactionId: string; channelType?: "im" | "mpim" | "channel" | "group"; - ctx: SlackInteractiveDispatchContext; + ctx: Parameters[0]["ctx"]; respond: SlackInteractiveHandlerContext["respond"]; onMatched?: () => Promise | void; }) { @@ -117,39 +112,17 @@ export async function dispatchSlackPluginInteractiveHandler(params: { : params.ctx.conversationId.trim(); const threadId = params.ctx.threadId?.trim() || undefined; - return await dispatchPluginInteractiveHandler({ - channel: "slack", - data: params.data, + return await dispatchSlackInteractive({ + ...params, dedupeId: params.interactionId, - onMatched: params.onMatched, - invoke: ({ registration, namespace, payload }) => - registration.handler({ - ...params.ctx, - channel: "slack", - interaction: { - ...params.ctx.interaction, - data: params.data, - namespace, - payload, - }, - respond: params.respond, - ...createInteractiveConversationBindingHelpers({ - // The shared helpers fail closed without owner authority; never expose it to unauthenticated actions. - registration: - params.ctx.auth.isAuthorizedSender && baseConversationId - ? registration - : { ...registration, pluginRoot: undefined }, - senderId: params.ctx.senderId, - conversation: { - channel: "slack", - accountId: params.ctx.accountId, - conversationId: threadId ?? baseConversationId, - parentConversationId: threadId - ? (params.ctx.parentConversationId ?? baseConversationId) - : params.ctx.parentConversationId, - threadId, - }, - }), - }), + conversation: { + channel: "slack", + accountId: params.ctx.accountId, + conversationId: threadId ?? baseConversationId, + parentConversationId: threadId + ? (params.ctx.parentConversationId ?? baseConversationId) + : params.ctx.parentConversationId, + threadId, + }, }); } diff --git a/extensions/slack/src/monitor/events/interactions.test.ts b/extensions/slack/src/monitor/events/interactions.test.ts index 9b2e3626621c..f8aaccffb0a4 100644 --- a/extensions/slack/src/monitor/events/interactions.test.ts +++ b/extensions/slack/src/monitor/events/interactions.test.ts @@ -98,13 +98,75 @@ vi.mock("openclaw/plugin-sdk/question-gateway-runtime", () => ({ }, })); -vi.mock("openclaw/plugin-sdk/plugin-runtime", () => ({ - dispatchPluginInteractiveHandler: (arg: unknown) => dispatchPluginInteractiveHandlerMock(arg), - createInteractiveConversationBindingHelpers: (params: { - registration: { pluginRoot?: string }; - conversation: Record; - }) => createInteractiveConversationBindingHelpersMock(params), -})); +vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createChannelInteractiveDispatcher: (config: { + channel: string; + interactiveKey: PropertyKey; + dispatchInteractiveKey?: PropertyKey; + }) => { + return (params: { + data: string; + dedupeId: string; + ctx: Record & { + accountId: string; + conversationId: string; + senderId?: string; + auth: { isAuthorizedSender: boolean }; + }; + respond: unknown; + conversation?: Record; + onMatched?: () => Promise | void; + afterInvoke?: (result: unknown) => Promise | void; + }) => + dispatchPluginInteractiveHandlerMock({ + channel: config.channel, + data: params.data, + dedupeId: params.dedupeId, + onMatched: params.onMatched, + afterInvoke: params.afterInvoke, + invoke: ({ + registration, + namespace, + payload, + }: { + registration: { pluginRoot?: string; handler: (ctx: unknown) => unknown }; + namespace: string; + payload: string; + }) => { + const dispatchKey = config.dispatchInteractiveKey ?? config.interactiveKey; + const handlerContext = { ...params.ctx }; + const interactiveContext = handlerContext[dispatchKey]; + delete handlerContext[dispatchKey]; + const hasBindingAuthority = + params.ctx.auth.isAuthorizedSender && + params.ctx.senderId?.trim() && + params.ctx.accountId.trim() && + params.ctx.conversationId.trim(); + return registration.handler({ + ...handlerContext, + channel: config.channel, + [config.interactiveKey]: { + ...(interactiveContext as object), + data: params.data, + namespace, + payload, + }, + respond: params.respond, + ...createInteractiveConversationBindingHelpersMock({ + registration: hasBindingAuthority + ? registration + : { ...registration, pluginRoot: undefined }, + conversation: params.conversation ?? {}, + }), + }); + }, + }); + }, + }; +}); vi.mock("../conversation.runtime.js", () => { const parsePluginBindingApprovalCustomId = (value: string) => { diff --git a/extensions/telegram/src/interactive-dispatch.ts b/extensions/telegram/src/interactive-dispatch.ts index 00b8162c517e..ba3e90a6ae10 100644 --- a/extensions/telegram/src/interactive-dispatch.ts +++ b/extensions/telegram/src/interactive-dispatch.ts @@ -1,7 +1,6 @@ // Telegram plugin module implements interactive dispatch behavior. import { - createInteractiveConversationBindingHelpers, - dispatchPluginInteractiveHandler, + createChannelInteractiveDispatcher, type PluginConversationBinding, type PluginConversationBindingRequestParams, type PluginConversationBindingRequestResult, @@ -63,78 +62,28 @@ export type TelegramInteractiveHandlerRegistration = PluginInteractiveRegistrati TelegramInteractiveHandlerResult >; -type TelegramInteractiveDispatchContext = Omit< +const dispatchTelegramInteractive = createChannelInteractiveDispatcher< + "telegram", + "callback", TelegramInteractiveHandlerContext, - | "callback" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - callbackMessage: { - messageId: number; - chatId: string; - messageText?: string; - }; -}; + TelegramInteractiveHandlerResult, + "callbackMessage" +>({ + channel: "telegram", + interactiveKey: "callback", + dispatchInteractiveKey: "callbackMessage", +}); export async function dispatchTelegramPluginInteractiveHandler(params: { data: string; callbackId: string; - ctx: TelegramInteractiveDispatchContext; - respond: { - reply: (params: { text: string; buttons?: TelegramInteractiveButtons }) => Promise; - editMessage: (params: { text: string; buttons?: TelegramInteractiveButtons }) => Promise; - editButtons: (params: { buttons: TelegramInteractiveButtons }) => Promise; - clearButtons: () => Promise; - deleteMessage: () => Promise; - }; + ctx: Parameters[0]["ctx"]; + respond: TelegramInteractiveHandlerContext["respond"]; onMatched?: () => Promise | void; afterInvoke?: (result: TelegramInteractiveHandlerResult) => Promise | void; }) { - return await dispatchPluginInteractiveHandler< - TelegramInteractiveHandlerRegistration, - TelegramInteractiveHandlerResult - >({ - channel: "telegram", - data: params.data, + return await dispatchTelegramInteractive({ + ...params, dedupeId: params.callbackId, - onMatched: params.onMatched, - afterInvoke: params.afterInvoke, - invoke: ({ registration, namespace, payload }) => { - const { callbackMessage, ...handlerContext } = params.ctx; - return registration.handler({ - ...handlerContext, - channel: "telegram", - callback: { - data: params.data, - namespace, - payload, - messageId: callbackMessage.messageId, - chatId: callbackMessage.chatId, - messageText: callbackMessage.messageText, - }, - respond: params.respond, - ...createInteractiveConversationBindingHelpers({ - // Untrusted callbacks may reach their plugin, but never inherit conversation-binding authority. - registration: - handlerContext.auth?.isAuthorizedSender && - handlerContext.senderId?.trim() && - handlerContext.accountId?.trim() && - handlerContext.conversationId?.trim() - ? registration - : { ...registration, pluginRoot: undefined }, - senderId: handlerContext.senderId, - conversation: { - channel: "telegram", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }), - }); - }, }); } diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 468540227684..606dbc2166f5 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -221,7 +221,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: bounded external-content sanitizer for plugin-owned untrusted projections. // +1: auth-profile preservation decision for native model pickers. // +2: shared channel question-reaction store and preflight-audio factories. - 4833, + // +1: shared channel interactive dispatcher with canonical binding authorization. + 4834, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -270,7 +271,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: bounded external-content sanitizer for plugin-owned untrusted projections. // +1: auth-profile preservation decision for native model pickers. // +2: shared channel question-reaction store and preflight-audio factories. - 2910, + // +1: shared channel interactive dispatcher with canonical binding authorization. + 2911, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/plugin-sdk/plugin-runtime.ts b/src/plugin-sdk/plugin-runtime.ts index 0aeec5c7b7cb..7a8d7d7113d5 100644 --- a/src/plugin-sdk/plugin-runtime.ts +++ b/src/plugin-sdk/plugin-runtime.ts @@ -14,6 +14,8 @@ export { export { createInteractiveConversationBindingHelpers } from "../plugins/interactive-binding-helpers.js"; export { clearPluginInteractiveHandlers, + createChannelInteractiveDispatcher, + dispatchPluginInteractiveHandler, registerPluginInteractiveHandler, } from "../plugins/interactive.js"; export { startLazyPluginServiceModule } from "../plugins/lazy-service-module.js"; @@ -28,7 +30,6 @@ export type { export { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; export type { PluginRuntime, RuntimeLogger } from "../plugins/runtime/types.js"; -export { dispatchPluginInteractiveHandler } from "../plugins/interactive.js"; export { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; export { getPluginCommandSpecs } from "../plugins/command-specs.js"; export type { OpenClawPluginConfigSchema } from "../plugins/types.js"; diff --git a/src/plugins/interactive-contract.test-helpers.ts b/src/plugins/interactive-contract.test-helpers.ts index ed738eb41108..7ff4828d403e 100644 --- a/src/plugins/interactive-contract.test-helpers.ts +++ b/src/plugins/interactive-contract.test-helpers.ts @@ -5,15 +5,6 @@ type ConversationBindingHelpers = { getCurrentConversationBinding: (...args: unknown[]) => unknown; }; -type InteractiveHandlerRegistration< - TChannel extends string, - TContext, -> = ConversationBindingHelpers & { - channel: TChannel; - namespace: string; - handler: (ctx: TContext) => unknown; -}; - type BaseInteractiveContext = ConversationBindingHelpers & { channel: TChannel; accountId: string; @@ -21,7 +12,7 @@ type BaseInteractiveContext = ConversationBindingHelper parentConversationId?: string; senderId: string; senderUsername?: string; - auth?: unknown; + auth: { isAuthorizedSender: boolean }; }; export type TelegramInteractiveHandlerContext = BaseInteractiveContext<"telegram"> & { @@ -64,16 +55,3 @@ export type SlackInteractiveHandlerContext = BaseInteractiveContext<"slack"> & { }; respond: Record unknown>; }; - -export type TelegramInteractiveHandlerRegistration = InteractiveHandlerRegistration< - "telegram", - TelegramInteractiveHandlerContext ->; -export type DiscordInteractiveHandlerRegistration = InteractiveHandlerRegistration< - "discord", - DiscordInteractiveHandlerContext ->; -export type SlackInteractiveHandlerRegistration = InteractiveHandlerRegistration< - "slack", - SlackInteractiveHandlerContext ->; diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index 01eafb0c2523..9e63400fa6c9 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -1,19 +1,15 @@ // Covers interactive plugin registry entries and lifecycle behavior. import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import * as conversationBinding from "./conversation-binding.js"; -import { createInteractiveConversationBindingHelpers } from "./interactive-binding-helpers.js"; import type { DiscordInteractiveHandlerContext, - DiscordInteractiveHandlerRegistration, SlackInteractiveHandlerContext, - SlackInteractiveHandlerRegistration, TelegramInteractiveHandlerContext, - TelegramInteractiveHandlerRegistration, } from "./interactive-contract.test-helpers.js"; import { registerRegistryPluginInteractiveHandler } from "./interactive-registry.js"; import { clearPluginInteractiveHandlers, - dispatchPluginInteractiveHandler, + createChannelInteractiveDispatcher, registerPluginInteractiveHandler, } from "./interactive.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; @@ -33,74 +29,28 @@ let getCurrentPluginConversationBindingMock: MockInstance< typeof conversationBinding.getCurrentPluginConversationBinding >; +const telegramInteractiveDispatcher = createChannelInteractiveDispatcher< + "telegram", + "callback", + TelegramInteractiveHandlerContext, + { handled?: boolean } | void, + "callbackMessage" +>({ channel: "telegram", interactiveKey: "callback", dispatchInteractiveKey: "callbackMessage" }); +const discordInteractiveDispatcher = createChannelInteractiveDispatcher< + "discord", + "interaction", + DiscordInteractiveHandlerContext +>({ channel: "discord", interactiveKey: "interaction" }); +const slackInteractiveDispatcher = createChannelInteractiveDispatcher< + "slack", + "interaction", + SlackInteractiveHandlerContext +>({ channel: "slack", interactiveKey: "interaction" }); + type InteractiveDispatchParams = - | { - channel: "telegram"; - data: string; - dedupeId: string; - onMatched?: () => Promise | void; - afterInvoke?: (result: { handled?: boolean } | void) => Promise | void; - ctx: Omit< - TelegramInteractiveHandlerContext, - | "callback" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" - > & { - callbackMessage: { - messageId: number; - chatId: string; - messageText?: string; - }; - }; - respond: TelegramInteractiveHandlerContext["respond"]; - } - | { - channel: "discord"; - data: string; - dedupeId: string; - onMatched?: () => Promise | void; - afterInvoke?: (result: { handled?: boolean } | void) => Promise | void; - ctx: Omit< - DiscordInteractiveHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" - > & { - interaction: Omit< - DiscordInteractiveHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; - }; - respond: DiscordInteractiveHandlerContext["respond"]; - } - | { - channel: "slack"; - data: string; - dedupeId: string; - onMatched?: () => Promise | void; - afterInvoke?: (result: { handled?: boolean } | void) => Promise | void; - ctx: Omit< - SlackInteractiveHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" - > & { - interaction: Omit< - SlackInteractiveHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; - }; - respond: SlackInteractiveHandlerContext["respond"]; - }; + | (Parameters[0] & { channel: "telegram" }) + | (Parameters[0] & { channel: "discord" }) + | (Parameters[0] & { channel: "slack" }); type InteractiveModule = typeof import("./interactive.js"); @@ -240,116 +190,48 @@ async function expectDedupedInteractiveDispatch(params: { } async function dispatchInteractive(params: InteractiveDispatchParams) { - return await dispatchInteractiveWith({ dispatchPluginInteractiveHandler }, params); + if (params.channel === "telegram") { + return await telegramInteractiveDispatcher(params); + } + if (params.channel === "discord") { + return await discordInteractiveDispatcher(params); + } + return await slackInteractiveDispatcher(params); } async function dispatchInteractiveWith( - interactiveModule: Pick, + interactiveModule: Pick, params: InteractiveDispatchParams, ) { if (params.channel === "telegram") { - return await interactiveModule.dispatchPluginInteractiveHandler( - { - channel: "telegram", - data: params.data, - dedupeId: params.dedupeId, - onMatched: params.onMatched, - afterInvoke: params.afterInvoke, - invoke: ({ registration, namespace, payload }) => { - const { callbackMessage, ...handlerContext } = params.ctx; - return registration.handler({ - ...handlerContext, - channel: "telegram", - callback: { - data: params.data, - namespace, - payload, - messageId: callbackMessage.messageId, - chatId: callbackMessage.chatId, - messageText: callbackMessage.messageText, - }, - respond: params.respond, - ...createInteractiveConversationBindingHelpers({ - registration, - senderId: handlerContext.senderId, - conversation: { - channel: "telegram", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }), - }); - }, - }, - ); + const dispatch = interactiveModule.createChannelInteractiveDispatcher< + "telegram", + "callback", + TelegramInteractiveHandlerContext, + { handled?: boolean } | void, + "callbackMessage" + >({ + channel: "telegram", + interactiveKey: "callback", + dispatchInteractiveKey: "callbackMessage", + }); + return await dispatch(params); } if (params.channel === "discord") { - return await interactiveModule.dispatchPluginInteractiveHandler( - { - channel: "discord", - data: params.data, - dedupeId: params.dedupeId, - onMatched: params.onMatched, - afterInvoke: params.afterInvoke, - invoke: ({ registration, namespace, payload }) => - registration.handler({ - ...params.ctx, - channel: "discord", - interaction: { - ...params.ctx.interaction, - data: params.data, - namespace, - payload, - }, - respond: params.respond, - ...createInteractiveConversationBindingHelpers({ - registration, - senderId: params.ctx.senderId, - conversation: { - channel: "discord", - accountId: params.ctx.accountId, - conversationId: params.ctx.conversationId, - parentConversationId: params.ctx.parentConversationId, - }, - }), - }), - }, - ); + return await interactiveModule.createChannelInteractiveDispatcher< + "discord", + "interaction", + DiscordInteractiveHandlerContext + >({ channel: "discord", interactiveKey: "interaction" })(params); } - return await interactiveModule.dispatchPluginInteractiveHandler( - { - channel: "slack", - data: params.data, - dedupeId: params.dedupeId, - onMatched: params.onMatched, - afterInvoke: params.afterInvoke, - invoke: ({ registration, namespace, payload }) => - registration.handler({ - ...params.ctx, - channel: "slack", - interaction: { - ...params.ctx.interaction, - data: params.data, - namespace, - payload, - }, - respond: params.respond, - ...createInteractiveConversationBindingHelpers({ - registration, - senderId: params.ctx.senderId, - conversation: { - channel: "slack", - accountId: params.ctx.accountId, - conversationId: params.ctx.conversationId, - parentConversationId: params.ctx.parentConversationId, - threadId: params.ctx.threadId, - }, - }), - }), - }, - ); + return await interactiveModule.createChannelInteractiveDispatcher< + "slack", + "interaction", + SlackInteractiveHandlerContext + >({ + channel: "slack", + interactiveKey: "interaction", + })(params); } function registerInteractiveHandler(params: { @@ -903,6 +785,91 @@ describe("plugin interactive handlers", () => { await expectBindingHelperWiring(testCase); }); + it.each([ + { + name: "authorized and bound", + auth: true, + senderId: "user-1", + accountId: "default", + conversationId: "conversation-1", + conversation: undefined, + expectedStatus: "bound", + }, + { + name: "unauthorized", + auth: false, + senderId: "user-1", + accountId: "default", + conversationId: "conversation-1", + conversation: undefined, + expectedStatus: "error", + }, + { + name: "missing sender", + auth: true, + senderId: " ", + accountId: "default", + conversationId: "conversation-1", + conversation: undefined, + expectedStatus: "error", + }, + { + name: "missing account", + auth: true, + senderId: "user-1", + accountId: " ", + conversationId: "conversation-1", + conversation: undefined, + expectedStatus: "error", + }, + { + name: "missing base conversation despite an override", + auth: true, + senderId: "user-1", + accountId: "default", + conversationId: " ", + conversation: { + channel: "telegram", + accountId: "default", + conversationId: "override-conversation", + }, + expectedStatus: "error", + }, + ] as const)("exposes binding authority only when $name", async (testCase) => { + let bindingResult: unknown; + const handler = vi.fn(async (ctx: TelegramInteractiveHandlerContext) => { + bindingResult = await ctx.requestConversationBinding(); + }); + expect( + registerPluginInteractiveHandler( + "codex-plugin", + { channel: "telegram", namespace: "codex", handler: handler as never }, + { pluginName: "Codex", pluginRoot: "/plugins/codex" }, + ), + ).toEqual({ ok: true }); + const dispatchParams = createTelegramDispatchParams({ + data: "codex:bind", + callbackId: `auth-${testCase.name}`, + }); + + await dispatchInteractive({ + ...dispatchParams, + conversation: testCase.conversation, + ctx: { + ...dispatchParams.ctx, + auth: { isAuthorizedSender: testCase.auth }, + senderId: testCase.senderId, + accountId: testCase.accountId, + conversationId: testCase.conversationId, + }, + }); + + expect(bindingResult).toMatchObject({ status: testCase.expectedStatus }); + expect(requestPluginConversationBindingMock).toHaveBeenCalledTimes( + testCase.expectedStatus === "bound" ? 1 : 0, + ); + }); + it("does not consume dedupe keys when a handler throws", async () => { const handler = vi .fn(async () => ({ handled: true })) diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index e45797bea524..9063821023e0 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -1,4 +1,5 @@ // Resolves interactive plugin entries from registry metadata. +import { createInteractiveConversationBindingHelpers } from "./interactive-binding-helpers.js"; import { resolvePluginInteractiveRegistrationsMatch, type RegisteredInteractiveHandler, @@ -9,6 +10,7 @@ import { releasePluginInteractiveCallbackDedupe, } from "./interactive-state.js"; import { getActivePluginRegistry } from "./runtime.js"; +import type { PluginInteractiveRegistration } from "./types.js"; type InteractiveDispatchResult = | { matched: false; handled: false; duplicate: false } @@ -26,6 +28,40 @@ type PluginInteractiveMatch = T extends ChannelInteractivePayload + ? Omit + : never; +type ChannelInteractiveDispatchBase = { + accountId: string; + conversationId: string; + parentConversationId?: string; + senderId?: string; + threadId?: string | number; + auth: { isAuthorizedSender: boolean }; +}; +type ChannelInteractiveHandlerContext< + TChannel extends string, + TInteractiveKey extends PropertyKey, +> = ChannelInteractiveDispatchBase & { + channel: TChannel; + respond: unknown; +} & Record; +type ChannelInteractiveOwnedContextKey = + | TInteractiveKey + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding"; +type ChannelInteractiveDispatchContext< + TContext, + TInteractiveKey extends keyof TContext, + TDispatchInteractiveKey extends PropertyKey, +> = Omit> & + ChannelInteractiveDispatchBase & + Record>; + export { clearPluginInteractiveHandlers, registerPluginInteractiveHandler, @@ -88,3 +124,79 @@ export async function dispatchPluginInteractiveHandler< throw error; } } + +/** Creates a channel dispatcher for plugin-owned interactive callbacks. */ +export function createChannelInteractiveDispatcher< + TChannel extends string, + TInteractiveKey extends PropertyKey, + TContext extends ChannelInteractiveHandlerContext, + TResult extends { handled?: boolean } | void = { handled?: boolean } | void, + TDispatchInteractiveKey extends PropertyKey = TInteractiveKey, +>(config: { + channel: TChannel; + interactiveKey: TInteractiveKey; + dispatchInteractiveKey?: TDispatchInteractiveKey; +}) { + type Registration = PluginInteractiveRegistration; + type DispatchContext = ChannelInteractiveDispatchContext< + TContext, + TInteractiveKey, + TDispatchInteractiveKey + >; + return async (params: { + data: string; + dedupeId: string; + ctx: DispatchContext; + respond: TContext["respond"]; + conversation?: Parameters< + typeof createInteractiveConversationBindingHelpers + >[0]["conversation"]; + onMatched?: () => Promise | void; + afterInvoke?: (result: TResult) => Promise | void; + }) => + await dispatchPluginInteractiveHandler({ + channel: config.channel, + data: params.data, + dedupeId: params.dedupeId, + onMatched: params.onMatched, + afterInvoke: params.afterInvoke, + invoke: ({ registration, namespace, payload }) => { + const dispatchInteractiveKey = config.dispatchInteractiveKey ?? config.interactiveKey; + const { [dispatchInteractiveKey]: interactiveContext, ...handlerContext } = params.ctx; + const conversation = params.conversation ?? { + channel: config.channel, + accountId: params.ctx.accountId, + conversationId: params.ctx.conversationId, + parentConversationId: params.ctx.parentConversationId, + threadId: params.ctx.threadId, + }; + const senderId = params.ctx.senderId?.trim(); + const accountId = params.ctx.accountId.trim(); + const conversationId = params.ctx.conversationId.trim(); + + // Unauthorized or unbound senders never receive pluginRoot, so binding helpers fail closed. + const bindingRegistration = + params.ctx.auth.isAuthorizedSender && senderId && accountId && conversationId + ? registration + : { ...registration, pluginRoot: undefined }; + const bindingHelpers = createInteractiveConversationBindingHelpers({ + registration: bindingRegistration, + senderId: params.ctx.senderId, + conversation, + }); + + return (registration as Registration).handler({ + ...handlerContext, + channel: config.channel, + [config.interactiveKey]: { + ...interactiveContext, + data: params.data, + namespace, + payload, + }, + respond: params.respond, + ...bindingHelpers, + } as unknown as TContext); + }, + }); +}