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
This commit is contained in:
Steven Gonsalvez 2026-01-28 20:51:05 +00:00
parent 48f97b4667
commit bf9f78c14c
No known key found for this signature in database
GPG Key ID: 907EC78C72C6AFF6
5 changed files with 261 additions and 6 deletions

View File

@ -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<string, unknown>
}
}
```
#### 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

View File

@ -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

View File

@ -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";

View File

@ -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");
});
});
});

View File

@ -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<MessageReceivedHookContext> | 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";
}