UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

742 lines (735 loc) 21.8 kB
import { M as logVerbose, y as resolveUserPath } from "./utils-CP9YLh6M.js"; import path from "node:path"; //#region src/hooks/internal-hooks.ts /** Registry of hook handlers by event key */ const handlers = /* @__PURE__ */ new Map(); /** * Register a hook handler for a specific event type or event:action combination * * @param eventKey - Event type (e.g., 'command') or specific action (e.g., 'command:new') * @param handler - Function to call when the event is triggered * * @example * ```ts * // Listen to all command events * registerInternalHook('command', async (event) => { * console.log('Command:', event.action); * }); * * // Listen only to /new commands * registerInternalHook('command:new', async (event) => { * await saveSessionToMemory(event); * }); * ``` */ function registerInternalHook(eventKey, handler) { if (!handlers.has(eventKey)) handlers.set(eventKey, []); handlers.get(eventKey).push(handler); } /** * Clear all registered hooks (useful for testing) */ function clearInternalHooks() { handlers.clear(); } /** * Trigger a hook event * * Calls all handlers registered for: * 1. The general event type (e.g., 'command') * 2. The specific event:action combination (e.g., 'command:new') * * Handlers are called in registration order. Errors are caught and logged * but don't prevent other handlers from running. * * @param event - The event to trigger */ async function triggerInternalHook(event) { const typeHandlers = handlers.get(event.type) ?? []; const specificHandlers = handlers.get(`${event.type}:${event.action}`) ?? []; const allHandlers = [...typeHandlers, ...specificHandlers]; if (allHandlers.length === 0) return; for (const handler of allHandlers) try { await handler(event); } catch (err) { console.error(`Hook error [${event.type}:${event.action}]:`, err instanceof Error ? err.message : String(err)); } } /** * Create a hook event with common fields filled in * * @param type - The event type * @param action - The action within that type * @param sessionKey - The session key * @param context - Additional context */ function createInternalHookEvent(type, action, sessionKey, context = {}) { return { type, action, sessionKey, context, timestamp: /* @__PURE__ */ new Date(), messages: [] }; } //#endregion //#region src/plugins/commands.ts const pluginCommands = /* @__PURE__ */ new Map(); let registryLocked = false; const MAX_ARGS_LENGTH = 4096; /** * Reserved command names that plugins cannot override. * These are built-in commands from commands-registry.data.ts. */ const RESERVED_COMMANDS = new Set([ "help", "commands", "status", "whoami", "context", "stop", "restart", "reset", "new", "compact", "config", "debug", "allowlist", "activation", "skill", "subagents", "kill", "steer", "tell", "model", "models", "queue", "send", "bash", "exec", "think", "verbose", "reasoning", "elevated", "usage" ]); /** * Validate a command name. * Returns an error message if invalid, or null if valid. */ function validateCommandName(name) { const trimmed = name.trim().toLowerCase(); if (!trimmed) return "Command name cannot be empty"; if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores"; if (RESERVED_COMMANDS.has(trimmed)) return `Command name "${trimmed}" is reserved by a built-in command`; return null; } /** * Register a plugin command. * Returns an error if the command name is invalid or reserved. */ function registerPluginCommand(pluginId, command) { if (registryLocked) return { ok: false, error: "Cannot register commands while processing is in progress" }; if (typeof command.handler !== "function") return { ok: false, error: "Command handler must be a function" }; const validationError = validateCommandName(command.name); if (validationError) return { ok: false, error: validationError }; const key = `/${command.name.toLowerCase()}`; if (pluginCommands.has(key)) { const existing = pluginCommands.get(key); return { ok: false, error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"` }; } pluginCommands.set(key, { ...command, pluginId }); logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`); return { ok: true }; } /** * Clear all registered plugin commands. * Called during plugin reload. */ function clearPluginCommands() { pluginCommands.clear(); } /** * Check if a command body matches a registered plugin command. * Returns the command definition and parsed args if matched. * * Note: If a command has `acceptsArgs: false` and the user provides arguments, * the command will not match. This allows the message to fall through to * built-in handlers or the agent. Document this behavior to plugin authors. */ function matchPluginCommand(commandBody) { const trimmed = commandBody.trim(); if (!trimmed.startsWith("/")) return null; const spaceIndex = trimmed.indexOf(" "); const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex); const args = spaceIndex === -1 ? void 0 : trimmed.slice(spaceIndex + 1).trim(); const key = commandName.toLowerCase(); const command = pluginCommands.get(key); if (!command) return null; if (args && !command.acceptsArgs) return null; return { command, args: args || void 0 }; } /** * Sanitize command arguments to prevent injection attacks. * Removes control characters and enforces length limits. */ function sanitizeArgs(args) { if (!args) return; if (args.length > MAX_ARGS_LENGTH) return args.slice(0, MAX_ARGS_LENGTH); let sanitized = ""; for (const char of args) { const code = char.charCodeAt(0); if (!(code <= 31 && code !== 9 && code !== 10 || code === 127)) sanitized += char; } return sanitized; } /** * Execute a plugin command handler. * * Note: Plugin authors should still validate and sanitize ctx.args for their * specific use case. This function provides basic defense-in-depth sanitization. */ async function executePluginCommand(params) { const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params; if (command.requireAuth !== false && !isAuthorizedSender) { logVerbose(`Plugin command /${command.name} blocked: unauthorized sender ${senderId || "<unknown>"}`); return { text: "⚠️ This command requires authorization." }; } const sanitizedArgs = sanitizeArgs(args); const ctx = { senderId, channel, channelId: params.channelId, isAuthorizedSender, args: sanitizedArgs, commandBody, config, from: params.from, to: params.to, accountId: params.accountId, messageThreadId: params.messageThreadId }; registryLocked = true; try { const result = await command.handler(ctx); logVerbose(`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`); return result; } catch (err) { const error = err; logVerbose(`Plugin command /${command.name} error: ${error.message}`); return { text: "⚠️ Command failed. Please try again later." }; } finally { registryLocked = false; } } /** * List all registered plugin commands. * Used for /help and /commands output. */ function listPluginCommands() { return Array.from(pluginCommands.values()).map((cmd) => ({ name: cmd.name, description: cmd.description, pluginId: cmd.pluginId })); } /** * Get plugin command specs for native command registration (e.g., Telegram). */ function getPluginCommandSpecs() { return Array.from(pluginCommands.values()).map((cmd) => ({ name: cmd.name, description: cmd.description })); } //#endregion //#region src/plugins/http-path.ts function normalizePluginHttpPath(path, fallback) { const trimmed = path?.trim(); if (!trimmed) { const fallbackTrimmed = fallback?.trim(); if (!fallbackTrimmed) return null; return fallbackTrimmed.startsWith("/") ? fallbackTrimmed : `/${fallbackTrimmed}`; } return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; } //#endregion //#region src/plugins/registry.ts function createEmptyPluginRegistry() { return { plugins: [], tools: [], hooks: [], typedHooks: [], channels: [], providers: [], gatewayHandlers: {}, httpHandlers: [], httpRoutes: [], cliRegistrars: [], services: [], commands: [], diagnostics: [] }; } function createPluginRegistry(registryParams) { const registry = createEmptyPluginRegistry(); const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); const pushDiagnostic = (diag) => { registry.diagnostics.push(diag); }; const registerTool = (record, tool, opts) => { const names = opts?.names ?? (opts?.name ? [opts.name] : []); const optional = opts?.optional === true; const factory = typeof tool === "function" ? tool : (_ctx) => tool; if (typeof tool !== "function") names.push(tool.name); const normalized = names.map((name) => name.trim()).filter(Boolean); if (normalized.length > 0) record.toolNames.push(...normalized); registry.tools.push({ pluginId: record.id, factory, names: normalized, optional, source: record.source }); }; const registerHook = (record, events, handler, opts, config) => { const normalizedEvents = (Array.isArray(events) ? events : [events]).map((event) => event.trim()).filter(Boolean); const entry = opts?.entry ?? null; const name = entry?.hook.name ?? opts?.name?.trim(); if (!name) { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, message: "hook registration missing name" }); return; } const description = entry?.hook.description ?? opts?.description ?? ""; const hookEntry = entry ? { ...entry, hook: { ...entry.hook, name, description, source: "openclaw-plugin", pluginId: record.id }, metadata: { ...entry.metadata, events: normalizedEvents } } : { hook: { name, description, source: "openclaw-plugin", pluginId: record.id, filePath: record.source, baseDir: path.dirname(record.source), handlerPath: record.source }, frontmatter: {}, metadata: { events: normalizedEvents }, invocation: { enabled: true } }; record.hookNames.push(name); registry.hooks.push({ pluginId: record.id, entry: hookEntry, events: normalizedEvents, source: record.source }); if (!(config?.hooks?.internal?.enabled === true) || opts?.register === false) return; for (const event of normalizedEvents) registerInternalHook(event, handler); }; const registerGatewayMethod = (record, method, handler) => { const trimmed = method.trim(); if (!trimmed) return; if (coreGatewayMethods.has(trimmed) || registry.gatewayHandlers[trimmed]) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: `gateway method already registered: ${trimmed}` }); return; } registry.gatewayHandlers[trimmed] = handler; record.gatewayMethods.push(trimmed); }; const registerHttpHandler = (record, handler) => { record.httpHandlers += 1; registry.httpHandlers.push({ pluginId: record.id, handler, source: record.source }); }; const registerHttpRoute = (record, params) => { const normalizedPath = normalizePluginHttpPath(params.path); if (!normalizedPath) { pushDiagnostic({ level: "warn", pluginId: record.id, source: record.source, message: "http route registration missing path" }); return; } if (registry.httpRoutes.some((entry) => entry.path === normalizedPath)) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: `http route already registered: ${normalizedPath}` }); return; } record.httpHandlers += 1; registry.httpRoutes.push({ pluginId: record.id, path: normalizedPath, handler: params.handler, source: record.source }); }; const registerChannel = (record, registration) => { const normalized = typeof registration.plugin === "object" ? registration : { plugin: registration }; const plugin = normalized.plugin; const id = typeof plugin?.id === "string" ? plugin.id.trim() : String(plugin?.id ?? "").trim(); if (!id) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: "channel registration missing id" }); return; } record.channelIds.push(id); registry.channels.push({ pluginId: record.id, plugin, dock: normalized.dock, source: record.source }); }; const registerProvider = (record, provider) => { const id = typeof provider?.id === "string" ? provider.id.trim() : ""; if (!id) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: "provider registration missing id" }); return; } const existing = registry.providers.find((entry) => entry.provider.id === id); if (existing) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: `provider already registered: ${id} (${existing.pluginId})` }); return; } record.providerIds.push(id); registry.providers.push({ pluginId: record.id, provider, source: record.source }); }; const registerCli = (record, registrar, opts) => { const commands = (opts?.commands ?? []).map((cmd) => cmd.trim()).filter(Boolean); record.cliCommands.push(...commands); registry.cliRegistrars.push({ pluginId: record.id, register: registrar, commands, source: record.source }); }; const registerService = (record, service) => { const id = service.id.trim(); if (!id) return; record.services.push(id); registry.services.push({ pluginId: record.id, service, source: record.source }); }; const registerCommand = (record, command) => { const name = command.name.trim(); if (!name) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: "command registration missing name" }); return; } const result = registerPluginCommand(record.id, command); if (!result.ok) { pushDiagnostic({ level: "error", pluginId: record.id, source: record.source, message: `command registration failed: ${result.error}` }); return; } record.commands.push(name); registry.commands.push({ pluginId: record.id, command, source: record.source }); }; const registerTypedHook = (record, hookName, handler, opts) => { record.hookCount += 1; registry.typedHooks.push({ pluginId: record.id, hookName, handler, priority: opts?.priority, source: record.source }); }; const normalizeLogger = (logger) => ({ info: logger.info, warn: logger.warn, error: logger.error, debug: logger.debug }); const createApi = (record, params) => { return { id: record.id, name: record.name, version: record.version, description: record.description, source: record.source, config: params.config, pluginConfig: params.pluginConfig, runtime: registryParams.runtime, logger: normalizeLogger(registryParams.logger), registerTool: (tool, opts) => registerTool(record, tool, opts), registerHook: (events, handler, opts) => registerHook(record, events, handler, opts, params.config), registerHttpHandler: (handler) => registerHttpHandler(record, handler), registerHttpRoute: (params) => registerHttpRoute(record, params), registerChannel: (registration) => registerChannel(record, registration), registerProvider: (provider) => registerProvider(record, provider), registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), registerCommand: (command) => registerCommand(record, command), resolvePath: (input) => resolveUserPath(input), on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts) }; }; return { registry, createApi, pushDiagnostic, registerTool, registerChannel, registerProvider, registerGatewayMethod, registerCli, registerService, registerCommand, registerHook, registerTypedHook }; } //#endregion //#region src/plugins/runtime.ts const REGISTRY_STATE = Symbol.for("openclaw.pluginRegistryState"); const state = (() => { const globalState = globalThis; if (!globalState[REGISTRY_STATE]) globalState[REGISTRY_STATE] = { registry: createEmptyPluginRegistry(), key: null }; return globalState[REGISTRY_STATE]; })(); function setActivePluginRegistry(registry, cacheKey) { state.registry = registry; state.key = cacheKey ?? null; } function getActivePluginRegistry() { return state.registry; } function requireActivePluginRegistry() { if (!state.registry) state.registry = createEmptyPluginRegistry(); return state.registry; } //#endregion //#region src/channels/registry.ts const CHAT_CHANNEL_ORDER = [ "telegram", "whatsapp", "discord", "irc", "googlechat", "slack", "signal", "imessage" ]; const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER]; const DEFAULT_CHAT_CHANNEL = "whatsapp"; const CHAT_CHANNEL_META = { telegram: { id: "telegram", label: "Telegram", selectionLabel: "Telegram (Bot API)", detailLabel: "Telegram Bot", docsPath: "/channels/telegram", docsLabel: "telegram", blurb: "simplest way to get started — register a bot with @BotFather and get going.", systemImage: "paperplane", selectionDocsPrefix: "", selectionDocsOmitLabel: true, selectionExtras: ["https://openclaw.ai"] }, whatsapp: { id: "whatsapp", label: "WhatsApp", selectionLabel: "WhatsApp (QR link)", detailLabel: "WhatsApp Web", docsPath: "/channels/whatsapp", docsLabel: "whatsapp", blurb: "works with your own number; recommend a separate phone + eSIM.", systemImage: "message" }, discord: { id: "discord", label: "Discord", selectionLabel: "Discord (Bot API)", detailLabel: "Discord Bot", docsPath: "/channels/discord", docsLabel: "discord", blurb: "very well supported right now.", systemImage: "bubble.left.and.bubble.right" }, irc: { id: "irc", label: "IRC", selectionLabel: "IRC (Server + Nick)", detailLabel: "IRC", docsPath: "/channels/irc", docsLabel: "irc", blurb: "classic IRC networks with DM/channel routing and pairing controls.", systemImage: "network" }, googlechat: { id: "googlechat", label: "Google Chat", selectionLabel: "Google Chat (Chat API)", detailLabel: "Google Chat", docsPath: "/channels/googlechat", docsLabel: "googlechat", blurb: "Google Workspace Chat app with HTTP webhook.", systemImage: "message.badge" }, slack: { id: "slack", label: "Slack", selectionLabel: "Slack (Socket Mode)", detailLabel: "Slack Bot", docsPath: "/channels/slack", docsLabel: "slack", blurb: "supported (Socket Mode).", systemImage: "number" }, signal: { id: "signal", label: "Signal", selectionLabel: "Signal (signal-cli)", detailLabel: "Signal REST", docsPath: "/channels/signal", docsLabel: "signal", blurb: "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").", systemImage: "antenna.radiowaves.left.and.right" }, imessage: { id: "imessage", label: "iMessage", selectionLabel: "iMessage (imsg)", detailLabel: "iMessage", docsPath: "/channels/imessage", docsLabel: "imessage", blurb: "this is still a work in progress.", systemImage: "message.fill" } }; const CHAT_CHANNEL_ALIASES = { imsg: "imessage", "internet-relay-chat": "irc", "google-chat": "googlechat", gchat: "googlechat" }; const normalizeChannelKey = (raw) => { return raw?.trim().toLowerCase() || void 0; }; function listChatChannels() { return CHAT_CHANNEL_ORDER.map((id) => CHAT_CHANNEL_META[id]); } function getChatChannelMeta(id) { return CHAT_CHANNEL_META[id]; } function normalizeChatChannelId(raw) { const normalized = normalizeChannelKey(raw); if (!normalized) return null; const resolved = CHAT_CHANNEL_ALIASES[normalized] ?? normalized; return CHAT_CHANNEL_ORDER.includes(resolved) ? resolved : null; } function normalizeChannelId(raw) { return normalizeChatChannelId(raw); } function normalizeAnyChannelId(raw) { const key = normalizeChannelKey(raw); if (!key) return null; return requireActivePluginRegistry().channels.find((entry) => { const id = String(entry.plugin.id ?? "").trim().toLowerCase(); if (id && id === key) return true; return (entry.plugin.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === key); })?.plugin.id ?? null; } function formatChannelPrimerLine(meta) { return `${meta.label}: ${meta.blurb}`; } function formatChannelSelectionLine(meta, docsLink) { const docsPrefix = meta.selectionDocsPrefix ?? "Docs:"; const docsLabel = meta.docsLabel ?? meta.id; const docs = meta.selectionDocsOmitLabel ? docsLink(meta.docsPath) : docsLink(meta.docsPath, docsLabel); const extras = (meta.selectionExtras ?? []).filter(Boolean).join(" "); return `${meta.label}${meta.blurb} ${docsPrefix ? `${docsPrefix} ` : ""}${docs}${extras ? ` ${extras}` : ""}`; } //#endregion export { createInternalHookEvent as C, clearInternalHooks as S, triggerInternalHook as T, clearPluginCommands as _, formatChannelSelectionLine as a, listPluginCommands as b, normalizeAnyChannelId as c, getActivePluginRegistry as d, requireActivePluginRegistry as f, normalizePluginHttpPath as g, createPluginRegistry as h, formatChannelPrimerLine as i, normalizeChannelId as l, createEmptyPluginRegistry as m, CHAT_CHANNEL_ORDER as n, getChatChannelMeta as o, setActivePluginRegistry as p, DEFAULT_CHAT_CHANNEL as r, listChatChannels as s, CHANNEL_IDS as t, normalizeChatChannelId as u, executePluginCommand as v, registerInternalHook as w, matchPluginCommand as x, getPluginCommandSpecs as y };