diff --git a/CHANGELOG.md b/CHANGELOG.md index 5909c9899..addfc6306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -228,6 +228,10 @@ Status: beta. - Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman. - Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.molt.bot/automation/cron-vs-heartbeat - Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.molt.bot/gateway/heartbeat +- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. +- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. +- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg. +- Hooks: add config-driven message handlers for immediate agent triggering on inbound messages (bypasses queue). ### Fixes - Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518) diff --git a/docs/hooks.md b/docs/hooks.md index 52ad78fdd..79ba0c22b 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -322,6 +322,182 @@ Planned event types: - **`agent:error`**: When an agent encounters an error - **`message:sent`**: When an outbound message is sent +## Message Handlers + +Message handlers provide config-driven routing that triggers immediate agent execution when messages match specified conditions. Unlike regular hooks (which are fire-and-forget observers), message handlers can take over message processing entirely. + +### Problem Solved + +When cron jobs wake an agent, they inject their own prompt. Messages that arrived earlier (bug reports, user questions) sit in a queue and are never processed: + +``` +Bug report arrives (10:03) → queued +Cron fires (10:10) → agent wakes with cron prompt only +Bug report → never processed +``` + +Message handlers fix this by immediately processing important messages as they arrive. + +### Configuration + +```json +{ + "hooks": { + "internal": { + "messageHandlers": [ + { + "id": "bug-reports", + "match": { + "channelId": "whatsapp", + "conversationId": "+447563241014", + "contentContains": ["bug", "error", "broken"] + }, + "action": "agent", + "agentId": "support-bot", + "priority": "immediate", + "messagePrefix": "[BUG REPORT] ", + "thinking": "medium" + } + ] + } + } +} +``` + +### Match Conditions + +All specified conditions must match (AND logic): + +| Field | Type | Description | +|-------|------|-------------| +| `channelId` | `string \| string[]` | Channel to match: `"whatsapp"`, `"telegram"`, `["discord", "slack"]`, or `"*"` for all | +| `conversationId` | `string \| string[]` | Chat/group ID to match | +| `from` | `string \| string[]` | Sender identifier (phone number, user ID) | +| `contentPattern` | `string` | Regex pattern (case-insensitive) | +| `contentContains` | `string \| string[]` | Keywords to find in message (case-insensitive, any match) | + +### Handler Options + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `id` | `string` | required | Unique identifier for this handler | +| `enabled` | `boolean` | `true` | Enable/disable the handler | +| `match` | `object` | required | Match conditions (see above) | +| `action` | `"agent"` | required | Action type (currently only "agent") | +| `agentId` | `string` | route default | Which agent processes the message | +| `sessionKey` | `string` | auto-derived | Custom session key | +| `priority` | `"immediate" \| "queue"` | `"immediate"` | Immediate bypasses queue | +| `mode` | `"exclusive" \| "parallel"` | `"exclusive"` | Exclusive: handler only; Parallel: both handler AND normal flow | +| `messagePrefix` | `string` | `""` | Text prepended to message | +| `messageSuffix` | `string` | `""` | Text appended to message | +| `messageTemplate` | `string` | - | Full template with `{{content}}`, `{{from}}`, `{{channelId}}`, `{{conversationId}}` | +| `model` | `string` | - | Override model (provider/model or alias) | +| `thinking` | `"off" \| "low" \| "medium" \| "high"` | - | Thinking level | +| `timeoutSeconds` | `number` | - | Agent timeout | + +### Example Configurations + +#### Bug Reports from WhatsApp Group + +```json +{ + "hooks": { + "internal": { + "messageHandlers": [ + { + "id": "bug-reports", + "match": { + "channelId": "whatsapp", + "conversationId": "+447563241014", + "contentContains": ["bug", "error", "broken", "fix"] + }, + "action": "agent", + "agentId": "support-bot", + "priority": "immediate", + "messagePrefix": "[BUG REPORT] ", + "thinking": "medium" + } + ] + } + } +} +``` + +#### All WhatsApp Messages to Specific Agent + +```json +{ + "hooks": { + "internal": { + "messageHandlers": [ + { + "id": "whatsapp-handler", + "match": { "channelId": "whatsapp" }, + "action": "agent", + "agentId": "personal-assistant", + "priority": "immediate" + } + ] + } + } +} +``` + +#### Urgent Keywords Across All Channels + +```json +{ + "hooks": { + "internal": { + "messageHandlers": [ + { + "id": "urgent-handler", + "match": { + "contentPattern": "urgent|asap|emergency|critical" + }, + "action": "agent", + "priority": "immediate", + "messagePrefix": "[URGENT] ", + "model": "anthropic/claude-sonnet-4-20250514", + "thinking": "high" + } + ] + } + } +} +``` + +#### Parallel Mode: Log AND Process Normally + +```json +{ + "hooks": { + "internal": { + "messageHandlers": [ + { + "id": "analytics-logger", + "match": { "channelId": "whatsapp" }, + "action": "agent", + "agentId": "analytics-bot", + "priority": "immediate", + "mode": "parallel", + "messageTemplate": "[LOG] From: {{from}}, Content: {{content}}" + } + ] + } + } +} +``` + +This triggers `analytics-bot` AND lets the normal message flow continue (so the user's main agent also processes it). + +### Order of Evaluation + +1. Handlers are evaluated in order (first match wins) +2. Disabled handlers (`enabled: false`) are skipped +3. If a handler matches with `mode: "exclusive"` (default), normal processing stops +4. If a handler matches with `mode: "parallel"`, normal processing continues after the handler fires + ## Creating Custom Hooks ### 1. Choose Location diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index fb02a6c41..5146923ea 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,8 +1,12 @@ -import type { MoltbotConfig } from "../../config/config.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { createDefaultDeps } from "../../cli/deps.js"; +import type { MoltbotConfig } from "../../config/config.js"; import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; import { createInternalHookEvent, triggerInternalHook } from "../../hooks/hooks.js"; +import { matchMessageHandler } from "../../hooks/message-handler-match.js"; +import { runMessageHandler } from "../../hooks/message-handler-run.js"; +import type { MessageReceivedHookContext } from "../../hooks/internal-hooks.js"; import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; import { logMessageProcessed, @@ -70,6 +74,10 @@ const resolveSessionTtsAuto = ( export type DispatchFromConfigResult = { queuedFinal: boolean; counts: Record; + /** Set when a message handler took over processing */ + handledByMessageHandler?: { + handlerId: string; + }; }; export async function dispatchReplyFromConfig(params: { @@ -227,6 +235,64 @@ export async function dispatchReplyFromConfig(params: { }, }), ); + + // Check for config-driven message handlers + const messageHandlers = cfg.hooks?.internal?.messageHandlers ?? []; + if (messageHandlers.length > 0) { + const handlerContext: MessageReceivedHookContext = { + from: ctx.From ?? "", + content, + timestamp, + channelId, + accountId: ctx.AccountId, + conversationId, + metadata: { + to: ctx.To, + provider: ctx.Provider, + surface: ctx.Surface, + threadId: ctx.MessageThreadId != null ? String(ctx.MessageThreadId) : undefined, + originatingChannel: ctx.OriginatingChannel, + originatingTo: ctx.OriginatingTo, + messageId: messageIdForHook, + senderId: ctx.SenderId, + senderName: ctx.SenderName, + senderUsername: ctx.SenderUsername, + senderE164: ctx.SenderE164, + }, + }; + + const matchedHandler = matchMessageHandler(messageHandlers, handlerContext); + if (matchedHandler) { + const priority = matchedHandler.priority ?? "immediate"; + const mode = matchedHandler.mode ?? "exclusive"; + + if (priority === "immediate") { + // Fire-and-forget for immediate handlers + const deps = createDefaultDeps(); + void runMessageHandler({ + cfg, + deps, + handler: matchedHandler, + context: handlerContext, + }).catch((err) => { + logVerbose( + `dispatch-from-config: message handler "${matchedHandler.id}" failed: ${String(err)}`, + ); + }); + + // Check mode: exclusive (default) vs parallel + if (mode === "exclusive") { + // Return early - handler takes over completely + return { + queuedFinal: false, + counts: dispatcher.getQueuedCounts(), + handledByMessageHandler: { handlerId: matchedHandler.id }, + }; + } + // parallel mode: continue with normal flow + } + } + } } // Check if we should route replies to originating channel instead of dispatcher. diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 7ca74605a..d931abf43 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -94,6 +94,58 @@ export type HookInstallRecord = { hooks?: string[]; }; +/** + * Conditions for matching inbound messages to handlers. + * All specified conditions must match for the handler to trigger. + */ +export type MessageHandlerMatch = { + /** Channel ID(s) to match (e.g., "whatsapp", "telegram", ["discord", "slack"]) */ + channelId?: string | string[]; + /** Conversation/chat ID(s) to match (e.g., group chat ID, DM ID) */ + conversationId?: string | string[]; + /** Sender identifier(s) to match (phone number, user ID, etc.) */ + from?: string | string[]; + /** Regex pattern to match against message content (case-insensitive) */ + contentPattern?: string; + /** Keyword(s) to match in message content (case-insensitive) */ + contentContains?: string | string[]; +}; + +/** + * Config-driven message handler that triggers agent execution for matching messages. + * Enables immediate processing of important messages without waiting for cron. + */ +export type MessageHandlerConfig = { + /** Unique identifier for this handler */ + id: string; + /** Whether this handler is enabled (default: true) */ + enabled?: boolean; + /** Conditions that must match for this handler to trigger */ + match: MessageHandlerMatch; + /** Action to take when matched */ + action: "agent"; + /** Which agent to use (default: route default) */ + agentId?: string; + /** Custom session key (default: derived from handler id + channel + conversation) */ + sessionKey?: string; + /** Priority: "immediate" bypasses queue, "queue" uses normal flow (default: "immediate") */ + priority?: "immediate" | "queue"; + /** Mode: "exclusive" = handler only, "parallel" = handler AND normal flow (default: "exclusive") */ + mode?: "exclusive" | "parallel"; + /** Text to prepend to the message content */ + messagePrefix?: string; + /** Text to append to the message content */ + messageSuffix?: string; + /** Full message template with placeholders: {{content}}, {{from}}, {{channelId}}, {{conversationId}} */ + messageTemplate?: string; + /** Override model for this handler (provider/model or alias) */ + model?: string; + /** Thinking level for the agent */ + thinking?: "off" | "low" | "medium" | "high"; + /** Timeout in seconds for agent execution */ + timeoutSeconds?: number; +}; + export type InternalHooksConfig = { /** Enable hooks system */ enabled?: boolean; @@ -108,6 +160,8 @@ export type InternalHooksConfig = { }; /** Install records for hook packs or hooks */ installs?: Record; + /** Config-driven message handlers for immediate agent execution */ + messageHandlers?: MessageHandlerConfig[]; }; export type HooksConfig = { diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 35e74f7af..433a25885 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -52,6 +52,37 @@ export const InternalHookHandlerSchema = z }) .strict(); +const MessageHandlerMatchSchema = z + .object({ + channelId: z.union([z.string(), z.array(z.string())]).optional(), + conversationId: z.union([z.string(), z.array(z.string())]).optional(), + from: z.union([z.string(), z.array(z.string())]).optional(), + contentPattern: z.string().optional(), + contentContains: z.union([z.string(), z.array(z.string())]).optional(), + }) + .strict(); + +const MessageHandlerConfigSchema = z + .object({ + id: z.string(), + enabled: z.boolean().optional(), + match: MessageHandlerMatchSchema, + action: z.literal("agent"), + agentId: z.string().optional(), + sessionKey: z.string().optional(), + priority: z.union([z.literal("immediate"), z.literal("queue")]).optional(), + mode: z.union([z.literal("exclusive"), z.literal("parallel")]).optional(), + messagePrefix: z.string().optional(), + messageSuffix: z.string().optional(), + messageTemplate: z.string().optional(), + model: z.string().optional(), + thinking: z + .union([z.literal("off"), z.literal("low"), z.literal("medium"), z.literal("high")]) + .optional(), + timeoutSeconds: z.number().int().positive().optional(), + }) + .strict(); + const HookConfigSchema = z .object({ enabled: z.boolean().optional(), @@ -83,6 +114,7 @@ export const InternalHooksSchema = z .strict() .optional(), installs: z.record(z.string(), HookInstallRecordSchema).optional(), + messageHandlers: z.array(MessageHandlerConfigSchema).optional(), }) .strict() .optional(); diff --git a/src/hooks/message-handler-match.test.ts b/src/hooks/message-handler-match.test.ts new file mode 100644 index 000000000..b2d05395f --- /dev/null +++ b/src/hooks/message-handler-match.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; +import type { MessageHandlerConfig } from "../config/types.hooks.js"; +import type { MessageReceivedHookContext } from "./internal-hooks.js"; +import { matchMessageHandler } from "./message-handler-match.js"; + +function createContext( + overrides: Partial = {}, +): MessageReceivedHookContext { + return { + 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", + }, + ...overrides, + }; +} + +function createHandler(overrides: Partial = {}): MessageHandlerConfig { + return { + id: "test-handler", + action: "agent", + match: {}, + ...overrides, + }; +} + +describe("matchMessageHandler", () => { + describe("channelId matching", () => { + it("matches single channelId", () => { + const handlers = [createHandler({ match: { channelId: "whatsapp" } })]; + const ctx = createContext({ channelId: "whatsapp" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("matches channelId from array", () => { + const handlers = [createHandler({ match: { channelId: ["telegram", "whatsapp"] } })]; + const ctx = createContext({ channelId: "whatsapp" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("does not match wrong channelId", () => { + const handlers = [createHandler({ match: { channelId: "telegram" } })]; + const ctx = createContext({ channelId: "whatsapp" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBeNull(); + }); + + it("matches wildcard channelId", () => { + const handlers = [createHandler({ match: { channelId: "*" } })]; + const ctx = createContext({ channelId: "discord" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + }); + + describe("conversationId matching", () => { + it("matches single conversationId", () => { + const handlers = [createHandler({ match: { conversationId: "chat-123" } })]; + const ctx = createContext({ conversationId: "chat-123" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("matches conversationId from array", () => { + const handlers = [ + createHandler({ match: { conversationId: ["chat-111", "chat-123", "chat-456"] } }), + ]; + const ctx = createContext({ conversationId: "chat-123" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("does not match wrong conversationId", () => { + const handlers = [createHandler({ match: { conversationId: "chat-999" } })]; + const ctx = createContext({ conversationId: "chat-123" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBeNull(); + }); + + it("does not match when conversationId is undefined", () => { + const handlers = [createHandler({ match: { conversationId: "chat-123" } })]; + const ctx = createContext({ conversationId: undefined }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBeNull(); + }); + }); + + describe("from matching", () => { + it("matches single from value", () => { + const handlers = [createHandler({ match: { from: "+1234567890" } })]; + const ctx = createContext({ from: "+1234567890" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("matches from array", () => { + const handlers = [createHandler({ match: { from: ["+1111111111", "+1234567890"] } })]; + const ctx = createContext({ from: "+1234567890" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("does not match wrong from", () => { + const handlers = [createHandler({ match: { from: "+9999999999" } })]; + const ctx = createContext({ from: "+1234567890" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBeNull(); + }); + }); + + describe("contentPattern matching", () => { + it("matches simple regex pattern", () => { + const handlers = [createHandler({ match: { contentPattern: "hello" } })]; + const ctx = createContext({ content: "Hello world" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("matches complex regex pattern", () => { + const handlers = [createHandler({ match: { contentPattern: "bug|error|broken" } })]; + const ctx = createContext({ content: "There is an error in the code" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("does not match when pattern not found", () => { + const handlers = [createHandler({ match: { contentPattern: "urgent" } })]; + const ctx = createContext({ content: "This is a normal message" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBeNull(); + }); + + it("handles invalid regex gracefully", () => { + const handlers = [createHandler({ match: { contentPattern: "[invalid(" } })]; + const ctx = createContext({ content: "Some content" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBeNull(); + }); + }); + + describe("contentContains matching", () => { + it("matches single keyword", () => { + const handlers = [createHandler({ match: { contentContains: "bug" } })]; + const ctx = createContext({ content: "There is a bug in the system" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("matches keyword array (any match)", () => { + const handlers = [createHandler({ match: { contentContains: ["bug", "error", "issue"] } })]; + const ctx = createContext({ content: "Found an issue with the login" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("matches case-insensitively", () => { + const handlers = [createHandler({ match: { contentContains: "BUG" } })]; + const ctx = createContext({ content: "there is a bug here" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("does not match when no keywords found", () => { + const handlers = [createHandler({ match: { contentContains: ["urgent", "critical"] } })]; + const ctx = createContext({ content: "This is a normal message" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBeNull(); + }); + }); + + describe("combined conditions", () => { + it("matches when all conditions are met", () => { + const handlers = [ + createHandler({ + match: { + channelId: "whatsapp", + conversationId: "chat-123", + contentContains: "bug", + }, + }), + ]; + const ctx = createContext({ + channelId: "whatsapp", + conversationId: "chat-123", + content: "Found a bug", + }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("does not match when one condition fails", () => { + const handlers = [ + createHandler({ + match: { + channelId: "whatsapp", + conversationId: "chat-123", + contentContains: "bug", + }, + }), + ]; + const ctx = createContext({ + channelId: "whatsapp", + conversationId: "chat-123", + content: "Normal message", // no "bug" keyword + }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBeNull(); + }); + }); + + describe("enabled flag", () => { + it("skips disabled handlers", () => { + const handlers = [ + createHandler({ id: "disabled", enabled: false, match: { channelId: "whatsapp" } }), + createHandler({ id: "enabled", match: { channelId: "whatsapp" } }), + ]; + const ctx = createContext({ channelId: "whatsapp" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result?.id).toBe("enabled"); + }); + + it("includes handlers with enabled=true explicitly", () => { + const handlers = [createHandler({ enabled: true, match: { channelId: "whatsapp" } })]; + const ctx = createContext({ channelId: "whatsapp" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + + it("includes handlers with enabled=undefined (default)", () => { + const handlers = [createHandler({ match: { channelId: "whatsapp" } })]; + const ctx = createContext({ channelId: "whatsapp" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + }); + + describe("first-match wins", () => { + it("returns the first matching handler", () => { + const handlers = [ + createHandler({ id: "first", match: { channelId: "whatsapp" } }), + createHandler({ id: "second", match: { channelId: "whatsapp" } }), + createHandler({ id: "third", match: { channelId: "whatsapp" } }), + ]; + const ctx = createContext({ channelId: "whatsapp" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result?.id).toBe("first"); + }); + + it("skips non-matching handlers to find first match", () => { + const handlers = [ + createHandler({ id: "no-match-1", match: { channelId: "telegram" } }), + createHandler({ id: "no-match-2", match: { contentContains: "urgent" } }), + createHandler({ id: "match", match: { channelId: "whatsapp" } }), + createHandler({ id: "also-match", match: { channelId: "whatsapp" } }), + ]; + const ctx = createContext({ channelId: "whatsapp", content: "Hello" }); + + const result = matchMessageHandler(handlers, ctx); + expect(result?.id).toBe("match"); + }); + }); + + describe("empty handlers", () => { + it("returns null for empty handlers array", () => { + const result = matchMessageHandler([], createContext()); + expect(result).toBeNull(); + }); + }); + + describe("empty match conditions", () => { + it("matches any message when match is empty", () => { + const handlers = [createHandler({ match: {} })]; + const ctx = createContext(); + + const result = matchMessageHandler(handlers, ctx); + expect(result).toBe(handlers[0]); + }); + }); +}); diff --git a/src/hooks/message-handler-match.ts b/src/hooks/message-handler-match.ts new file mode 100644 index 000000000..74ef0a513 --- /dev/null +++ b/src/hooks/message-handler-match.ts @@ -0,0 +1,98 @@ +/** + * Config-driven message handler matching. + * Matches inbound messages against configured handlers based on channel, sender, content, etc. + */ + +import type { MessageHandlerConfig, MessageHandlerMatch } from "../config/types.hooks.js"; +import type { MessageReceivedHookContext } from "./internal-hooks.js"; + +/** + * Find the first matching handler for a message context. + * Returns null if no handler matches. + * + * @param handlers - Array of configured message handlers + * @param context - The message received context to match against + * @returns The first matching handler, or null if none match + */ +export function matchMessageHandler( + handlers: MessageHandlerConfig[], + context: MessageReceivedHookContext, +): MessageHandlerConfig | null { + for (const handler of handlers) { + // Skip disabled handlers + if (handler.enabled === false) { + continue; + } + if (matchesConditions(handler.match, context)) { + return handler; + } + } + return null; +} + +/** + * Check if all conditions in the match config are satisfied by the context. + * All specified conditions must match (AND logic). + */ +function matchesConditions(match: MessageHandlerMatch, ctx: MessageReceivedHookContext): boolean { + // channelId match + if (match.channelId !== undefined && !matchesValue(match.channelId, ctx.channelId)) { + return false; + } + + // conversationId match + if ( + match.conversationId !== undefined && + !matchesValue(match.conversationId, ctx.conversationId) + ) { + return false; + } + + // from match (sender) + if (match.from !== undefined && !matchesValue(match.from, ctx.from)) { + return false; + } + + // contentPattern (regex) + if (match.contentPattern !== undefined) { + try { + const regex = new RegExp(match.contentPattern, "i"); + if (!regex.test(ctx.content)) { + return false; + } + } catch { + // Invalid regex - treat as no match + return false; + } + } + + // contentContains (keyword matching) + if (match.contentContains !== undefined) { + const keywords = Array.isArray(match.contentContains) + ? match.contentContains + : [match.contentContains]; + const content = ctx.content.toLowerCase(); + const hasKeyword = keywords.some((kw) => content.includes(kw.toLowerCase())); + if (!hasKeyword) { + return false; + } + } + + return true; +} + +/** + * Check if a value matches a pattern (single value, array, or wildcard). + * + * @param pattern - The pattern to match against (string, string[], or "*" for wildcard) + * @param value - The value to check + * @returns true if the value matches the pattern + */ +function matchesValue(pattern: string | string[], value: string | undefined): boolean { + if (value === undefined || value === null) { + return false; + } + + const patterns = Array.isArray(pattern) ? pattern : [pattern]; + return patterns.some((p) => p === "*" || p === value); +} diff --git a/src/hooks/message-handler-run.ts b/src/hooks/message-handler-run.ts new file mode 100644 index 000000000..e859bb7a0 --- /dev/null +++ b/src/hooks/message-handler-run.ts @@ -0,0 +1,138 @@ +/** + * Message handler execution. + * Runs agent turns for matched message handlers. + */ + +import type { CliDeps } from "../cli/outbound-send-deps.js"; +import type { MoltbotConfig } from "../config/config.js"; +import type { MessageHandlerConfig } from "../config/types.hooks.js"; +import { runCronIsolatedAgentTurn } from "../cron/isolated-agent/run.js"; +import type { CronJob, CronMessageChannel } from "../cron/types.js"; +import { logVerbose } from "../globals.js"; +import type { MessageReceivedHookContext } from "./internal-hooks.js"; + +export type RunMessageHandlerParams = { + cfg: MoltbotConfig; + deps: CliDeps; + handler: MessageHandlerConfig; + context: MessageReceivedHookContext; +}; + +export type RunMessageHandlerResult = { + status: "ok" | "error" | "skipped"; + error?: string; +}; + +/** + * Execute a message handler by triggering an isolated agent turn. + */ +export async function runMessageHandler( + params: RunMessageHandlerParams, +): Promise { + const { cfg, deps, handler, context } = params; + + try { + // Build message from template or raw content + const message = buildHandlerMessage(handler, context); + + // Build session key + const sessionKey = + handler.sessionKey ?? + `handler:${handler.id}:${context.channelId}:${context.conversationId ?? "dm"}`; + + // Determine delivery channel + const channel = normalizeChannel(context.channelId); + + // Determine recipient - reply back to the originating conversation + const to = context.metadata.originatingTo ?? context.conversationId ?? context.from; + + // Create synthetic cron job for execution + const now = Date.now(); + const job: CronJob = { + id: `handler-${handler.id}`, + name: handler.id, + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "at", atMs: now }, + sessionTarget: "isolated", + wakeMode: "now", + payload: { + kind: "agentTurn", + message, + model: handler.model, + thinking: handler.thinking, + timeoutSeconds: handler.timeoutSeconds, + deliver: true, + channel, + to, + }, + state: {}, + }; + + const result = await runCronIsolatedAgentTurn({ + cfg, + deps, + job, + message, + sessionKey, + agentId: handler.agentId, + lane: "message-handler", + }); + + if (result.status === "error") { + logVerbose(`message-handler-run: handler "${handler.id}" failed: ${result.error}`); + return { status: "error", error: result.error }; + } + + return { status: result.status }; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + logVerbose(`message-handler-run: handler "${handler.id}" threw: ${errorMsg}`); + return { status: "error", error: errorMsg }; + } +} + +/** + * Build the message to send to the agent from handler config and context. + */ +function buildHandlerMessage( + handler: MessageHandlerConfig, + ctx: MessageReceivedHookContext, +): string { + // If a full template is provided, use it with placeholder substitution + if (handler.messageTemplate) { + return handler.messageTemplate + .replace(/\{\{content\}\}/g, ctx.content) + .replace(/\{\{from\}\}/g, ctx.from) + .replace(/\{\{channelId\}\}/g, ctx.channelId) + .replace(/\{\{conversationId\}\}/g, ctx.conversationId ?? ""); + } + + // Otherwise, use prefix/suffix around raw content + const prefix = handler.messagePrefix ?? ""; + const suffix = handler.messageSuffix ?? ""; + return `${prefix}${ctx.content}${suffix}`; +} + +/** + * Normalize channel ID to a valid CronMessageChannel. + */ +function normalizeChannel(channelId: string): CronMessageChannel { + const normalized = channelId.toLowerCase(); + const validChannels: CronMessageChannel[] = [ + "whatsapp", + "telegram", + "discord", + "slack", + "signal", + "imessage", + "msteams", + "last", + ]; + if (validChannels.includes(normalized as CronMessageChannel)) { + return normalized as CronMessageChannel; + } + // Default to "last" for unknown channels + return "last"; +}