feat: add webhook hook mappings
# Conflicts: # src/gateway/server.ts
This commit is contained in:
@@ -62,10 +62,65 @@ export type CronConfig = {
|
|||||||
maxConcurrentRuns?: number;
|
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 = {
|
export type HooksConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
maxBodyBytes?: number;
|
||||||
|
presets?: string[];
|
||||||
|
transformsDir?: string;
|
||||||
|
mappings?: HookMappingConfig[];
|
||||||
|
gmail?: HooksGmailConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TelegramConfig = {
|
export type TelegramConfig = {
|
||||||
@@ -387,6 +442,68 @@ const RoutingSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.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({
|
const ClawdisSchema = z.object({
|
||||||
identity: z
|
identity: z
|
||||||
.object({
|
.object({
|
||||||
@@ -473,6 +590,11 @@ const ClawdisSchema = z.object({
|
|||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
token: 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(),
|
.optional(),
|
||||||
web: z
|
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";
|
} from "./auth.js";
|
||||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
|
import {
|
||||||
|
applyHookMappings,
|
||||||
|
type HookMappingResolved,
|
||||||
|
resolveHookMappings,
|
||||||
|
} from "./hooks-mapping.js";
|
||||||
|
|
||||||
ensureClawdisCliOnPath();
|
ensureClawdisCliOnPath();
|
||||||
|
|
||||||
@@ -153,6 +158,7 @@ type HooksConfigResolved = {
|
|||||||
basePath: string;
|
basePath: string;
|
||||||
token: string;
|
token: string;
|
||||||
maxBodyBytes: number;
|
maxBodyBytes: number;
|
||||||
|
mappings: HookMappingResolved[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveHooksConfig(cfg: ClawdisConfig): HooksConfigResolved | null {
|
function resolveHooksConfig(cfg: ClawdisConfig): HooksConfigResolved | null {
|
||||||
@@ -168,10 +174,16 @@ function resolveHooksConfig(cfg: ClawdisConfig): HooksConfigResolved | null {
|
|||||||
if (trimmed === "/") {
|
if (trimmed === "/") {
|
||||||
throw new Error("hooks.path may not be '/'");
|
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 {
|
return {
|
||||||
basePath: trimmed,
|
basePath: trimmed,
|
||||||
token,
|
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 canvasHost: CanvasHostHandler | null = null;
|
||||||
let canvasHostServer: CanvasHostServer | null = null;
|
let canvasHostServer: CanvasHostServer | null = null;
|
||||||
if (canvasHostEnabled) {
|
if (canvasHostEnabled) {
|
||||||
@@ -1361,137 +1548,76 @@ export async function startGatewayServer(
|
|||||||
|
|
||||||
const payload =
|
const payload =
|
||||||
typeof body.value === "object" && body.value !== null ? body.value : {};
|
typeof body.value === "object" && body.value !== null ? body.value : {};
|
||||||
|
const headers = normalizeHookHeaders(req);
|
||||||
|
|
||||||
if (subPath === "wake") {
|
if (subPath === "wake") {
|
||||||
const textRaw = (payload as { text?: unknown }).text;
|
const normalized = normalizeWakePayload(
|
||||||
const text = typeof textRaw === "string" ? textRaw.trim() : "";
|
payload as Record<string, unknown>,
|
||||||
if (!text) {
|
);
|
||||||
sendJson(res, 400, { ok: false, error: "text required" });
|
if (!normalized.ok) {
|
||||||
|
sendJson(res, 400, { ok: false, error: normalized.error });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const modeRaw = (payload as { mode?: unknown }).mode;
|
dispatchWakeHook(normalized.value);
|
||||||
const mode = modeRaw === "next-heartbeat" ? "next-heartbeat" : "now";
|
sendJson(res, 200, { ok: true, mode: normalized.value.mode });
|
||||||
enqueueSystemEvent(text);
|
|
||||||
if (mode === "now") {
|
|
||||||
requestReplyHeartbeatNow({ reason: "hook:wake" });
|
|
||||||
}
|
|
||||||
sendJson(res, 200, { ok: true, mode });
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subPath === "agent") {
|
if (subPath === "agent") {
|
||||||
const messageRaw = (payload as { message?: unknown }).message;
|
const normalized = normalizeAgentPayload(
|
||||||
const message = typeof messageRaw === "string" ? messageRaw.trim() : "";
|
payload as Record<string, unknown>,
|
||||||
if (!message) {
|
);
|
||||||
sendJson(res, 400, { ok: false, error: "message required" });
|
if (!normalized.ok) {
|
||||||
|
sendJson(res, 400, { ok: false, error: normalized.error });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const runId = dispatchAgentHook(normalized.value);
|
||||||
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();
|
|
||||||
sendJson(res, 202, { ok: true, runId });
|
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;
|
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.statusCode = 404;
|
||||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||||
res.end("Not Found");
|
res.end("Not Found");
|
||||||
|
|||||||
Reference in New Issue
Block a user