feat(hooks): add config-driven message handlers for immediate agent triggering

Add `hooks.internal.messageHandlers` config array for triggering agent
execution when inbound messages match specified conditions. This solves
the problem of important messages (like bug reports) being queued and
potentially missed when cron jobs wake the agent.

Features:
- Match conditions: channelId, conversationId, from, contentPattern (regex),
  contentContains (keywords)
- Priority modes: "immediate" (bypass queue) or "queue" (normal flow)
- Execution modes: "exclusive" (handler only) or "parallel" (handler + normal)
- Message transformation: prefix, suffix, or full template with placeholders
- Configurable model and thinking level per handler

Implementation:
- src/config/types.hooks.ts: MessageHandlerConfig and MessageHandlerMatch types
- src/config/zod-schema.hooks.ts: Zod validation schemas
- src/hooks/message-handler-match.ts: Condition matching logic
- src/hooks/message-handler-run.ts: Handler executor using runCronIsolatedAgentTurn
- src/auto-reply/reply/dispatch-from-config.ts: Integration point
- docs/hooks.md: Full documentation with examples
- 28 unit tests for matcher logic
This commit is contained in:
Steven Gonsalvez 2026-01-28 20:51:06 +00:00
parent bf9f78c14c
commit 42c647f2c9
No known key found for this signature in database
GPG Key ID: 907EC78C72C6AFF6
8 changed files with 884 additions and 1 deletions

View File

@ -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)

View File

@ -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

View File

@ -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<ReplyDispatchKind, number>;
/** 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.

View File

@ -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<string, HookInstallRecord>;
/** Config-driven message handlers for immediate agent execution */
messageHandlers?: MessageHandlerConfig[];
};
export type HooksConfig = {

View File

@ -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();

View File

@ -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> = {},
): 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> = {}): 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]);
});
});
});

View File

@ -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);
}

View File

@ -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<RunMessageHandlerResult> {
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";
}