/** * Hook system for OpenClaw agent events * * Provides an extensible event-driven hook system for agent events * like command processing, session lifecycle, etc. */ import type { SessionsPatchParams } from "../../packages/gateway-protocol/src/schema/sessions.js"; import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; import type { CliDeps } from "../cli/outbound-send-deps.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { clearLegacyPluginInternalHooks, listLegacyPluginInternalHookEventKeys, listLegacyPluginInternalHooks, } from "../plugins/legacy-internal-hook-state.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import type { InternalHookEvent, InternalHookEventType, InternalHookHandler, } from "./internal-hook-types.js"; import type { MessageHookMediaFact } from "./message-hook-media.js"; export type { InternalHookEvent, InternalHookEventType, InternalHookHandler }; export type AgentBootstrapHookContext = { workspaceDir: string; bootstrapFiles: WorkspaceBootstrapFile[]; cfg?: OpenClawConfig; sessionKey?: string; sessionId?: string; agentId?: string; }; export type AgentBootstrapHookEvent = InternalHookEvent & { type: "agent"; action: "bootstrap"; context: AgentBootstrapHookContext; }; export type GatewayStartupHookContext = { cfg?: OpenClawConfig; deps?: CliDeps; workspaceDir?: string; }; export type GatewayStartupHookEvent = InternalHookEvent & { type: "gateway"; action: "startup"; context: GatewayStartupHookContext; }; // ============================================================================ // Message Hook Events // ============================================================================ export type MessageReceivedHookContext = { /** Sender identifier (e.g., phone number, user ID) */ from: string; /** Message content */ content: string; /** Unix timestamp when the message was received */ timestamp?: number; /** Channel identifier (for example "chat" or "support-chat") */ channelId: string; /** Provider account ID for multi-account setups */ accountId?: string; /** Conversation/chat ID */ conversationId?: string; /** Message ID from the provider */ messageId?: string; /** Staged, locally usable attachments in stable source order. */ media?: MessageHookMediaFact[]; /** Original attachment facts when local staging has not completed yet. */ originalMedia?: MessageHookMediaFact[]; /** True when originalMedia is present but media is withheld pending staging. */ mediaStagingPending?: boolean; /** Additional provider-specific metadata */ metadata?: Record; }; export type MessageSentHookContext = { /** Recipient identifier */ to: string; /** Message content */ content: string; /** Whether the message was sent successfully */ success: boolean; /** Error message if sending failed */ error?: string; /** Channel identifier (for example "chat" or "support-chat") */ channelId: string; /** Provider account ID for multi-account setups */ accountId?: string; /** Conversation/chat ID */ conversationId?: string; /** Message ID returned by the provider */ messageId?: string; /** Whether this message was sent in a group/channel context */ isGroup?: boolean; /** Group or channel identifier, if applicable */ groupId?: string; }; type MessageEnrichedBodyHookContext = { /** Sender identifier (e.g., phone number, user ID) */ from?: string; /** Recipient identifier */ to?: string; /** Original raw message body (e.g., "🎤 [Audio]") */ body?: string; /** Enriched body shown to the agent, including transcript */ bodyForAgent?: string; /** Unix timestamp when the message was received */ timestamp?: number; /** Channel identifier (for example "chat" or "support-chat") */ channelId: string; /** Conversation/chat ID */ conversationId?: string; /** Message ID from the provider */ messageId?: string; /** Sender user ID */ senderId?: string; /** Sender display name */ senderName?: string; /** Sender username */ senderUsername?: string; /** Provider name */ provider?: string; /** Surface name */ surface?: string; /** Ordered media facts available to preprocessing/transcription hooks. */ media?: MessageHookMediaFact[]; /** Original facts when local staging has not completed yet. */ originalMedia?: MessageHookMediaFact[]; /** True when originalMedia is present but media is withheld pending staging. */ mediaStagingPending?: boolean; /** @deprecated Use `media?.[0]?.path`. */ mediaPath?: string; /** @deprecated Use `media?.[0]?.contentType` or `.kind`. */ mediaType?: string; }; export type MessageTranscribedHookContext = MessageEnrichedBodyHookContext & { /** The transcribed text from audio */ transcript: string; }; export type MessagePreprocessedHookContext = MessageEnrichedBodyHookContext & { /** Transcribed audio text, if the message contained audio */ transcript?: string; /** Whether this message was sent in a group/channel context */ isGroup?: boolean; /** Group or channel identifier, if applicable */ groupId?: string; }; export type SessionPatchHookContext = { sessionEntry: SessionEntry; patch: SessionsPatchParams; cfg: OpenClawConfig; }; export type SessionPatchHookEvent = InternalHookEvent & { type: "session"; action: "patch"; context: SessionPatchHookContext; }; /** * Registry of hook handlers by event key. * * Uses a globalThis singleton so that registerInternalHook and * triggerInternalHook always share the same Map even when the bundler * emits multiple copies of this module into separate chunks (bundle * splitting). Without the singleton, handlers registered in one chunk * are invisible to triggerInternalHook in another chunk, causing hooks * to silently fire with zero handlers. */ const INTERNAL_HOOK_HANDLERS_KEY = Symbol.for("openclaw.internalHookHandlers"); const handlers = resolveGlobalSingleton>( INTERNAL_HOOK_HANDLERS_KEY, () => new Map(), ); const INTERNAL_HOOKS_ENABLED_KEY = Symbol.for("openclaw.internalHooksEnabled"); const internalHooksEnabledState = resolveGlobalSingleton<{ enabled: boolean }>( INTERNAL_HOOKS_ENABLED_KEY, () => ({ enabled: true }), ); const log = createSubsystemLogger("internal-hooks"); /** * 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); * }); * ``` */ export function registerInternalHook(eventKey: string, handler: InternalHookHandler): void { if (!handlers.has(eventKey)) { handlers.set(eventKey, []); } handlers.get(eventKey)!.push(handler); } /** * Unregister a specific hook handler * * @param eventKey - Event key the handler was registered for * @param handler - The handler function to remove */ export function unregisterInternalHook(eventKey: string, handler: InternalHookHandler): void { const eventHandlers = handlers.get(eventKey); if (!eventHandlers) { return; } const index = eventHandlers.indexOf(handler); if (index !== -1) { eventHandlers.splice(index, 1); } // Clean up empty handler arrays if (eventHandlers.length === 0) { handlers.delete(eventKey); } } /** * Clear all registered hooks (useful for testing) */ export function clearInternalHooks(): void { handlers.clear(); clearLegacyPluginInternalHooks(); } export function setInternalHooksEnabled(enabled: boolean): void { internalHooksEnabledState.enabled = enabled; } /** * Get all registered event keys (useful for debugging) */ export function getRegisteredEventKeys(): string[] { return [...new Set([...handlers.keys(), ...listLegacyPluginInternalHookEventKeys()])]; } export function hasInternalHookListeners(type: InternalHookEventType, action: string): boolean { return ( (handlers.get(type)?.length ?? 0) + listLegacyPluginInternalHooks(type).length > 0 || (handlers.get(`${type}:${action}`)?.length ?? 0) + listLegacyPluginInternalHooks(`${type}:${action}`).length > 0 ); } /** * 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 */ export async function triggerInternalHook(event: InternalHookEvent): Promise { if (!internalHooksEnabledState.enabled) { return; } if (!hasInternalHookListeners(event.type, event.action)) { return; } // An admitted event finishes its snapshot even if a handler awaits across a reload or disable. const typeHandlers = [ ...(handlers.get(event.type) ?? []), ...listLegacyPluginInternalHooks(event.type), ]; const specificKey = `${event.type}:${event.action}`; const specificHandlers = [ ...(handlers.get(specificKey) ?? []), ...listLegacyPluginInternalHooks(specificKey), ]; const allHandlers = [...typeHandlers, ...specificHandlers]; for (const handler of allHandlers) { try { await handler(event); } catch (err) { const message = formatErrorMessage(err); log.error(`Hook error [${event.type}:${event.action}]: ${message}`); } } } /** * 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 */ export function createInternalHookEvent( type: InternalHookEventType, action: string, sessionKey: string, context: Record = {}, ): InternalHookEvent { return { type, action, sessionKey, context, timestamp: new Date(), messages: [], }; } function isHookEventTypeAndAction( event: InternalHookEvent, type: InternalHookEventType, action: string, ): boolean { return event.type === type && event.action === action; } function getHookContext>( event: InternalHookEvent, ): Partial | null { const context = event.context as Partial | null; if (!context || typeof context !== "object") { return null; } return context; } function hasStringContextField>( context: Partial, key: keyof T, ): boolean { return typeof context[key] === "string"; } export function isAgentBootstrapEvent(event: InternalHookEvent): event is AgentBootstrapHookEvent { if (!isHookEventTypeAndAction(event, "agent", "bootstrap")) { return false; } const context = getHookContext(event); if (!context) { return false; } if (!hasStringContextField(context, "workspaceDir")) { return false; } return Array.isArray(context.bootstrapFiles); } export function isGatewayStartupEvent(event: InternalHookEvent): event is GatewayStartupHookEvent { if (!isHookEventTypeAndAction(event, "gateway", "startup")) { return false; } return Boolean(getHookContext(event)); } export function isSessionPatchEvent(event: InternalHookEvent): event is SessionPatchHookEvent { if (!isHookEventTypeAndAction(event, "session", "patch")) { return false; } const context = getHookContext(event); if (!context) { return false; } return ( typeof context.patch === "object" && context.patch !== null && typeof context.cfg === "object" && context.cfg !== null && typeof context.sessionEntry === "object" && context.sessionEntry !== null ); }