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:
parent
48f97b4667
commit
bf9f78c14c
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user