From bf9f78c14c7d0a2aaf156598dad12f2e5e106330 Mon Sep 17 00:00:00 2001 From: Steven Gonsalvez Date: Wed, 28 Jan 2026 20:51:05 +0000 Subject: [PATCH] feat(hooks): add message:received internal hook - Add MessageReceivedHookContext type with full message metadata - Add isMessageReceivedEvent() type guard for type-safe handling - Trigger hook in dispatchReplyFromConfig() after plugin hooks - Fire-and-forget execution to avoid blocking message processing - Works across all 12 channels (whatsapp, telegram, discord, etc.) - Add comprehensive tests for type guard and hook triggering - Document message events, context fields, and filtering examples --- docs/hooks.md | 81 +++++++++++++++++-- src/auto-reply/reply/dispatch-from-config.ts | 46 +++++++++++ src/hooks/hooks.ts | 2 + src/hooks/internal-hooks.test.ts | 83 ++++++++++++++++++++ src/hooks/internal-hooks.ts | 55 ++++++++++++- 5 files changed, 261 insertions(+), 6 deletions(-) diff --git a/docs/hooks.md b/docs/hooks.md index 8576146ba..52ad78fdd 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -196,12 +196,13 @@ Each event includes: ```typescript { - type: 'command' | 'session' | 'agent' | 'gateway', - action: string, // e.g., 'new', 'reset', 'stop' + type: 'command' | 'session' | 'agent' | 'gateway' | 'message', + action: string, // e.g., 'new', 'reset', 'stop', 'received' sessionKey: string, // Session identifier timestamp: Date, // When the event occurred messages: string[], // Push messages here to send to user context: { + // For command events: sessionEntry?: SessionEntry, sessionId?: string, sessionFile?: string, @@ -209,11 +210,49 @@ Each event includes: senderId?: string, workspaceDir?: string, bootstrapFiles?: WorkspaceBootstrapFile[], - cfg?: MoltbotConfig + cfg?: ClawdbotConfig, + // For message:received events: + from?: string, + content?: string, + channelId?: string, + metadata?: Record } } ``` +#### Message Received Handler Example + +```typescript +import type { HookHandler } from '../../src/hooks/hooks.js'; +import { isMessageReceivedEvent } from '../../src/hooks/hooks.js'; + +const handler: HookHandler = async (event) => { + if (!isMessageReceivedEvent(event)) return; + + const { from, content, channelId, metadata } = event.context; + + console.log(`[message-hook] ${channelId}: ${from} said "${content.slice(0, 50)}..."`); + + // Example: Log to external service, analytics, audit trail, etc. + // await logToExternalService({ from, content, channel: channelId }); +}; + +export default handler; +``` + +**HOOK.md** for message watcher: +```markdown +--- +name: message-watcher +description: "Watch all inbound messages" +metadata: {"clawdbot":{"emoji":"👀","events":["message:received"]}} +--- + +# Message Watcher + +Logs all inbound messages for debugging or auditing. +``` + ## Event Types ### Command Events @@ -235,6 +274,39 @@ Triggered when the gateway starts: - **`gateway:startup`**: After channels start and hooks are loaded +### Message Events + +Triggered when messages are received: + +- **`message`**: All message events (general listener) +- **`message:received`**: When an inbound message is received from any channel (WhatsApp, Telegram, Discord, Slack, Signal, iMessage, Gateway/WebUI, MS Teams, Matrix, etc.). Fire-and-forget; cannot modify the message. + +#### Message Received Context + +For `message:received` events, the context includes: + +```typescript +{ + from: string; // Sender identifier (phone, user ID, etc.) + content: string; // Message text body + timestamp?: number; // Unix ms timestamp (if available) + channelId: string; // "whatsapp", "telegram", "discord", etc. + accountId?: string; // Multi-account bot ID + conversationId?: string;// Chat/conversation ID + metadata: { + to?: string; + provider?: string; + surface?: string; + threadId?: string; + messageId?: string; + senderId?: string; + senderName?: string; + senderUsername?: string; + senderE164?: string; // E.164 phone number + } +} +``` + ### Tool Result Hooks (Plugin API) These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before Moltbot persists them. @@ -248,8 +320,7 @@ Planned event types: - **`session:start`**: When a new session begins - **`session:end`**: When a session ends - **`agent:error`**: When an agent encounters an error -- **`message:sent`**: When a message is sent -- **`message:received`**: When a message is received +- **`message:sent`**: When an outbound message is sent ## Creating Custom Hooks diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 58d5d71b5..fb02a6c41 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -2,6 +2,7 @@ import type { MoltbotConfig } from "../../config/config.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; +import { createInternalHookEvent, triggerInternalHook } from "../../hooks/hooks.js"; import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; import { logMessageProcessed, @@ -183,6 +184,51 @@ export async function dispatchReplyFromConfig(params: { }); } + // Trigger internal message:received hook (for user hooks in ~/.clawdbot/hooks/) + // Fire-and-forget: don't await, errors logged internally by triggerInternalHook + { + const timestamp = + typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp) + ? ctx.Timestamp + : undefined; + const messageIdForHook = + ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; + const content = + typeof ctx.BodyForCommands === "string" + ? ctx.BodyForCommands + : typeof ctx.RawBody === "string" + ? ctx.RawBody + : typeof ctx.Body === "string" + ? ctx.Body + : ""; + const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase(); + const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined; + + void triggerInternalHook( + createInternalHookEvent("message", "received", sessionKey ?? "", { + from: ctx.From ?? "", + content, + timestamp, + channelId, + accountId: ctx.AccountId, + conversationId, + metadata: { + to: ctx.To, + provider: ctx.Provider, + surface: ctx.Surface, + threadId: ctx.MessageThreadId, + originatingChannel: ctx.OriginatingChannel, + originatingTo: ctx.OriginatingTo, + messageId: messageIdForHook, + senderId: ctx.SenderId, + senderName: ctx.SenderName, + senderUsername: ctx.SenderUsername, + senderE164: ctx.SenderE164, + }, + }), + ); + } + // Check if we should route replies to originating channel instead of dispatcher. // Only route when the originating channel is DIFFERENT from the current surface. // This handles cross-provider routing (e.g., message from Telegram being processed diff --git a/src/hooks/hooks.ts b/src/hooks/hooks.ts index 3cf7a0958..4e414917d 100644 --- a/src/hooks/hooks.ts +++ b/src/hooks/hooks.ts @@ -3,6 +3,7 @@ export * from "./internal-hooks.js"; export type HookEventType = import("./internal-hooks.js").InternalHookEventType; export type HookEvent = import("./internal-hooks.js").InternalHookEvent; export type HookHandler = import("./internal-hooks.js").InternalHookHandler; +export type MessageReceivedContext = import("./internal-hooks.js").MessageReceivedHookContext; export { registerInternalHook as registerHook, @@ -11,4 +12,5 @@ export { getRegisteredEventKeys as getRegisteredHookEventKeys, triggerInternalHook as triggerHook, createInternalHookEvent as createHookEvent, + isMessageReceivedEvent, } from "./internal-hooks.js"; diff --git a/src/hooks/internal-hooks.test.ts b/src/hooks/internal-hooks.test.ts index e01e5bc3c..1f632ab2e 100644 --- a/src/hooks/internal-hooks.test.ts +++ b/src/hooks/internal-hooks.test.ts @@ -4,11 +4,13 @@ import { createInternalHookEvent, getRegisteredEventKeys, isAgentBootstrapEvent, + isMessageReceivedEvent, registerInternalHook, triggerInternalHook, unregisterInternalHook, type AgentBootstrapHookContext, type InternalHookEvent, + type MessageReceivedHookContext, } from "./internal-hooks.js"; describe("hooks", () => { @@ -182,6 +184,50 @@ describe("hooks", () => { }); }); + describe("isMessageReceivedEvent", () => { + it("returns true for message:received events with expected context", () => { + const context: MessageReceivedHookContext = { + from: "+1234567890", + content: "Hello world", + timestamp: Date.now(), + channelId: "whatsapp", + accountId: "default", + conversationId: "chat-123", + metadata: { + to: "bot", + provider: "whatsapp", + surface: "whatsapp", + senderId: "user-1", + senderName: "Test User", + }, + }; + const event = createInternalHookEvent("message", "received", "test-session", context); + expect(isMessageReceivedEvent(event)).toBe(true); + }); + + it("returns false for non-message events", () => { + const event = createInternalHookEvent("command", "new", "test-session"); + expect(isMessageReceivedEvent(event)).toBe(false); + }); + + it("returns false for message events with different action", () => { + const event = createInternalHookEvent("message", "sent", "test-session", { + from: "user", + content: "hello", + channelId: "telegram", + }); + expect(isMessageReceivedEvent(event)).toBe(false); + }); + + it("returns false for message:received with missing required fields", () => { + const event = createInternalHookEvent("message", "received", "test-session", { + from: "user", + // missing content and channelId + }); + expect(isMessageReceivedEvent(event)).toBe(false); + }); + }); + describe("getRegisteredEventKeys", () => { it("should return all registered event keys", () => { registerInternalHook("command:new", vi.fn()); @@ -243,5 +289,42 @@ describe("hooks", () => { expect(results).toHaveLength(2); }); + + it("should trigger message:received hooks with full context", async () => { + const receivedEvents: InternalHookEvent[] = []; + const generalHandler = vi.fn((event: InternalHookEvent) => { + receivedEvents.push(event); + }); + const specificHandler = vi.fn((event: InternalHookEvent) => { + receivedEvents.push(event); + }); + + // Register for both general and specific + registerInternalHook("message", generalHandler); + registerInternalHook("message:received", specificHandler); + + const context: MessageReceivedHookContext = { + from: "+1234567890", + content: "Test message", + timestamp: 1706300000000, + channelId: "telegram", + conversationId: "chat-456", + metadata: { + provider: "telegram", + surface: "telegram", + senderId: "user-123", + }, + }; + + const event = createInternalHookEvent("message", "received", "agent:main:telegram", context); + await triggerInternalHook(event); + + // Both handlers should fire + expect(generalHandler).toHaveBeenCalledTimes(1); + expect(specificHandler).toHaveBeenCalledTimes(1); + expect(receivedEvents).toHaveLength(2); + expect(receivedEvents[0].context).toEqual(context); + expect(receivedEvents[0].sessionKey).toBe("agent:main:telegram"); + }); }); }); diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 1b866d444..cfcd97fd6 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -8,7 +8,7 @@ import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; import type { MoltbotConfig } from "../config/config.js"; -export type InternalHookEventType = "command" | "session" | "agent" | "gateway"; +export type InternalHookEventType = "command" | "session" | "agent" | "gateway" | "message"; export type AgentBootstrapHookContext = { workspaceDir: string; @@ -25,6 +25,45 @@ export type AgentBootstrapHookEvent = InternalHookEvent & { context: AgentBootstrapHookContext; }; +/** + * Context for message:received hook events. + * Provides sender, content, channel, and rich metadata about the inbound message. + */ +export type MessageReceivedHookContext = { + /** Sender identifier (phone number, user ID, etc.) */ + from: string; + /** Message content (text body) */ + content: string; + /** Unix timestamp in milliseconds (if available) */ + timestamp?: number; + /** Normalized channel identifier (e.g., "whatsapp", "telegram", "discord") */ + channelId: string; + /** Account/bot ID if multi-account */ + accountId?: string; + /** Conversation/chat ID */ + conversationId?: string; + /** Additional metadata about the message */ + metadata: { + to?: string; + provider?: string; + surface?: string; + threadId?: string; + originatingChannel?: string; + originatingTo?: string; + messageId?: string; + senderId?: string; + senderName?: string; + senderUsername?: string; + senderE164?: string; + }; +}; + +export type MessageReceivedHookEvent = InternalHookEvent & { + type: "message"; + action: "received"; + context: MessageReceivedHookContext; +}; + export interface InternalHookEvent { /** The type of event (command, session, agent, gateway, etc.) */ type: InternalHookEventType; @@ -173,3 +212,17 @@ export function isAgentBootstrapEvent(event: InternalHookEvent): event is AgentB if (typeof context.workspaceDir !== "string") return false; return Array.isArray(context.bootstrapFiles); } + +/** + * Type guard for message:received events. + */ +export function isMessageReceivedEvent( + event: InternalHookEvent, +): event is MessageReceivedHookEvent { + if (event.type !== "message" || event.action !== "received") return false; + const context = event.context as Partial | null; + if (!context || typeof context !== "object") return false; + if (typeof context.from !== "string") return false; + if (typeof context.content !== "string") return false; + return typeof context.channelId === "string"; +}