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
|
```typescript
|
||||||
{
|
{
|
||||||
type: 'command' | 'session' | 'agent' | 'gateway',
|
type: 'command' | 'session' | 'agent' | 'gateway' | 'message',
|
||||||
action: string, // e.g., 'new', 'reset', 'stop'
|
action: string, // e.g., 'new', 'reset', 'stop', 'received'
|
||||||
sessionKey: string, // Session identifier
|
sessionKey: string, // Session identifier
|
||||||
timestamp: Date, // When the event occurred
|
timestamp: Date, // When the event occurred
|
||||||
messages: string[], // Push messages here to send to user
|
messages: string[], // Push messages here to send to user
|
||||||
context: {
|
context: {
|
||||||
|
// For command events:
|
||||||
sessionEntry?: SessionEntry,
|
sessionEntry?: SessionEntry,
|
||||||
sessionId?: string,
|
sessionId?: string,
|
||||||
sessionFile?: string,
|
sessionFile?: string,
|
||||||
@ -209,11 +210,49 @@ Each event includes:
|
|||||||
senderId?: string,
|
senderId?: string,
|
||||||
workspaceDir?: string,
|
workspaceDir?: string,
|
||||||
bootstrapFiles?: WorkspaceBootstrapFile[],
|
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
|
## Event Types
|
||||||
|
|
||||||
### Command Events
|
### Command Events
|
||||||
@ -235,6 +274,39 @@ Triggered when the gateway starts:
|
|||||||
|
|
||||||
- **`gateway:startup`**: After channels start and hooks are loaded
|
- **`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)
|
### Tool Result Hooks (Plugin API)
|
||||||
|
|
||||||
These hooks are not event-stream listeners; they let plugins synchronously adjust tool results before Moltbot persists them.
|
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:start`**: When a new session begins
|
||||||
- **`session:end`**: When a session ends
|
- **`session:end`**: When a session ends
|
||||||
- **`agent:error`**: When an agent encounters an error
|
- **`agent:error`**: When an agent encounters an error
|
||||||
- **`message:sent`**: When a message is sent
|
- **`message:sent`**: When an outbound message is sent
|
||||||
- **`message:received`**: When a message is received
|
|
||||||
|
|
||||||
## Creating Custom Hooks
|
## Creating Custom Hooks
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { MoltbotConfig } from "../../config/config.js";
|
|||||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||||
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
|
||||||
import { logVerbose } from "../../globals.js";
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/hooks.js";
|
||||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||||
import {
|
import {
|
||||||
logMessageProcessed,
|
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.
|
// Check if we should route replies to originating channel instead of dispatcher.
|
||||||
// Only route when the originating channel is DIFFERENT from the current surface.
|
// Only route when the originating channel is DIFFERENT from the current surface.
|
||||||
// This handles cross-provider routing (e.g., message from Telegram being processed
|
// 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 HookEventType = import("./internal-hooks.js").InternalHookEventType;
|
||||||
export type HookEvent = import("./internal-hooks.js").InternalHookEvent;
|
export type HookEvent = import("./internal-hooks.js").InternalHookEvent;
|
||||||
export type HookHandler = import("./internal-hooks.js").InternalHookHandler;
|
export type HookHandler = import("./internal-hooks.js").InternalHookHandler;
|
||||||
|
export type MessageReceivedContext = import("./internal-hooks.js").MessageReceivedHookContext;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
registerInternalHook as registerHook,
|
registerInternalHook as registerHook,
|
||||||
@ -11,4 +12,5 @@ export {
|
|||||||
getRegisteredEventKeys as getRegisteredHookEventKeys,
|
getRegisteredEventKeys as getRegisteredHookEventKeys,
|
||||||
triggerInternalHook as triggerHook,
|
triggerInternalHook as triggerHook,
|
||||||
createInternalHookEvent as createHookEvent,
|
createInternalHookEvent as createHookEvent,
|
||||||
|
isMessageReceivedEvent,
|
||||||
} from "./internal-hooks.js";
|
} from "./internal-hooks.js";
|
||||||
|
|||||||
@ -4,11 +4,13 @@ import {
|
|||||||
createInternalHookEvent,
|
createInternalHookEvent,
|
||||||
getRegisteredEventKeys,
|
getRegisteredEventKeys,
|
||||||
isAgentBootstrapEvent,
|
isAgentBootstrapEvent,
|
||||||
|
isMessageReceivedEvent,
|
||||||
registerInternalHook,
|
registerInternalHook,
|
||||||
triggerInternalHook,
|
triggerInternalHook,
|
||||||
unregisterInternalHook,
|
unregisterInternalHook,
|
||||||
type AgentBootstrapHookContext,
|
type AgentBootstrapHookContext,
|
||||||
type InternalHookEvent,
|
type InternalHookEvent,
|
||||||
|
type MessageReceivedHookContext,
|
||||||
} from "./internal-hooks.js";
|
} from "./internal-hooks.js";
|
||||||
|
|
||||||
describe("hooks", () => {
|
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", () => {
|
describe("getRegisteredEventKeys", () => {
|
||||||
it("should return all registered event keys", () => {
|
it("should return all registered event keys", () => {
|
||||||
registerInternalHook("command:new", vi.fn());
|
registerInternalHook("command:new", vi.fn());
|
||||||
@ -243,5 +289,42 @@ describe("hooks", () => {
|
|||||||
|
|
||||||
expect(results).toHaveLength(2);
|
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 { WorkspaceBootstrapFile } from "../agents/workspace.js";
|
||||||
import type { MoltbotConfig } from "../config/config.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 = {
|
export type AgentBootstrapHookContext = {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
@ -25,6 +25,45 @@ export type AgentBootstrapHookEvent = InternalHookEvent & {
|
|||||||
context: AgentBootstrapHookContext;
|
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 {
|
export interface InternalHookEvent {
|
||||||
/** The type of event (command, session, agent, gateway, etc.) */
|
/** The type of event (command, session, agent, gateway, etc.) */
|
||||||
type: InternalHookEventType;
|
type: InternalHookEventType;
|
||||||
@ -173,3 +212,17 @@ export function isAgentBootstrapEvent(event: InternalHookEvent): event is AgentB
|
|||||||
if (typeof context.workspaceDir !== "string") return false;
|
if (typeof context.workspaceDir !== "string") return false;
|
||||||
return Array.isArray(context.bootstrapFiles);
|
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