refactor(plugins): single-source interactive dispatch and its auth downgrade via plugin SDK (#120062)

This commit is contained in:
Peter Steinberger
2026-08-06 16:31:04 -07:00
committed by GitHub
parent d64f5b6a09
commit 0b849a316d
10 changed files with 377 additions and 371 deletions
@@ -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
+12 -50
View File
@@ -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<typeof dispatchDiscordInteractive>[0]["ctx"];
respond: DiscordInteractiveHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}) {
return await dispatchPluginInteractiveHandler<DiscordInteractiveHandlerRegistration>({
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,
},
}),
}),
});
}
+21 -48
View File
@@ -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<SlackBlockInteractivePayload, "data" | "namespace" | "payload">
| Omit<SlackModalInteractivePayload, "data" | "namespace" | "payload">;
};
SlackInteractiveHandlerResult
>({
channel: "slack",
interactiveKey: "interaction",
});
export async function dispatchSlackPluginInteractiveHandler(params: {
data: string;
interactionId: string;
channelType?: "im" | "mpim" | "channel" | "group";
ctx: SlackInteractiveDispatchContext;
ctx: Parameters<typeof dispatchSlackInteractive>[0]["ctx"];
respond: SlackInteractiveHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
}) {
@@ -117,39 +112,17 @@ export async function dispatchSlackPluginInteractiveHandler(params: {
: params.ctx.conversationId.trim();
const threadId = params.ctx.threadId?.trim() || undefined;
return await dispatchPluginInteractiveHandler<SlackInteractiveHandlerRegistration>({
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,
},
});
}
@@ -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<string, unknown>;
}) => createInteractiveConversationBindingHelpersMock(params),
}));
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
return {
...actual,
createChannelInteractiveDispatcher: (config: {
channel: string;
interactiveKey: PropertyKey;
dispatchInteractiveKey?: PropertyKey;
}) => {
return (params: {
data: string;
dedupeId: string;
ctx: Record<PropertyKey, unknown> & {
accountId: string;
conversationId: string;
senderId?: string;
auth: { isAuthorizedSender: boolean };
};
respond: unknown;
conversation?: Record<string, unknown>;
onMatched?: () => Promise<void> | void;
afterInvoke?: (result: unknown) => Promise<void> | 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) => {
+15 -66
View File
@@ -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<void>;
editMessage: (params: { text: string; buttons?: TelegramInteractiveButtons }) => Promise<void>;
editButtons: (params: { buttons: TelegramInteractiveButtons }) => Promise<void>;
clearButtons: () => Promise<void>;
deleteMessage: () => Promise<void>;
};
ctx: Parameters<typeof dispatchTelegramInteractive>[0]["ctx"];
respond: TelegramInteractiveHandlerContext["respond"];
onMatched?: () => Promise<void> | void;
afterInvoke?: (result: TelegramInteractiveHandlerResult) => Promise<void> | 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,
},
}),
});
},
});
}
+4 -2
View File
@@ -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(
+2 -1
View File
@@ -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";
@@ -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<TChannel extends string> = ConversationBindingHelpers & {
channel: TChannel;
accountId: string;
@@ -21,7 +12,7 @@ type BaseInteractiveContext<TChannel extends string> = 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<string, (...args: unknown[]) => unknown>;
};
export type TelegramInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"telegram",
TelegramInteractiveHandlerContext
>;
export type DiscordInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"discord",
DiscordInteractiveHandlerContext
>;
export type SlackInteractiveHandlerRegistration = InteractiveHandlerRegistration<
"slack",
SlackInteractiveHandlerContext
>;
+140 -173
View File
@@ -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> | void;
afterInvoke?: (result: { handled?: boolean } | void) => Promise<void> | 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> | void;
afterInvoke?: (result: { handled?: boolean } | void) => Promise<void> | 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> | void;
afterInvoke?: (result: { handled?: boolean } | void) => Promise<void> | void;
ctx: Omit<
SlackInteractiveHandlerContext,
| "interaction"
| "respond"
| "channel"
| "requestConversationBinding"
| "detachConversationBinding"
| "getCurrentConversationBinding"
> & {
interaction: Omit<
SlackInteractiveHandlerContext["interaction"],
"data" | "namespace" | "payload"
>;
};
respond: SlackInteractiveHandlerContext["respond"];
};
| (Parameters<typeof telegramInteractiveDispatcher>[0] & { channel: "telegram" })
| (Parameters<typeof discordInteractiveDispatcher>[0] & { channel: "discord" })
| (Parameters<typeof slackInteractiveDispatcher>[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<typeof import("./interactive.js"), "dispatchPluginInteractiveHandler">,
interactiveModule: Pick<typeof import("./interactive.js"), "createChannelInteractiveDispatcher">,
params: InteractiveDispatchParams,
) {
if (params.channel === "telegram") {
return await interactiveModule.dispatchPluginInteractiveHandler<TelegramInteractiveHandlerRegistration>(
{
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<DiscordInteractiveHandlerRegistration>(
{
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<SlackInteractiveHandlerRegistration>(
{
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 }))
+112
View File
@@ -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<TResult = unknown> =
| { matched: false; handled: false; duplicate: false }
@@ -26,6 +28,40 @@ type PluginInteractiveMatch<TRegistration extends PluginInteractiveDispatchRegis
payload: string;
};
type ChannelInteractivePayload = { data: string; namespace: string; payload: string };
type ChannelInteractiveDispatchPayload<T> = T extends ChannelInteractivePayload
? Omit<T, keyof ChannelInteractivePayload>
: 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<TInteractiveKey, ChannelInteractivePayload>;
type ChannelInteractiveOwnedContextKey<TInteractiveKey> =
| TInteractiveKey
| "respond"
| "channel"
| "requestConversationBinding"
| "detachConversationBinding"
| "getCurrentConversationBinding";
type ChannelInteractiveDispatchContext<
TContext,
TInteractiveKey extends keyof TContext,
TDispatchInteractiveKey extends PropertyKey,
> = Omit<TContext, ChannelInteractiveOwnedContextKey<TInteractiveKey>> &
ChannelInteractiveDispatchBase &
Record<TDispatchInteractiveKey, ChannelInteractiveDispatchPayload<TContext[TInteractiveKey]>>;
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<TChannel, TInteractiveKey>,
TResult extends { handled?: boolean } | void = { handled?: boolean } | void,
TDispatchInteractiveKey extends PropertyKey = TInteractiveKey,
>(config: {
channel: TChannel;
interactiveKey: TInteractiveKey;
dispatchInteractiveKey?: TDispatchInteractiveKey;
}) {
type Registration = PluginInteractiveRegistration<TContext, TChannel, TResult>;
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> | void;
afterInvoke?: (result: TResult) => Promise<void> | void;
}) =>
await dispatchPluginInteractiveHandler<Registration, TResult>({
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);
},
});
}