diff --git a/src/config/config.ts b/src/config/config.ts index b57ad10af..9c4cfbe21 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -62,10 +62,65 @@ export type CronConfig = { maxConcurrentRuns?: number; }; +export type HookMappingMatch = { + path?: string; + source?: string; +}; + +export type HookMappingTransform = { + module: string; + export?: string; +}; + +export type HookMappingConfig = { + id?: string; + match?: HookMappingMatch; + action?: "wake" | "agent"; + wakeMode?: "now" | "next-heartbeat"; + name?: string; + sessionKey?: string; + messageTemplate?: string; + textTemplate?: string; + deliver?: boolean; + channel?: "last" | "whatsapp" | "telegram"; + to?: string; + thinking?: string; + timeoutSeconds?: number; + transform?: HookMappingTransform; +}; + +export type HooksGmailTailscaleMode = "off" | "serve" | "funnel"; + +export type HooksGmailConfig = { + account?: string; + label?: string; + topic?: string; + subscription?: string; + pushToken?: string; + hookUrl?: string; + includeBody?: boolean; + maxBytes?: number; + renewEveryMinutes?: number; + serve?: { + bind?: string; + port?: number; + path?: string; + }; + tailscale?: { + mode?: HooksGmailTailscaleMode; + path?: string; + }; +}; + export type HooksConfig = { enabled?: boolean; path?: string; token?: string; + maxBodyBytes?: number; + presets?: string[]; + transformsDir?: string; + mappings?: HookMappingConfig[]; + gmail?: HooksGmailConfig; }; export type TelegramConfig = { @@ -387,6 +442,68 @@ const RoutingSchema = z }) .optional(); +const HookMappingSchema = z + .object({ + id: z.string().optional(), + match: z + .object({ + path: z.string().optional(), + source: z.string().optional(), + }) + .optional(), + action: z.union([z.literal("wake"), z.literal("agent")]).optional(), + wakeMode: z + .union([z.literal("now"), z.literal("next-heartbeat")]) + .optional(), + name: z.string().optional(), + sessionKey: z.string().optional(), + messageTemplate: z.string().optional(), + textTemplate: z.string().optional(), + deliver: z.boolean().optional(), + channel: z + .union([z.literal("last"), z.literal("whatsapp"), z.literal("telegram")]) + .optional(), + to: z.string().optional(), + thinking: z.string().optional(), + timeoutSeconds: z.number().int().positive().optional(), + transform: z + .object({ + module: z.string(), + export: z.string().optional(), + }) + .optional(), + }) + .optional(); + +const HooksGmailSchema = z + .object({ + account: z.string().optional(), + label: z.string().optional(), + topic: z.string().optional(), + subscription: z.string().optional(), + pushToken: z.string().optional(), + hookUrl: z.string().optional(), + includeBody: z.boolean().optional(), + maxBytes: z.number().int().positive().optional(), + renewEveryMinutes: z.number().int().positive().optional(), + serve: z + .object({ + bind: z.string().optional(), + port: z.number().int().positive().optional(), + path: z.string().optional(), + }) + .optional(), + tailscale: z + .object({ + mode: z + .union([z.literal("off"), z.literal("serve"), z.literal("funnel")]) + .optional(), + path: z.string().optional(), + }) + .optional(), + }) + .optional(); + const ClawdisSchema = z.object({ identity: z .object({ @@ -473,6 +590,11 @@ const ClawdisSchema = z.object({ enabled: z.boolean().optional(), path: z.string().optional(), token: z.string().optional(), + maxBodyBytes: z.number().int().positive().optional(), + presets: z.array(z.string()).optional(), + transformsDir: z.string().optional(), + mappings: z.array(HookMappingSchema).optional(), + gmail: HooksGmailSchema, }) .optional(), web: z diff --git a/src/gateway/hooks-mapping.test.ts b/src/gateway/hooks-mapping.test.ts new file mode 100644 index 000000000..0d8e56b81 --- /dev/null +++ b/src/gateway/hooks-mapping.test.ts @@ -0,0 +1,89 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { applyHookMappings, resolveHookMappings } from "./hooks-mapping.js"; + +const baseUrl = new URL("http://127.0.0.1:18789/hooks/gmail"); + +describe("hooks mapping", () => { + it("resolves gmail preset", () => { + const mappings = resolveHookMappings({ presets: ["gmail"] }); + expect(mappings.length).toBeGreaterThan(0); + expect(mappings[0]?.matchPath).toBe("gmail"); + }); + + it("renders template from payload", async () => { + const mappings = resolveHookMappings({ + mappings: [ + { + id: "demo", + match: { path: "gmail" }, + action: "agent", + messageTemplate: "Subject: {{messages[0].subject}}", + }, + ], + }); + const result = await applyHookMappings(mappings, { + payload: { messages: [{ subject: "Hello" }] }, + headers: {}, + url: baseUrl, + path: "gmail", + }); + expect(result?.ok).toBe(true); + if (result?.ok) { + expect(result.action.kind).toBe("agent"); + expect(result.action.message).toBe("Subject: Hello"); + } + }); + + it("runs transform module", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-hooks-")); + const modPath = path.join(dir, "transform.mjs"); + const placeholder = "${" + "payload.name}"; + fs.writeFileSync( + modPath, + `export default ({ payload }) => ({ kind: "wake", text: \`Ping ${placeholder}\` });`, + ); + + const mappings = resolveHookMappings({ + transformsDir: dir, + mappings: [ + { + match: { path: "custom" }, + action: "agent", + transform: { module: "transform.mjs" }, + }, + ], + }); + + const result = await applyHookMappings(mappings, { + payload: { name: "Ada" }, + headers: {}, + url: new URL("http://127.0.0.1:18789/hooks/custom"), + path: "custom", + }); + + expect(result?.ok).toBe(true); + if (result?.ok) { + expect(result.action.kind).toBe("wake"); + if (result.action.kind === "wake") { + expect(result.action.text).toBe("Ping Ada"); + } + } + }); + + it("rejects missing message", async () => { + const mappings = resolveHookMappings({ + mappings: [{ match: { path: "noop" }, action: "agent" }], + }); + const result = await applyHookMappings(mappings, { + payload: {}, + headers: {}, + url: new URL("http://127.0.0.1:18789/hooks/noop"), + path: "noop", + }); + expect(result?.ok).toBe(false); + }); +}); diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts new file mode 100644 index 000000000..6553d27fc --- /dev/null +++ b/src/gateway/hooks-mapping.ts @@ -0,0 +1,388 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + CONFIG_PATH_CLAWDIS, + type HookMappingConfig, + type HooksConfig, +} from "../config/config.js"; + +export type HookMappingResolved = { + id: string; + matchPath?: string; + matchSource?: string; + action: "wake" | "agent"; + wakeMode?: "now" | "next-heartbeat"; + name?: string; + sessionKey?: string; + messageTemplate?: string; + textTemplate?: string; + deliver?: boolean; + channel?: "last" | "whatsapp" | "telegram"; + to?: string; + thinking?: string; + timeoutSeconds?: number; + transform?: HookMappingTransformResolved; +}; + +export type HookMappingTransformResolved = { + modulePath: string; + exportName?: string; +}; + +export type HookMappingContext = { + payload: Record; + headers: Record; + url: URL; + path: string; +}; + +export type HookAction = + | { + kind: "wake"; + text: string; + mode: "now" | "next-heartbeat"; + } + | { + kind: "agent"; + message: string; + name?: string; + wakeMode: "now" | "next-heartbeat"; + sessionKey?: string; + deliver?: boolean; + channel?: "last" | "whatsapp" | "telegram"; + to?: string; + thinking?: string; + timeoutSeconds?: number; + }; + +export type HookMappingResult = + | { ok: true; action: HookAction } + | { ok: false; error: string }; + +const hookPresetMappings: Record = { + gmail: [ + { + id: "gmail", + match: { path: "gmail" }, + action: "agent", + wakeMode: "now", + name: "Gmail", + sessionKey: "hook:gmail:{{messages[0].id}}", + messageTemplate: + "New email from {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}\n{{messages[0].body}}", + }, + ], +}; + +const transformCache = new Map(); + +type HookTransformResult = Partial<{ + kind: HookAction["kind"]; + text: string; + mode: "now" | "next-heartbeat"; + message: string; + wakeMode: "now" | "next-heartbeat"; + name: string; + sessionKey: string; + deliver: boolean; + channel: "last" | "whatsapp" | "telegram"; + to: string; + thinking: string; + timeoutSeconds: number; +}> | null; + +type HookTransformFn = ( + ctx: HookMappingContext, +) => HookTransformResult | Promise; + +export function resolveHookMappings( + hooks?: HooksConfig, +): HookMappingResolved[] { + const presets = hooks?.presets ?? []; + const mappings: HookMappingConfig[] = []; + for (const preset of presets) { + const presetMappings = hookPresetMappings[preset]; + if (presetMappings) mappings.push(...presetMappings); + } + if (hooks?.mappings) mappings.push(...hooks.mappings); + if (mappings.length === 0) return []; + + const configDir = path.dirname(CONFIG_PATH_CLAWDIS); + const transformsDir = hooks?.transformsDir + ? resolvePath(configDir, hooks.transformsDir) + : configDir; + + return mappings.map((mapping, index) => + normalizeHookMapping(mapping, index, transformsDir), + ); +} + +export async function applyHookMappings( + mappings: HookMappingResolved[], + ctx: HookMappingContext, +): Promise { + if (mappings.length === 0) return null; + for (const mapping of mappings) { + if (!mappingMatches(mapping, ctx)) continue; + + const base = buildActionFromMapping(mapping, ctx); + if (!base.ok) return base; + + let override: HookTransformResult = null; + if (mapping.transform) { + const transform = await loadTransform(mapping.transform); + override = await transform(ctx); + if (override === null) return null; + } + + const merged = mergeAction(base.action, override, mapping.action); + if (!merged.ok) return merged; + return merged; + } + return null; +} + +function normalizeHookMapping( + mapping: HookMappingConfig, + index: number, + transformsDir: string, +): HookMappingResolved { + const id = mapping.id?.trim() || `mapping-${index + 1}`; + const matchPath = normalizeMatchPath(mapping.match?.path); + const matchSource = mapping.match?.source?.trim(); + const action = mapping.action ?? "agent"; + const wakeMode = mapping.wakeMode ?? "now"; + const transform = mapping.transform + ? { + modulePath: resolvePath(transformsDir, mapping.transform.module), + exportName: mapping.transform.export?.trim() || undefined, + } + : undefined; + + return { + id, + matchPath, + matchSource, + action, + wakeMode, + name: mapping.name, + sessionKey: mapping.sessionKey, + messageTemplate: mapping.messageTemplate, + textTemplate: mapping.textTemplate, + deliver: mapping.deliver, + channel: mapping.channel, + to: mapping.to, + thinking: mapping.thinking, + timeoutSeconds: mapping.timeoutSeconds, + transform, + }; +} + +function mappingMatches(mapping: HookMappingResolved, ctx: HookMappingContext) { + if (mapping.matchPath) { + if (mapping.matchPath !== normalizeMatchPath(ctx.path)) return false; + } + if (mapping.matchSource) { + const source = + typeof ctx.payload.source === "string" ? ctx.payload.source : undefined; + if (!source || source !== mapping.matchSource) return false; + } + return true; +} + +function buildActionFromMapping( + mapping: HookMappingResolved, + ctx: HookMappingContext, +): HookMappingResult { + if (mapping.action === "wake") { + const text = renderTemplate(mapping.textTemplate ?? "", ctx); + return { + ok: true, + action: { + kind: "wake", + text, + mode: mapping.wakeMode ?? "now", + }, + }; + } + const message = renderTemplate(mapping.messageTemplate ?? "", ctx); + return { + ok: true, + action: { + kind: "agent", + message, + name: renderOptional(mapping.name, ctx), + wakeMode: mapping.wakeMode ?? "now", + sessionKey: renderOptional(mapping.sessionKey, ctx), + deliver: mapping.deliver, + channel: mapping.channel, + to: renderOptional(mapping.to, ctx), + thinking: renderOptional(mapping.thinking, ctx), + timeoutSeconds: mapping.timeoutSeconds, + }, + }; +} + +function mergeAction( + base: HookAction, + override: HookTransformResult, + defaultAction: "wake" | "agent", +): HookMappingResult { + if (!override) { + return validateAction(base); + } + const kind = (override.kind ?? base.kind ?? defaultAction) as + | "wake" + | "agent"; + if (kind === "wake") { + const baseWake = base.kind === "wake" ? base : undefined; + const text = + typeof override.text === "string" + ? override.text + : (baseWake?.text ?? ""); + const mode = + override.mode === "next-heartbeat" + ? "next-heartbeat" + : (baseWake?.mode ?? "now"); + return validateAction({ kind: "wake", text, mode }); + } + const baseAgent = base.kind === "agent" ? base : undefined; + const message = + typeof override.message === "string" + ? override.message + : (baseAgent?.message ?? ""); + const wakeMode = + override.wakeMode === "next-heartbeat" + ? "next-heartbeat" + : (baseAgent?.wakeMode ?? "now"); + return validateAction({ + kind: "agent", + message, + wakeMode, + name: override.name ?? baseAgent?.name, + sessionKey: override.sessionKey ?? baseAgent?.sessionKey, + deliver: + typeof override.deliver === "boolean" + ? override.deliver + : baseAgent?.deliver, + channel: override.channel ?? baseAgent?.channel, + to: override.to ?? baseAgent?.to, + thinking: override.thinking ?? baseAgent?.thinking, + timeoutSeconds: override.timeoutSeconds ?? baseAgent?.timeoutSeconds, + }); +} + +function validateAction(action: HookAction): HookMappingResult { + if (action.kind === "wake") { + if (!action.text?.trim()) { + return { ok: false, error: "hook mapping requires text" }; + } + return { ok: true, action }; + } + if (!action.message?.trim()) { + return { ok: false, error: "hook mapping requires message" }; + } + return { ok: true, action }; +} + +async function loadTransform( + transform: HookMappingTransformResolved, +): Promise { + const cached = transformCache.get(transform.modulePath); + if (cached) return cached; + const url = pathToFileURL(transform.modulePath).href; + const mod = (await import(url)) as Record; + const fn = resolveTransformFn(mod, transform.exportName); + transformCache.set(transform.modulePath, fn); + return fn; +} + +function resolveTransformFn( + mod: Record, + exportName?: string, +): HookTransformFn { + const candidate = exportName + ? mod[exportName] + : (mod.default ?? mod.transform); + if (typeof candidate !== "function") { + throw new Error("hook transform module must export a function"); + } + return candidate as HookTransformFn; +} + +function resolvePath(baseDir: string, target: string): string { + if (!target) return baseDir; + if (path.isAbsolute(target)) return target; + return path.join(baseDir, target); +} + +function normalizeMatchPath(raw?: string): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + return trimmed.replace(/^\/+/, "").replace(/\/+$/, ""); +} + +function renderOptional(value: string | undefined, ctx: HookMappingContext) { + if (!value) return undefined; + const rendered = renderTemplate(value, ctx).trim(); + return rendered ? rendered : undefined; +} + +function renderTemplate(template: string, ctx: HookMappingContext) { + if (!template) return ""; + return template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, expr: string) => { + const value = resolveTemplateExpr(expr.trim(), ctx); + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + return JSON.stringify(value); + }); +} + +function resolveTemplateExpr(expr: string, ctx: HookMappingContext) { + if (expr === "path") return ctx.path; + if (expr === "now") return new Date().toISOString(); + if (expr.startsWith("headers.")) { + return getByPath(ctx.headers, expr.slice("headers.".length)); + } + if (expr.startsWith("query.")) { + return getByPath( + Object.fromEntries(ctx.url.searchParams.entries()), + expr.slice("query.".length), + ); + } + if (expr.startsWith("payload.")) { + return getByPath(ctx.payload, expr.slice("payload.".length)); + } + return getByPath(ctx.payload, expr); +} + +function getByPath(input: Record, pathExpr: string): unknown { + if (!pathExpr) return undefined; + const parts: Array = []; + const re = /([^.[\]]+)|(\[(\d+)\])/g; + let match = re.exec(pathExpr); + while (match) { + if (match[1]) { + parts.push(match[1]); + } else if (match[3]) { + parts.push(Number(match[3])); + } + match = re.exec(pathExpr); + } + let current: unknown = input; + for (const part of parts) { + if (current === null || current === undefined) return undefined; + if (typeof part === "number") { + if (!Array.isArray(current)) return undefined; + current = current[part] as unknown; + continue; + } + if (typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + return current; +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 470c5984d..bb7dfa108 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -143,6 +143,11 @@ import { } from "./auth.js"; import { buildMessageWithAttachments } from "./chat-attachments.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; +import { + applyHookMappings, + type HookMappingResolved, + resolveHookMappings, +} from "./hooks-mapping.js"; ensureClawdisCliOnPath(); @@ -153,6 +158,7 @@ type HooksConfigResolved = { basePath: string; token: string; maxBodyBytes: number; + mappings: HookMappingResolved[]; }; function resolveHooksConfig(cfg: ClawdisConfig): HooksConfigResolved | null { @@ -168,10 +174,16 @@ function resolveHooksConfig(cfg: ClawdisConfig): HooksConfigResolved | null { if (trimmed === "/") { throw new Error("hooks.path may not be '/'"); } + const maxBodyBytes = + cfg.hooks?.maxBodyBytes && cfg.hooks.maxBodyBytes > 0 + ? cfg.hooks.maxBodyBytes + : DEFAULT_HOOKS_MAX_BODY_BYTES; + const mappings = resolveHookMappings(cfg.hooks); return { basePath: trimmed, token, - maxBodyBytes: DEFAULT_HOOKS_MAX_BODY_BYTES, + maxBodyBytes, + mappings, }; } @@ -1296,6 +1308,181 @@ export async function startGatewayServer( ); } + const normalizeHookHeaders = (req: IncomingMessage) => { + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === "string") { + headers[key.toLowerCase()] = value; + } else if (Array.isArray(value) && value.length > 0) { + headers[key.toLowerCase()] = value.join(", "); + } + } + return headers; + }; + + const normalizeWakePayload = ( + payload: Record, + ): + | { ok: true; value: { text: string; mode: "now" | "next-heartbeat" } } + | { ok: false; error: string } => { + const text = typeof payload.text === "string" ? payload.text.trim() : ""; + if (!text) return { ok: false, error: "text required" }; + const mode = payload.mode === "next-heartbeat" ? "next-heartbeat" : "now"; + return { ok: true, value: { text, mode } }; + }; + + const normalizeAgentPayload = ( + payload: Record, + ): + | { + ok: true; + value: { + message: string; + name: string; + wakeMode: "now" | "next-heartbeat"; + sessionKey: string; + deliver: boolean; + channel: "last" | "whatsapp" | "telegram"; + to?: string; + thinking?: string; + timeoutSeconds?: number; + }; + } + | { ok: false; error: string } => { + const message = + typeof payload.message === "string" ? payload.message.trim() : ""; + if (!message) return { ok: false, error: "message required" }; + const nameRaw = payload.name; + const name = + typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook"; + const wakeMode = + payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now"; + const sessionKeyRaw = payload.sessionKey; + const sessionKey = + typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() + ? sessionKeyRaw.trim() + : `hook:${randomUUID()}`; + const channelRaw = payload.channel; + const channel = + channelRaw === "whatsapp" || + channelRaw === "telegram" || + channelRaw === "last" + ? channelRaw + : channelRaw === undefined + ? "last" + : null; + if (channel === null) { + return { ok: false, error: "channel must be last|whatsapp|telegram" }; + } + const toRaw = payload.to; + const to = + typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; + const deliver = payload.deliver === true; + const thinkingRaw = payload.thinking; + const thinking = + typeof thinkingRaw === "string" && thinkingRaw.trim() + ? thinkingRaw.trim() + : undefined; + const timeoutRaw = payload.timeoutSeconds; + const timeoutSeconds = + typeof timeoutRaw === "number" && + Number.isFinite(timeoutRaw) && + timeoutRaw > 0 + ? Math.floor(timeoutRaw) + : undefined; + return { + ok: true, + value: { + message, + name, + wakeMode, + sessionKey, + deliver, + channel, + to, + thinking, + timeoutSeconds, + }, + }; + }; + + const dispatchWakeHook = (value: { + text: string; + mode: "now" | "next-heartbeat"; + }) => { + enqueueSystemEvent(value.text); + if (value.mode === "now") { + requestReplyHeartbeatNow({ reason: "hook:wake" }); + } + }; + + const dispatchAgentHook = (value: { + message: string; + name: string; + wakeMode: "now" | "next-heartbeat"; + sessionKey: string; + deliver: boolean; + channel: "last" | "whatsapp" | "telegram"; + to?: string; + thinking?: string; + timeoutSeconds?: number; + }) => { + const jobId = randomUUID(); + const now = Date.now(); + const job: CronJob = { + id: jobId, + name: value.name, + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "at", atMs: now }, + sessionTarget: "isolated", + wakeMode: value.wakeMode, + payload: { + kind: "agentTurn", + message: value.message, + thinking: value.thinking, + timeoutSeconds: value.timeoutSeconds, + deliver: value.deliver, + channel: value.channel, + to: value.to, + }, + state: { nextRunAtMs: now }, + }; + + const runId = randomUUID(); + void (async () => { + try { + const cfg = loadConfig(); + const result = await runCronIsolatedAgentTurn({ + cfg, + deps, + job, + message: value.message, + sessionKey: value.sessionKey, + lane: "cron", + }); + const summary = + result.summary?.trim() || result.error?.trim() || result.status; + const prefix = + result.status === "ok" + ? `Hook ${value.name}` + : `Hook ${value.name} (${result.status})`; + enqueueSystemEvent(`${prefix}: ${summary}`.trim()); + if (value.wakeMode === "now") { + requestReplyHeartbeatNow({ reason: `hook:${jobId}` }); + } + } catch (err) { + logHooks.warn(`hook agent failed: ${String(err)}`); + enqueueSystemEvent(`Hook ${value.name} (error): ${String(err)}`); + if (value.wakeMode === "now") { + requestReplyHeartbeatNow({ reason: `hook:${jobId}:error` }); + } + } + })(); + + return runId; + }; let canvasHost: CanvasHostHandler | null = null; let canvasHostServer: CanvasHostServer | null = null; if (canvasHostEnabled) { @@ -1361,137 +1548,76 @@ export async function startGatewayServer( const payload = typeof body.value === "object" && body.value !== null ? body.value : {}; + const headers = normalizeHookHeaders(req); if (subPath === "wake") { - const textRaw = (payload as { text?: unknown }).text; - const text = typeof textRaw === "string" ? textRaw.trim() : ""; - if (!text) { - sendJson(res, 400, { ok: false, error: "text required" }); + const normalized = normalizeWakePayload( + payload as Record, + ); + if (!normalized.ok) { + sendJson(res, 400, { ok: false, error: normalized.error }); return true; } - const modeRaw = (payload as { mode?: unknown }).mode; - const mode = modeRaw === "next-heartbeat" ? "next-heartbeat" : "now"; - enqueueSystemEvent(text); - if (mode === "now") { - requestReplyHeartbeatNow({ reason: "hook:wake" }); - } - sendJson(res, 200, { ok: true, mode }); + dispatchWakeHook(normalized.value); + sendJson(res, 200, { ok: true, mode: normalized.value.mode }); return true; } if (subPath === "agent") { - const messageRaw = (payload as { message?: unknown }).message; - const message = typeof messageRaw === "string" ? messageRaw.trim() : ""; - if (!message) { - sendJson(res, 400, { ok: false, error: "message required" }); + const normalized = normalizeAgentPayload( + payload as Record, + ); + if (!normalized.ok) { + sendJson(res, 400, { ok: false, error: normalized.error }); return true; } - - const nameRaw = (payload as { name?: unknown }).name; - const name = - typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook"; - const wakeModeRaw = (payload as { wakeMode?: unknown }).wakeMode; - const wakeMode = - wakeModeRaw === "next-heartbeat" ? "next-heartbeat" : "now"; - const sessionKeyRaw = (payload as { sessionKey?: unknown }).sessionKey; - const sessionKey = - typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() - ? sessionKeyRaw.trim() - : `hook:${randomUUID()}`; - - const channelRaw = (payload as { channel?: unknown }).channel; - const channel = - channelRaw === "whatsapp" || - channelRaw === "telegram" || - channelRaw === "last" - ? channelRaw - : channelRaw === undefined - ? undefined - : null; - if (channel === null) { - sendJson(res, 400, { - ok: false, - error: "channel must be last|whatsapp|telegram", - }); - return true; - } - - const toRaw = (payload as { to?: unknown }).to; - const to = - typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; - const deliver = (payload as { deliver?: unknown }).deliver === true; - const thinkingRaw = (payload as { thinking?: unknown }).thinking; - const thinking = - typeof thinkingRaw === "string" && thinkingRaw.trim() - ? thinkingRaw.trim() - : undefined; - const timeoutRaw = (payload as { timeoutSeconds?: unknown }) - .timeoutSeconds; - const timeoutSeconds = - typeof timeoutRaw === "number" && - Number.isFinite(timeoutRaw) && - timeoutRaw > 0 - ? Math.floor(timeoutRaw) - : undefined; - - const jobId = randomUUID(); - const now = Date.now(); - const job: CronJob = { - id: jobId, - name, - enabled: true, - createdAtMs: now, - updatedAtMs: now, - schedule: { kind: "at", atMs: now }, - sessionTarget: "isolated", - wakeMode, - payload: { - kind: "agentTurn", - message, - thinking, - timeoutSeconds, - deliver, - channel: channel ?? "last", - to, - }, - state: { nextRunAtMs: now }, - }; - - const runId = randomUUID(); + const runId = dispatchAgentHook(normalized.value); sendJson(res, 202, { ok: true, runId }); - - void (async () => { - try { - const cfg = loadConfig(); - const result = await runCronIsolatedAgentTurn({ - cfg, - deps, - job, - message, - sessionKey, - lane: "cron", - }); - const summary = - result.summary?.trim() || result.error?.trim() || result.status; - const prefix = - result.status === "ok" - ? `Hook ${name}` - : `Hook ${name} (${result.status})`; - enqueueSystemEvent(`${prefix}: ${summary}`.trim()); - if (wakeMode === "now") { - requestReplyHeartbeatNow({ reason: `hook:${jobId}` }); - } - } catch (err) { - logHooks.warn("hook agent failed", { err: String(err) }); - enqueueSystemEvent(`Hook ${name} (error): ${String(err)}`); - if (wakeMode === "now") { - requestReplyHeartbeatNow({ reason: `hook:${jobId}:error` }); - } - } - })(); return true; } + if (hooksConfig.mappings.length > 0) { + try { + const mapped = await applyHookMappings(hooksConfig.mappings, { + payload: payload as Record, + headers, + url, + path: subPath, + }); + if (mapped) { + if (!mapped.ok) { + sendJson(res, 400, { ok: false, error: mapped.error }); + return true; + } + if (mapped.action.kind === "wake") { + dispatchWakeHook({ + text: mapped.action.text, + mode: mapped.action.mode, + }); + sendJson(res, 200, { ok: true, mode: mapped.action.mode }); + return true; + } + const runId = dispatchAgentHook({ + message: mapped.action.message, + name: mapped.action.name ?? "Hook", + wakeMode: mapped.action.wakeMode, + sessionKey: mapped.action.sessionKey ?? `hook:${randomUUID()}`, + deliver: mapped.action.deliver === true, + channel: mapped.action.channel ?? "last", + to: mapped.action.to, + thinking: mapped.action.thinking, + timeoutSeconds: mapped.action.timeoutSeconds, + }); + sendJson(res, 202, { ok: true, runId }); + return true; + } + } catch (err) { + logHooks.warn(`hook mapping failed: ${String(err)}`); + sendJson(res, 500, { ok: false, error: "hook mapping failed" }); + return true; + } + } + res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found");