feat: add webhook hook mappings

# Conflicts:
#	src/gateway/server.ts
This commit is contained in:
Peter Steinberger
2025-12-24 19:48:05 +00:00
parent 337390b590
commit aeb5455555
4 changed files with 843 additions and 118 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>;
headers: Record<string, string>;
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<string, HookMappingConfig[]> = {
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<string, HookTransformFn>();
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<HookTransformResult>;
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<HookMappingResult | null> {
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<HookTransformFn> {
const cached = transformCache.get(transform.modulePath);
if (cached) return cached;
const url = pathToFileURL(transform.modulePath).href;
const mod = (await import(url)) as Record<string, unknown>;
const fn = resolveTransformFn(mod, transform.exportName);
transformCache.set(transform.modulePath, fn);
return fn;
}
function resolveTransformFn(
mod: Record<string, unknown>,
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<string, unknown>, pathExpr: string): unknown {
if (!pathExpr) return undefined;
const parts: Array<string | number> = [];
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<string, unknown>)[part];
}
return current;
}

View File

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