Files
clawdbot/src/gateway/hooks-mapping.ts
Peter Steinberger c379191f80 chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
2026-01-14 15:02:19 +00:00

372 lines
11 KiB
TypeScript

import path from "node:path";
import { pathToFileURL } from "node:url";
import {
CONFIG_PATH_CLAWDBOT,
type HookMappingConfig,
type HooksConfig,
} from "../config/config.js";
import type { HookMessageChannel } from "./hooks.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?: HookMessageChannel;
to?: string;
model?: 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?: HookMessageChannel;
to?: string;
model?: string;
thinking?: string;
timeoutSeconds?: number;
};
export type HookMappingResult =
| { ok: true; action: HookAction }
| { ok: true; action: null; skipped: true }
| { 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: HookMessageChannel;
to: string;
model: 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[] = [];
if (hooks?.mappings) mappings.push(...hooks.mappings);
for (const preset of presets) {
const presetMappings = hookPresetMappings[preset];
if (presetMappings) mappings.push(...presetMappings);
}
if (mappings.length === 0) return [];
const configDir = path.dirname(CONFIG_PATH_CLAWDBOT);
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 { ok: true, action: null, skipped: true };
}
}
if (!base.action) return { ok: true, action: null, skipped: true };
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,
model: mapping.model,
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),
model: renderOptional(mapping.model, 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,
model: override.model ?? baseAgent?.model,
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;
}