- Replace direct Math.random mutation with vi.spyOn(Math, 'random').mockReturnValue(0.5) for idiomatic Vitest cleanup integration - Fix comment: collision is driven by identical LLM slug, not timestamp fallback; Math.random pin is a backstop for null sessionContent edge case - Remove unnecessary nullish-coalescing and conditional guard on postHookActions (field is required in interface and always initialized by createInternalHookEvent) Addresses greptile review feedback for confidence score improvement.
447 lines
13 KiB
TypeScript
447 lines
13 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[];
|
|
/** Deferred actions to run after all handlers complete.
|
|
* Handlers push async callbacks here; triggerInternalHook drains them
|
|
* sequentially after the main handler loop. This eliminates FIFO
|
|
* registration-order dependencies: a handler that runs early can defer
|
|
* work that depends on context set by later handlers. */
|
|
postHookActions: Array<() => Promise<void> | void>;
|
|
}
|
|
|
|
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];
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
// Drain post-hook actions — these run after all handlers have had
|
|
// a chance to mutate event.context, eliminating FIFO ordering issues.
|
|
// Actions execute in push order; errors are caught per-action so one
|
|
// failure doesn't block others.
|
|
//
|
|
// Snapshot the array before draining so that actions pushed *by* post-hook
|
|
// callbacks do not execute in this drain cycle. Without this, a self-
|
|
// scheduling action (one that pushes another action) could loop infinitely
|
|
// because Array's for...of iterator is live and re-reads length each step.
|
|
const pendingActions = [...event.postHookActions];
|
|
// Clear the source array so re-draining the same event is a no-op.
|
|
// Without this, passing an event twice would re-execute every action.
|
|
event.postHookActions.length = 0;
|
|
for (const action of pendingActions) {
|
|
try {
|
|
await action();
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
log.error(`Post-hook action 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: [],
|
|
postHookActions: [],
|
|
};
|
|
}
|
|
|
|
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");
|
|
}
|