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