feat: add webhook hook mappings
# Conflicts: # src/gateway/server.ts
This commit is contained in:
@@ -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
|
||||
|
||||
89
src/gateway/hooks-mapping.test.ts
Normal file
89
src/gateway/hooks-mapping.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
388
src/gateway/hooks-mapping.ts
Normal file
388
src/gateway/hooks-mapping.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user