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
+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,
},
}),
});
},
});
}