mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(plugins): single-source interactive dispatch and its auth downgrade via plugin SDK (#120062)
This commit is contained in:
committed by
GitHub
parent
d64f5b6a09
commit
0b849a316d
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 }))
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user