mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
d6f70a96cb
* fix(plugins): preserve selected command identity * test(telegram): use scoped command registries * test(telegram): isolate command runtime fixtures * test(telegram): warm native command runtime * refactor(plugins): keep command metadata private * fix(plugins): accept synchronous command handlers * fix(plugins): scope command drain bypass to live execution * test(telegram): use scoped command registry fixtures * test(telegram): isolate native menu runtime fixtures * test(telegram): isolate login session store * test(telegram): surface login flow failures * test(telegram): preload native login module * test(telegram): scope native command registries * fix(plugins): complete command dispatch contracts * fix(plugins): break command dispatch import cycles * fix(plugins): stabilize command dispatch contracts * fix(channels): keep plugin dispatch options internal * fix(plugins): keep command dispatch carrier opaque * test(channels): align delivery adapter fixtures * test(delivery): align custody ownership coverage * test(delivery): align latest queue reconciliation * test(channels): drop obsolete delivery wrappers * fix(plugins): rebind channel reload starts * fix(plugins): scope command catalog reloads * fix(ci): align current runtime contracts * chore(plugin-sdk): refresh API baseline
265 lines
9.5 KiB
TypeScript
265 lines
9.5 KiB
TypeScript
/** Registry-bound plugin command selection and execution for native/channel surfaces. */
|
|
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import type { RegisteredPluginCommand } from "./command-registry-state.js";
|
|
import { retainPluginCommandCatalogForCurrentAccount } from "./plugin-command-account-start-scope.js";
|
|
import {
|
|
PLUGIN_COMMAND_DISPATCH,
|
|
type PluginCommandReplyOptions,
|
|
} from "./plugin-command-dispatch-contract.js";
|
|
import { matchRegisteredPluginCommand } from "./plugin-command-matcher.js";
|
|
import {
|
|
pluginCommandSupportsChannel,
|
|
projectPluginCommandNativeMetadata,
|
|
} from "./plugin-command-metadata.js";
|
|
import {
|
|
listRegisteredPluginCommands,
|
|
resolveSelectedPluginCommandRegistry,
|
|
} from "./plugin-command-registry.js";
|
|
import { isPluginRegistryRetired } from "./registry-lifecycle.js";
|
|
import type { PluginRegistry } from "./registry-types.js";
|
|
import type { PluginCommandContext, PluginCommandResult } from "./types.js";
|
|
|
|
export { PLUGIN_COMMAND_DISPATCH };
|
|
export type { PluginCommandReplyOptions };
|
|
|
|
declare const pluginCommandDispatchBrand: unique symbol;
|
|
|
|
export type PluginCommandDispatchContext = Readonly<{
|
|
senderId?: string;
|
|
channel: string;
|
|
channelId?: PluginCommandContext["channelId"];
|
|
isAuthorizedSender: boolean;
|
|
senderIsOwner?: boolean;
|
|
gatewayClientScopes?: PluginCommandContext["gatewayClientScopes"];
|
|
agentId?: string;
|
|
sessionKey?: PluginCommandContext["sessionKey"];
|
|
sessionId?: PluginCommandContext["sessionId"];
|
|
sessionTarget?: PluginCommandContext["sessionTarget"];
|
|
sessionFile?: PluginCommandContext["sessionFile"];
|
|
authProfileId?: string;
|
|
commandBody: string;
|
|
config: OpenClawConfig;
|
|
from?: PluginCommandContext["from"];
|
|
to?: PluginCommandContext["to"];
|
|
originatingTo?: string;
|
|
accountId?: PluginCommandContext["accountId"];
|
|
messageThreadId?: PluginCommandContext["messageThreadId"];
|
|
threadParentId?: PluginCommandContext["threadParentId"];
|
|
diagnosticsSessions?: PluginCommandContext["diagnosticsSessions"];
|
|
diagnosticsUploadApproved?: PluginCommandContext["diagnosticsUploadApproved"];
|
|
diagnosticsPreviewOnly?: PluginCommandContext["diagnosticsPreviewOnly"];
|
|
diagnosticsPrivateRouted?: PluginCommandContext["diagnosticsPrivateRouted"];
|
|
}>;
|
|
|
|
/** Opaque capability bound to one selected command in one registry generation. */
|
|
export type PluginCommandDispatch = Readonly<{
|
|
kind: "plugin";
|
|
execute: (context: PluginCommandDispatchContext) => Promise<PluginCommandResult>;
|
|
[pluginCommandDispatchBrand]: true;
|
|
}>;
|
|
|
|
export type PluginCommandCatalogDecision = PluginCommandDispatch | Readonly<{ kind: "non-plugin" }>;
|
|
|
|
/** Internal reply-pipeline view after the opaque catalog decision has been validated. */
|
|
export type PluginCommandExecutionReplyOptions = Readonly<{
|
|
[PLUGIN_COMMAND_DISPATCH]?: PluginCommandCatalogDecision;
|
|
}>;
|
|
|
|
export type PluginCommandNativeCandidate = Readonly<{
|
|
name: string;
|
|
description: string;
|
|
descriptionLocalizations?: Readonly<Record<string, string>>;
|
|
acceptsArgs: boolean;
|
|
requireAuth: boolean;
|
|
progressMessage?: string;
|
|
prepareDispatch: (rawArgs?: string) => PluginCommandCatalogDecision;
|
|
}>;
|
|
|
|
type PluginCommandInvocationMatch = Readonly<{
|
|
dispatch: PluginCommandDispatch;
|
|
acceptsArgs: boolean;
|
|
requireAuth: boolean;
|
|
progressMessage?: string;
|
|
}>;
|
|
|
|
export type PluginCommandRuntime = Readonly<{
|
|
listNativeCandidates: (provider: string) => readonly PluginCommandNativeCandidate[];
|
|
retainNativeCatalog: (provider: string) => void;
|
|
}>;
|
|
|
|
type PluginCommandRuntimeState = Readonly<{
|
|
registry: PluginRegistry;
|
|
commands: readonly RegisteredPluginCommand[];
|
|
}>;
|
|
|
|
type SelectedCommand = {
|
|
runtime: PluginCommandRuntime;
|
|
registry: PluginRegistry;
|
|
channel: string;
|
|
command: RegisteredPluginCommand;
|
|
args?: string;
|
|
};
|
|
|
|
const dispatchSelections = new WeakMap<object, SelectedCommand>();
|
|
const runtimeStates = new WeakMap<PluginCommandRuntime, PluginCommandRuntimeState>();
|
|
|
|
const INVALID_SELECTION_REPLY = {
|
|
text: "⚠️ This command selection is no longer valid. Please try again.",
|
|
} as const;
|
|
const RETIRED_SELECTION_REPLY = {
|
|
text: "⚠️ This command is no longer available after the plugin registry changed. Please try again.",
|
|
} as const;
|
|
|
|
function createSelectedPluginCommandDispatch(
|
|
runtime: PluginCommandRuntime,
|
|
state: PluginCommandRuntimeState,
|
|
command: RegisteredPluginCommand,
|
|
channel: string,
|
|
args?: string,
|
|
): PluginCommandDispatch {
|
|
const dispatch = Object.freeze({
|
|
kind: "plugin" as const,
|
|
async execute(this: PluginCommandDispatch, context: PluginCommandDispatchContext) {
|
|
if (this !== dispatch) {
|
|
return { ...INVALID_SELECTION_REPLY };
|
|
}
|
|
return await executeSelectedPluginCommand(runtime, dispatch, context);
|
|
},
|
|
}) as PluginCommandDispatch;
|
|
dispatchSelections.set(dispatch as object, {
|
|
runtime,
|
|
registry: state.registry,
|
|
command,
|
|
channel: normalizeOptionalLowercaseString(channel) ?? "",
|
|
...(args?.trim() ? { args: args.trim() } : {}),
|
|
});
|
|
return dispatch;
|
|
}
|
|
|
|
async function executeSelectedPluginCommand(
|
|
runtime: PluginCommandRuntime | undefined,
|
|
dispatch: PluginCommandDispatch,
|
|
context: PluginCommandDispatchContext,
|
|
): Promise<PluginCommandResult> {
|
|
const selected = dispatchSelections.get(dispatch as object);
|
|
if (!selected || (runtime && selected.runtime !== runtime)) {
|
|
return { ...INVALID_SELECTION_REPLY };
|
|
}
|
|
if (isPluginRegistryRetired(selected.registry)) {
|
|
return { ...RETIRED_SELECTION_REPLY };
|
|
}
|
|
const channel = normalizeOptionalLowercaseString(context.channel) ?? "";
|
|
if (selected.channel !== channel) {
|
|
return { ...INVALID_SELECTION_REPLY };
|
|
}
|
|
const { executeRegisteredPluginCommand } = await import("./plugin-command-execution.js");
|
|
if (isPluginRegistryRetired(selected.registry)) {
|
|
return { ...RETIRED_SELECTION_REPLY };
|
|
}
|
|
return await executeRegisteredPluginCommand(selected.registry, {
|
|
...context,
|
|
args: selected.args,
|
|
command: selected.command,
|
|
});
|
|
}
|
|
|
|
/** Validates and executes a dispatch carried through the core reply pipeline. */
|
|
export async function executePluginCommandDispatch(
|
|
dispatch: PluginCommandDispatch,
|
|
context: PluginCommandDispatchContext,
|
|
): Promise<PluginCommandResult> {
|
|
return await executeSelectedPluginCommand(undefined, dispatch, context);
|
|
}
|
|
|
|
/** Creates one command runtime bound permanently to the current scoped registry generation. */
|
|
export function createPluginCommandRuntime(): PluginCommandRuntime {
|
|
const registry = resolveSelectedPluginCommandRegistry();
|
|
if (!registry) {
|
|
throw new Error("Plugin command runtime requires an active or request-scoped registry.");
|
|
}
|
|
const state: PluginCommandRuntimeState = Object.freeze({
|
|
registry,
|
|
commands: Object.freeze(listRegisteredPluginCommands(registry)),
|
|
});
|
|
|
|
const assertCurrent = () => {
|
|
if (isPluginRegistryRetired(state.registry)) {
|
|
throw new Error("Plugin command runtime is bound to a retired registry generation.");
|
|
}
|
|
};
|
|
|
|
const runtime: PluginCommandRuntime = Object.freeze({
|
|
listNativeCandidates(provider: string): readonly PluginCommandNativeCandidate[] {
|
|
assertCurrent();
|
|
const channel = normalizeOptionalLowercaseString(provider) ?? "";
|
|
return Object.freeze(
|
|
state.commands
|
|
.filter((command) => pluginCommandSupportsChannel(command, channel))
|
|
.map((command) => {
|
|
const metadata = projectPluginCommandNativeMetadata(command, channel);
|
|
return Object.freeze({
|
|
...metadata,
|
|
prepareDispatch(rawArgs?: string): PluginCommandCatalogDecision {
|
|
const args = rawArgs?.trim();
|
|
if (args && !command.acceptsArgs) {
|
|
return Object.freeze({ kind: "non-plugin" as const });
|
|
}
|
|
return createSelectedPluginCommandDispatch(runtime, state, command, channel, args);
|
|
},
|
|
});
|
|
}),
|
|
);
|
|
},
|
|
retainNativeCatalog(provider: string): void {
|
|
assertCurrent();
|
|
const channel = normalizeOptionalLowercaseString(provider) ?? "";
|
|
if (!state.commands.some((command) => pluginCommandSupportsChannel(command, channel))) {
|
|
return;
|
|
}
|
|
retainPluginCommandCatalogForCurrentAccount(channel);
|
|
},
|
|
});
|
|
runtimeStates.set(runtime, state);
|
|
return runtime;
|
|
}
|
|
|
|
/** Core-only text matcher that returns a dispatch from the same bound runtime. */
|
|
export function matchPluginCommandInvocation(
|
|
runtime: PluginCommandRuntime,
|
|
commandBody: string,
|
|
options: { channel: string; provider?: string },
|
|
): PluginCommandInvocationMatch | null {
|
|
const state = runtimeStates.get(runtime);
|
|
if (!state) {
|
|
return null;
|
|
}
|
|
if (isPluginRegistryRetired(state.registry)) {
|
|
throw new Error("Plugin command runtime is bound to a retired registry generation.");
|
|
}
|
|
const channel = normalizeOptionalLowercaseString(options.channel) ?? "";
|
|
const provider = normalizeOptionalLowercaseString(options.provider) ?? channel;
|
|
const match = matchRegisteredPluginCommand({
|
|
commands: state.commands,
|
|
commandBody,
|
|
channel,
|
|
aliasScope: { kind: "provider", provider },
|
|
});
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
const metadata = projectPluginCommandNativeMetadata(match.command, provider);
|
|
return Object.freeze({
|
|
dispatch: createSelectedPluginCommandDispatch(
|
|
runtime,
|
|
state,
|
|
match.command,
|
|
channel,
|
|
match.args,
|
|
),
|
|
acceptsArgs: metadata.acceptsArgs,
|
|
requireAuth: metadata.requireAuth,
|
|
...(metadata.progressMessage ? { progressMessage: metadata.progressMessage } : {}),
|
|
});
|
|
}
|