feat(hooks): Add message:received hook event
Implements the message:received hook event that fires when a user message is received, before processing. This enables hooks to: - Track user activity in real-time - Update state files without polling - React to incoming messages Changes: - Add 'message' to InternalHookEventType union - Fire message:received event in dispatchInboundMessage() - Event fires async to avoid blocking message processing - Include context: senderId, channel, messageBody (truncated), timestamp, cfg New bundled hook: - activity-tracker: Updates heartbeat-state.json on every user message - Sets lastUserMessage to current timestamp - Sets backupActive=true to enable hourly backups - Enables session-backup hook to work without polling Tests: - message-received.test.ts: Tests for the new hook event Docs: - Updated hooks.md to document message:received event - Moved from 'Future Events' to 'Message Events' section
This commit is contained in:
parent
b85810a550
commit
3a1ca0ea8b
@ -241,6 +241,19 @@ These hooks are not event-stream listeners; they let plugins synchronously adjus
|
||||
|
||||
- **`tool_result_persist`**: transform tool results before they are written to the session transcript. Must be synchronous; return the updated tool result payload or `undefined` to keep it as-is. See [Agent Loop](/concepts/agent-loop).
|
||||
|
||||
### Message Events
|
||||
|
||||
Triggered when messages are processed:
|
||||
|
||||
- **`message:received`**: When a user message is received (fires before processing)
|
||||
|
||||
Context includes:
|
||||
- `senderId`: The sender's ID
|
||||
- `channel`: The channel the message came from
|
||||
- `messageBody`: The message content (truncated to 500 chars)
|
||||
- `timestamp`: When the message was received
|
||||
- `cfg`: The current config
|
||||
|
||||
### Future Events
|
||||
|
||||
Planned event types:
|
||||
@ -249,7 +262,6 @@ Planned event types:
|
||||
- **`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
|
||||
|
||||
## Creating Custom Hooks
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
type ReplyDispatcherOptions,
|
||||
type ReplyDispatcherWithTypingOptions,
|
||||
} from "./reply/reply-dispatcher.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-hooks.js";
|
||||
|
||||
export type DispatchInboundResult = DispatchFromConfigResult;
|
||||
|
||||
@ -22,6 +23,27 @@ export async function dispatchInboundMessage(params: {
|
||||
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
|
||||
}): Promise<DispatchInboundResult> {
|
||||
const finalized = finalizeInboundContext(params.ctx);
|
||||
|
||||
// Trigger message:received hook for user messages
|
||||
// This enables hooks to track activity, update state, etc.
|
||||
const sessionKey = finalized.SessionKey ?? "";
|
||||
const senderId = finalized.SenderId ?? finalized.From ?? "";
|
||||
const channel = finalized.OriginatingChannel ?? finalized.Channel ?? "";
|
||||
const messageBody = finalized.Body ?? finalized.RawBody ?? "";
|
||||
|
||||
const hookEvent = createInternalHookEvent("message", "received", sessionKey, {
|
||||
senderId,
|
||||
channel,
|
||||
messageBody: typeof messageBody === "string" ? messageBody.slice(0, 500) : "",
|
||||
timestamp: Date.now(),
|
||||
cfg: params.cfg,
|
||||
});
|
||||
|
||||
// Fire hook asynchronously - don't block message processing
|
||||
void triggerInternalHook(hookEvent).catch((err) => {
|
||||
console.error("[message:received hook]", err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return await dispatchReplyFromConfig({
|
||||
ctx: finalized,
|
||||
cfg: params.cfg,
|
||||
|
||||
59
src/hooks/bundled/activity-tracker/HOOK.md
Normal file
59
src/hooks/bundled/activity-tracker/HOOK.md
Normal file
@ -0,0 +1,59 @@
|
||||
---
|
||||
name: activity-tracker
|
||||
description: "Tracks user activity for memory backup lifecycle management"
|
||||
homepage: https://docs.clawd.bot/hooks#activity-tracker
|
||||
metadata:
|
||||
{
|
||||
"clawdbot":
|
||||
{
|
||||
"emoji": "📍",
|
||||
"events": ["message:received"],
|
||||
"requires": { "config": ["workspace.dir"] },
|
||||
"install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }],
|
||||
},
|
||||
}
|
||||
---
|
||||
|
||||
# Activity Tracker Hook
|
||||
|
||||
Tracks user message activity for memory backup lifecycle management.
|
||||
|
||||
## What It Does
|
||||
|
||||
When a user sends a message (`message:received` event):
|
||||
|
||||
1. Updates `memory/heartbeat-state.json` with current timestamp
|
||||
2. Sets `backupActive = true` to enable hourly backups
|
||||
3. Fires asynchronously - doesn't block message processing
|
||||
|
||||
## Why It Matters
|
||||
|
||||
This hook enables the session-backup hook to know when the user is active without:
|
||||
- Polling session logs repeatedly
|
||||
- Relying on the agent to remember to update state
|
||||
- Adding latency to message processing
|
||||
|
||||
## State File
|
||||
|
||||
Updates `memory/heartbeat-state.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"lastUserMessage": 1234567890,
|
||||
"backupActive": true,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Config**: `workspace.dir` must be set
|
||||
|
||||
## Disabling
|
||||
|
||||
```bash
|
||||
clawdbot hooks disable activity-tracker
|
||||
```
|
||||
|
||||
If disabled, the session-backup hook will still work but will rely on
|
||||
polling session logs for activity detection (less efficient).
|
||||
99
src/hooks/bundled/activity-tracker/handler.ts
Normal file
99
src/hooks/bundled/activity-tracker/handler.ts
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Activity tracker hook handler
|
||||
*
|
||||
* Updates heartbeat-state.json when user messages are received.
|
||||
* This enables session-backup to know when user is active without polling.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import type { ClawdbotConfig } from "../../../config/config.js";
|
||||
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
|
||||
import type { HookHandler } from "../../hooks.js";
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
interface BackupState {
|
||||
lastBackup: number | null;
|
||||
lastDistill: number | null;
|
||||
lastSessionSummary: number | null;
|
||||
lastUserMessage: number | null;
|
||||
backupActive: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// State Management
|
||||
// ============================================
|
||||
|
||||
async function readState(stateFile: string): Promise<BackupState> {
|
||||
try {
|
||||
const content = await fs.readFile(stateFile, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return {
|
||||
lastBackup: null,
|
||||
lastDistill: null,
|
||||
lastSessionSummary: null,
|
||||
lastUserMessage: null,
|
||||
backupActive: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeState(stateFile: string, state: BackupState): Promise<void> {
|
||||
await fs.writeFile(stateFile, JSON.stringify(state, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Hook Handler
|
||||
// ============================================
|
||||
|
||||
const activityTrackerHandler: HookHandler = async (event) => {
|
||||
// Only trigger on message:received events
|
||||
if (event.type !== "message" || event.action !== "received") {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = event.context || {};
|
||||
const cfg = context.cfg as ClawdbotConfig | undefined;
|
||||
|
||||
if (!cfg) {
|
||||
// Config not available, skip silently
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const agentId = resolveAgentIdFromSessionKey(event.sessionKey);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const memoryDir = path.join(workspaceDir, "memory");
|
||||
const stateFile = path.join(memoryDir, "heartbeat-state.json");
|
||||
|
||||
// Ensure directory exists
|
||||
await fs.mkdir(memoryDir, { recursive: true });
|
||||
|
||||
// Read current state
|
||||
const state = await readState(stateFile);
|
||||
|
||||
// Update activity
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
state.lastUserMessage = now;
|
||||
state.backupActive = true;
|
||||
|
||||
// Write updated state
|
||||
await writeState(stateFile, state);
|
||||
|
||||
console.log(`[activity-tracker] Updated: lastUserMessage=${now}, backupActive=true`);
|
||||
} catch (err) {
|
||||
// Log but don't throw - we don't want to affect message processing
|
||||
console.error(
|
||||
"[activity-tracker] Failed to update state:",
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default activityTrackerHandler;
|
||||
@ -8,7 +8,7 @@
|
||||
import type { WorkspaceBootstrapFile } from "../agents/workspace.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
export type InternalHookEventType = "command" | "session" | "agent" | "gateway";
|
||||
export type InternalHookEventType = "command" | "session" | "agent" | "gateway" | "message";
|
||||
|
||||
export type AgentBootstrapHookContext = {
|
||||
workspaceDir: string;
|
||||
|
||||
121
test/hooks/message-received.test.ts
Normal file
121
test/hooks/message-received.test.ts
Normal file
@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Tests for message:received hook event
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
registerInternalHook,
|
||||
unregisterInternalHook,
|
||||
clearInternalHooks,
|
||||
createInternalHookEvent,
|
||||
triggerInternalHook,
|
||||
} from "../../src/hooks/internal-hooks.js";
|
||||
|
||||
describe("message:received hook event", () => {
|
||||
beforeEach(() => {
|
||||
clearInternalHooks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearInternalHooks();
|
||||
});
|
||||
|
||||
it("triggers handler on message:received event", async () => {
|
||||
const handler = vi.fn();
|
||||
registerInternalHook("message:received", handler);
|
||||
|
||||
const event = createInternalHookEvent("message", "received", "test-session", {
|
||||
senderId: "user123",
|
||||
channel: "telegram",
|
||||
messageBody: "Hello",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
await triggerInternalHook(event);
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(handler).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("includes message context in event", async () => {
|
||||
let capturedEvent: any = null;
|
||||
const handler = vi.fn((event) => {
|
||||
capturedEvent = event;
|
||||
});
|
||||
registerInternalHook("message:received", handler);
|
||||
|
||||
const event = createInternalHookEvent("message", "received", "agent:main:dm:user123", {
|
||||
senderId: "user123",
|
||||
channel: "telegram",
|
||||
messageBody: "Test message content",
|
||||
timestamp: 1234567890,
|
||||
});
|
||||
|
||||
await triggerInternalHook(event);
|
||||
|
||||
expect(capturedEvent).not.toBeNull();
|
||||
expect(capturedEvent.type).toBe("message");
|
||||
expect(capturedEvent.action).toBe("received");
|
||||
expect(capturedEvent.sessionKey).toBe("agent:main:dm:user123");
|
||||
expect(capturedEvent.context.senderId).toBe("user123");
|
||||
expect(capturedEvent.context.channel).toBe("telegram");
|
||||
expect(capturedEvent.context.messageBody).toBe("Test message content");
|
||||
expect(capturedEvent.context.timestamp).toBe(1234567890);
|
||||
});
|
||||
|
||||
it("allows multiple handlers for message:received", async () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
registerInternalHook("message:received", handler1);
|
||||
registerInternalHook("message:received", handler2);
|
||||
|
||||
const event = createInternalHookEvent("message", "received", "test-session", {});
|
||||
await triggerInternalHook(event);
|
||||
|
||||
expect(handler1).toHaveBeenCalledTimes(1);
|
||||
expect(handler2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("also triggers general message type handler", async () => {
|
||||
const generalHandler = vi.fn();
|
||||
const specificHandler = vi.fn();
|
||||
|
||||
registerInternalHook("message", generalHandler);
|
||||
registerInternalHook("message:received", specificHandler);
|
||||
|
||||
const event = createInternalHookEvent("message", "received", "test-session", {});
|
||||
await triggerInternalHook(event);
|
||||
|
||||
expect(generalHandler).toHaveBeenCalledTimes(1);
|
||||
expect(specificHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not throw if handler errors", async () => {
|
||||
const failingHandler = vi.fn(() => {
|
||||
throw new Error("Handler error");
|
||||
});
|
||||
registerInternalHook("message:received", failingHandler);
|
||||
|
||||
const event = createInternalHookEvent("message", "received", "test-session", {});
|
||||
|
||||
// Should not throw
|
||||
await expect(triggerInternalHook(event)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("continues to other handlers if one fails", async () => {
|
||||
const failingHandler = vi.fn(() => {
|
||||
throw new Error("First handler error");
|
||||
});
|
||||
const successHandler = vi.fn();
|
||||
|
||||
registerInternalHook("message:received", failingHandler);
|
||||
registerInternalHook("message:received", successHandler);
|
||||
|
||||
const event = createInternalHookEvent("message", "received", "test-session", {});
|
||||
await triggerInternalHook(event);
|
||||
|
||||
expect(failingHandler).toHaveBeenCalled();
|
||||
expect(successHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user