Files
openclaw/src/hooks/internal-hooks.ts
2026-03-07 10:41:05 +00:00

422 lines
12 KiB
TypeScript

/**
* Hook system for OpenClaw agent events
*
* Provides an extensible event-driven hook system for agent events
* like command processing, session lifecycle, etc.
*/
import type { WorkspaceBootstrapFile } from "../agents/workspace.js";
import type { CliDeps } from "../cli/deps.js";
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
export type InternalHookEventType = "command" | "session" | "agent" | "gateway" | "message";
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 (e.g., "telegram", "whatsapp") */
channelId: string;
/** Provider account ID for multi-account setups */
accountId?: string;
/** Conversation/chat ID */
conversationId?: string;
/** Message ID from the provider */
messageId?: string;
/** Additional provider-specific metadata */
metadata?: Record<string, unknown>;
};
export type MessageReceivedHookEvent = InternalHookEvent & {
type: "message";
action: "received";
context: MessageReceivedHookContext;
};
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 (e.g., "telegram", "whatsapp") */
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;
};
export type MessageSentHookEvent = InternalHookEvent & {
type: "message";
action: "sent";
context: MessageSentHookContext;
};
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 (e.g., "telegram", "whatsapp") */
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;
/** Path to the media file that was transcribed */
mediaPath?: string;
/** MIME type of the media */
mediaType?: string;
};
export type MessageTranscribedHookContext = MessageEnrichedBodyHookContext & {
/** The transcribed text from audio */
transcript: string;
};
export type MessageTranscribedHookEvent = InternalHookEvent & {
type: "message";
action: "transcribed";
context: MessageTranscribedHookContext;
};
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 MessagePreprocessedHookEvent = InternalHookEvent & {
type: "message";
action: "preprocessed";
context: MessagePreprocessedHookContext;
};
export interface InternalHookEvent {
/** The type of event (command, session, agent, gateway, etc.) */
type: InternalHookEventType;
/** The specific action within the type (e.g., 'new', 'reset', 'stop') */
action: string;
/** The session key this event relates to */
sessionKey: string;
/** Additional context specific to the event */
context: Record<string, unknown>;
/** Timestamp when the event occurred */
timestamp: Date;
/** Messages to send back to the user (hooks can push to this array) */
messages: string[];
}
export type InternalHookHandler = (event: InternalHookEvent) => Promise<void> | void;
/**
* 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 _g = globalThis as typeof globalThis & {
__openclaw_internal_hook_handlers__?: Map<string, InternalHookHandler[]>;
};
const handlers = (_g.__openclaw_internal_hook_handlers__ ??= new Map<
string,
InternalHookHandler[]
>());
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();
}
/**
* Get all registered event keys (useful for debugging)
*/
export function getRegisteredEventKeys(): string[] {
return Array.from(handlers.keys());
}
/**
* 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<void> {
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) {
const message = err instanceof Error ? err.message : String(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<string, unknown> = {},
): 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<T extends Record<string, unknown>>(
event: InternalHookEvent,
): Partial<T> | null {
const context = event.context as Partial<T> | null;
if (!context || typeof context !== "object") {
return null;
}
return context;
}
function hasStringContextField<T extends Record<string, unknown>>(
context: Partial<T>,
key: keyof T,
): boolean {
return typeof context[key] === "string";
}
function hasBooleanContextField<T extends Record<string, unknown>>(
context: Partial<T>,
key: keyof T,
): boolean {
return typeof context[key] === "boolean";
}
export function isAgentBootstrapEvent(event: InternalHookEvent): event is AgentBootstrapHookEvent {
if (!isHookEventTypeAndAction(event, "agent", "bootstrap")) {
return false;
}
const context = getHookContext<AgentBootstrapHookContext>(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<GatewayStartupHookContext>(event));
}
export function isMessageReceivedEvent(
event: InternalHookEvent,
): event is MessageReceivedHookEvent {
if (!isHookEventTypeAndAction(event, "message", "received")) {
return false;
}
const context = getHookContext<MessageReceivedHookContext>(event);
if (!context) {
return false;
}
return hasStringContextField(context, "from") && hasStringContextField(context, "channelId");
}
export function isMessageSentEvent(event: InternalHookEvent): event is MessageSentHookEvent {
if (!isHookEventTypeAndAction(event, "message", "sent")) {
return false;
}
const context = getHookContext<MessageSentHookContext>(event);
if (!context) {
return false;
}
return (
hasStringContextField(context, "to") &&
hasStringContextField(context, "channelId") &&
hasBooleanContextField(context, "success")
);
}
export function isMessageTranscribedEvent(
event: InternalHookEvent,
): event is MessageTranscribedHookEvent {
if (!isHookEventTypeAndAction(event, "message", "transcribed")) {
return false;
}
const context = getHookContext<MessageTranscribedHookContext>(event);
if (!context) {
return false;
}
return (
hasStringContextField(context, "transcript") && hasStringContextField(context, "channelId")
);
}
export function isMessagePreprocessedEvent(
event: InternalHookEvent,
): event is MessagePreprocessedHookEvent {
if (!isHookEventTypeAndAction(event, "message", "preprocessed")) {
return false;
}
const context = getHookContext<MessagePreprocessedHookContext>(event);
if (!context) {
return false;
}
return hasStringContextField(context, "channelId");
}