mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 10:01:37 -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
@@ -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