mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-06 06:41:08 +00:00
176 lines
5.3 KiB
TypeScript
176 lines
5.3 KiB
TypeScript
import { createDedupeCache, resolveGlobalDedupeCache } from "../infra/dedupe.js";
|
|
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
|
|
import type { PluginInteractiveHandlerRegistration } from "./types.js";
|
|
|
|
type RegisteredInteractiveHandler = PluginInteractiveHandlerRegistration & {
|
|
pluginId: string;
|
|
pluginName?: string;
|
|
pluginRoot?: string;
|
|
};
|
|
|
|
type InteractiveRegistrationResult = {
|
|
ok: boolean;
|
|
error?: string;
|
|
};
|
|
|
|
type InteractiveDispatchResult =
|
|
| { matched: false; handled: false; duplicate: false }
|
|
| { matched: true; handled: boolean; duplicate: boolean };
|
|
|
|
type PluginInteractiveDispatchRegistration = {
|
|
channel: string;
|
|
namespace: string;
|
|
};
|
|
|
|
export type PluginInteractiveMatch<TRegistration extends PluginInteractiveDispatchRegistration> = {
|
|
registration: RegisteredInteractiveHandler & TRegistration;
|
|
namespace: string;
|
|
payload: string;
|
|
};
|
|
|
|
type InteractiveState = {
|
|
interactiveHandlers: Map<string, RegisteredInteractiveHandler>;
|
|
callbackDedupe: ReturnType<typeof createDedupeCache>;
|
|
};
|
|
|
|
const PLUGIN_INTERACTIVE_STATE_KEY = Symbol.for("openclaw.pluginInteractiveState");
|
|
|
|
const getState = () =>
|
|
resolveGlobalSingleton<InteractiveState>(PLUGIN_INTERACTIVE_STATE_KEY, () => ({
|
|
interactiveHandlers: new Map<string, RegisteredInteractiveHandler>(),
|
|
callbackDedupe: resolveGlobalDedupeCache(
|
|
Symbol.for("openclaw.pluginInteractiveCallbackDedupe"),
|
|
{
|
|
ttlMs: 5 * 60_000,
|
|
maxSize: 4096,
|
|
},
|
|
),
|
|
}));
|
|
|
|
const getInteractiveHandlers = () => getState().interactiveHandlers;
|
|
const getCallbackDedupe = () => getState().callbackDedupe;
|
|
|
|
function toRegistryKey(channel: string, namespace: string): string {
|
|
return `${channel.trim().toLowerCase()}:${namespace.trim()}`;
|
|
}
|
|
|
|
function normalizeNamespace(namespace: string): string {
|
|
return namespace.trim();
|
|
}
|
|
|
|
function validateNamespace(namespace: string): string | null {
|
|
if (!namespace.trim()) {
|
|
return "Interactive handler namespace cannot be empty";
|
|
}
|
|
if (!/^[A-Za-z0-9._-]+$/.test(namespace.trim())) {
|
|
return "Interactive handler namespace must contain only letters, numbers, dots, underscores, and hyphens";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveNamespaceMatch(
|
|
channel: string,
|
|
data: string,
|
|
): { registration: RegisteredInteractiveHandler; namespace: string; payload: string } | null {
|
|
const interactiveHandlers = getInteractiveHandlers();
|
|
const trimmedData = data.trim();
|
|
if (!trimmedData) {
|
|
return null;
|
|
}
|
|
|
|
const separatorIndex = trimmedData.indexOf(":");
|
|
const namespace =
|
|
separatorIndex >= 0 ? trimmedData.slice(0, separatorIndex) : normalizeNamespace(trimmedData);
|
|
const registration = interactiveHandlers.get(toRegistryKey(channel, namespace));
|
|
if (!registration) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
registration,
|
|
namespace,
|
|
payload: separatorIndex >= 0 ? trimmedData.slice(separatorIndex + 1) : "",
|
|
};
|
|
}
|
|
|
|
export function registerPluginInteractiveHandler(
|
|
pluginId: string,
|
|
registration: PluginInteractiveHandlerRegistration,
|
|
opts?: { pluginName?: string; pluginRoot?: string },
|
|
): InteractiveRegistrationResult {
|
|
const interactiveHandlers = getInteractiveHandlers();
|
|
const namespace = normalizeNamespace(registration.namespace);
|
|
const validationError = validateNamespace(namespace);
|
|
if (validationError) {
|
|
return { ok: false, error: validationError };
|
|
}
|
|
const key = toRegistryKey(registration.channel, namespace);
|
|
const existing = interactiveHandlers.get(key);
|
|
if (existing) {
|
|
return {
|
|
ok: false,
|
|
error: `Interactive handler namespace "${namespace}" already registered by plugin "${existing.pluginId}"`,
|
|
};
|
|
}
|
|
interactiveHandlers.set(key, {
|
|
...registration,
|
|
namespace,
|
|
pluginId,
|
|
pluginName: opts?.pluginName,
|
|
pluginRoot: opts?.pluginRoot,
|
|
});
|
|
return { ok: true };
|
|
}
|
|
|
|
export function clearPluginInteractiveHandlers(): void {
|
|
const interactiveHandlers = getInteractiveHandlers();
|
|
const callbackDedupe = getCallbackDedupe();
|
|
interactiveHandlers.clear();
|
|
callbackDedupe.clear();
|
|
}
|
|
|
|
export function clearPluginInteractiveHandlersForPlugin(pluginId: string): void {
|
|
const interactiveHandlers = getInteractiveHandlers();
|
|
for (const [key, value] of interactiveHandlers.entries()) {
|
|
if (value.pluginId === pluginId) {
|
|
interactiveHandlers.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function dispatchPluginInteractiveHandler<
|
|
TRegistration extends PluginInteractiveDispatchRegistration,
|
|
>(params: {
|
|
channel: TRegistration["channel"];
|
|
data: string;
|
|
dedupeId?: string;
|
|
onMatched?: () => Promise<void> | void;
|
|
invoke: (
|
|
match: PluginInteractiveMatch<TRegistration>,
|
|
) => Promise<{ handled?: boolean } | void> | { handled?: boolean } | void;
|
|
}): Promise<InteractiveDispatchResult> {
|
|
const callbackDedupe = getCallbackDedupe();
|
|
const match = resolveNamespaceMatch(params.channel, params.data);
|
|
if (!match) {
|
|
return { matched: false, handled: false, duplicate: false };
|
|
}
|
|
|
|
const dedupeKey = params.dedupeId?.trim();
|
|
if (dedupeKey && callbackDedupe.peek(dedupeKey)) {
|
|
return { matched: true, handled: true, duplicate: true };
|
|
}
|
|
|
|
await params.onMatched?.();
|
|
|
|
const resolved = await params.invoke(match as PluginInteractiveMatch<TRegistration>);
|
|
if (dedupeKey) {
|
|
callbackDedupe.check(dedupeKey);
|
|
}
|
|
|
|
return {
|
|
matched: true,
|
|
handled: resolved?.handled ?? true,
|
|
duplicate: false,
|
|
};
|
|
}
|