openclaw/src/hooks/bundled/activity-tracker/handler.ts
Rex Liu 3a1ca0ea8b
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
2026-01-26 22:01:54 -05:00

100 lines
2.9 KiB
TypeScript

/**
* 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;