refactor: add gateway server helper modules

This commit is contained in:
Peter Steinberger
2026-01-03 18:00:45 +01:00
parent 145964c85e
commit 4a6b33d799
4 changed files with 960 additions and 0 deletions

227
src/gateway/server-http.ts Normal file
View File

@@ -0,0 +1,227 @@
import {
createServer as createHttpServer,
type IncomingMessage,
type Server as HttpServer,
type ServerResponse,
} from "node:http";
import { type WebSocketServer } from "ws";
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js";
import { type HooksConfigResolved, extractHookToken, normalizeAgentPayload, normalizeHookHeaders, normalizeWakePayload, readJsonBody } from "./hooks.js";
import { applyHookMappings } from "./hooks-mapping.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
import type { createSubsystemLogger } from "../logging.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
type HookDispatchers = {
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
dispatchAgentHook: (value: {
message: string;
name: string;
wakeMode: "now" | "next-heartbeat";
sessionKey: string;
deliver: boolean;
channel:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "signal"
| "imessage";
to?: string;
thinking?: string;
timeoutSeconds?: number;
}) => string;
};
function sendJson(res: ServerResponse, status: number, body: unknown) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
export type HooksRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<boolean>;
export function createHooksRequestHandler(opts: {
hooksConfig: HooksConfigResolved | null;
bindHost: string;
port: number;
logHooks: SubsystemLogger;
} & HookDispatchers): HooksRequestHandler {
const { hooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
return async (req, res) => {
if (!hooksConfig) return false;
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
const basePath = hooksConfig.basePath;
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
return false;
}
const token = extractHookToken(req, url);
if (!token || token !== hooksConfig.token) {
res.statusCode = 401;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Unauthorized");
return true;
}
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const subPath = url.pathname.slice(basePath.length).replace(/^\/+/, "");
if (!subPath) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
return true;
}
const body = await readJsonBody(req, hooksConfig.maxBodyBytes);
if (!body.ok) {
const status = body.error === "payload too large" ? 413 : 400;
sendJson(res, status, { ok: false, error: body.error });
return true;
}
const payload =
typeof body.value === "object" && body.value !== null ? body.value : {};
const headers = normalizeHookHeaders(req);
if (subPath === "wake") {
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
if (!normalized.ok) {
sendJson(res, 400, { ok: false, error: normalized.error });
return true;
}
dispatchWakeHook(normalized.value);
sendJson(res, 200, { ok: true, mode: normalized.value.mode });
return true;
}
if (subPath === "agent") {
const normalized = normalizeAgentPayload(payload as Record<string, unknown>);
if (!normalized.ok) {
sendJson(res, 400, { ok: false, error: normalized.error });
return true;
}
const runId = dispatchAgentHook(normalized.value);
sendJson(res, 202, { ok: true, runId });
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 === null) {
res.statusCode = 204;
res.end();
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 ?? "",
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");
return true;
};
}
export function createGatewayHttpServer(opts: {
canvasHost: CanvasHostHandler | null;
controlUiEnabled: boolean;
controlUiBasePath: string;
handleHooksRequest: HooksRequestHandler;
}): HttpServer {
const { canvasHost, controlUiEnabled, controlUiBasePath, handleHooksRequest } =
opts;
const httpServer: HttpServer = createHttpServer((req, res) => {
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
void (async () => {
if (await handleHooksRequest(req, res)) return;
if (canvasHost) {
if (await handleA2uiHttpRequest(req, res)) return;
if (await canvasHost.handleHttpRequest(req, res)) return;
}
if (controlUiEnabled) {
if (
handleControlUiHttpRequest(req, res, {
basePath: controlUiBasePath,
})
)
return;
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
})().catch((err) => {
res.statusCode = 500;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(String(err));
});
});
return httpServer;
}
export function attachGatewayUpgradeHandler(opts: {
httpServer: HttpServer;
wss: WebSocketServer;
canvasHost: CanvasHostHandler | null;
}) {
const { httpServer, wss, canvasHost } = opts;
httpServer.on("upgrade", (req, socket, head) => {
if (canvasHost?.handleUpgrade(req, socket, head)) return;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
}

View File

@@ -0,0 +1,672 @@
import type { ClawdisConfig } from "../config/config.js";
import { shouldLogVerbose } from "../globals.js";
import type { createSubsystemLogger } from "../logging.js";
import type { RuntimeEnv } from "../runtime.js";
import { monitorDiscordProvider } from "../discord/index.js";
import { probeDiscord } from "../discord/probe.js";
import { monitorIMessageProvider } from "../imessage/index.js";
import { monitorSignalProvider } from "../signal/index.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { monitorTelegramProvider } from "../telegram/monitor.js";
import { probeTelegram } from "../telegram/probe.js";
import { monitorWebProvider, webAuthExists } from "../providers/web/index.js";
import { readWebSelfId } from "../web/session.js";
import type { WebProviderStatus } from "../web/auto-reply.js";
import { formatError } from "./server-utils.js";
export type TelegramRuntimeStatus = {
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
mode?: "webhook" | "polling" | null;
};
export type DiscordRuntimeStatus = {
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
};
export type SignalRuntimeStatus = {
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
baseUrl?: string | null;
};
export type IMessageRuntimeStatus = {
running: boolean;
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
cliPath?: string | null;
dbPath?: string | null;
};
export type ProviderRuntimeSnapshot = {
whatsapp: WebProviderStatus;
telegram: TelegramRuntimeStatus;
discord: DiscordRuntimeStatus;
signal: SignalRuntimeStatus;
imessage: IMessageRuntimeStatus;
};
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
type ProviderManagerOptions = {
loadConfig: () => ClawdisConfig;
logWhatsApp: SubsystemLogger;
logTelegram: SubsystemLogger;
logDiscord: SubsystemLogger;
logSignal: SubsystemLogger;
logIMessage: SubsystemLogger;
whatsappRuntimeEnv: RuntimeEnv;
telegramRuntimeEnv: RuntimeEnv;
discordRuntimeEnv: RuntimeEnv;
signalRuntimeEnv: RuntimeEnv;
imessageRuntimeEnv: RuntimeEnv;
};
export type ProviderManager = {
getRuntimeSnapshot: () => ProviderRuntimeSnapshot;
startProviders: () => Promise<void>;
startWhatsAppProvider: () => Promise<void>;
stopWhatsAppProvider: () => Promise<void>;
startTelegramProvider: () => Promise<void>;
stopTelegramProvider: () => Promise<void>;
startDiscordProvider: () => Promise<void>;
stopDiscordProvider: () => Promise<void>;
startSignalProvider: () => Promise<void>;
stopSignalProvider: () => Promise<void>;
startIMessageProvider: () => Promise<void>;
stopIMessageProvider: () => Promise<void>;
markWhatsAppLoggedOut: (cleared: boolean) => void;
};
export function createProviderManager(
opts: ProviderManagerOptions,
): ProviderManager {
const {
loadConfig,
logWhatsApp,
logTelegram,
logDiscord,
logSignal,
logIMessage,
whatsappRuntimeEnv,
telegramRuntimeEnv,
discordRuntimeEnv,
signalRuntimeEnv,
imessageRuntimeEnv,
} = opts;
let whatsappAbort: AbortController | null = null;
let telegramAbort: AbortController | null = null;
let discordAbort: AbortController | null = null;
let signalAbort: AbortController | null = null;
let imessageAbort: AbortController | null = null;
let whatsappTask: Promise<unknown> | null = null;
let telegramTask: Promise<unknown> | null = null;
let discordTask: Promise<unknown> | null = null;
let signalTask: Promise<unknown> | null = null;
let imessageTask: Promise<unknown> | null = null;
let whatsappRuntime: WebProviderStatus = {
running: false,
connected: false,
reconnectAttempts: 0,
lastConnectedAt: null,
lastDisconnect: null,
lastMessageAt: null,
lastEventAt: null,
lastError: null,
};
let telegramRuntime: TelegramRuntimeStatus = {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
mode: null,
};
let discordRuntime: DiscordRuntimeStatus = {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
};
let signalRuntime: SignalRuntimeStatus = {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
baseUrl: null,
};
let imessageRuntime: IMessageRuntimeStatus = {
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
cliPath: null,
dbPath: null,
};
const updateWhatsAppStatus = (next: WebProviderStatus) => {
whatsappRuntime = next;
};
const startWhatsAppProvider = async () => {
if (whatsappTask) return;
const cfg = loadConfig();
if (cfg.web?.enabled === false) {
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
lastError: "disabled",
};
logWhatsApp.info("skipping provider start (web.enabled=false)");
return;
}
if (!(await webAuthExists())) {
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
lastError: "not linked",
};
logWhatsApp.info("skipping provider start (no linked session)");
return;
}
const { e164, jid } = readWebSelfId();
const identity = e164 ? e164 : jid ? `jid ${jid}` : "unknown";
logWhatsApp.info(`starting provider (${identity})`);
whatsappAbort = new AbortController();
whatsappRuntime = {
...whatsappRuntime,
running: true,
connected: false,
lastError: null,
};
const task = monitorWebProvider(
shouldLogVerbose(),
undefined,
true,
undefined,
whatsappRuntimeEnv,
whatsappAbort.signal,
{ statusSink: updateWhatsAppStatus },
)
.catch((err) => {
whatsappRuntime = {
...whatsappRuntime,
lastError: formatError(err),
};
logWhatsApp.error(`provider exited: ${formatError(err)}`);
})
.finally(() => {
whatsappAbort = null;
whatsappTask = null;
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
};
});
whatsappTask = task;
};
const stopWhatsAppProvider = async () => {
if (!whatsappAbort && !whatsappTask) return;
whatsappAbort?.abort();
try {
await whatsappTask;
} catch {
// ignore
}
whatsappAbort = null;
whatsappTask = null;
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
};
};
const startTelegramProvider = async () => {
if (telegramTask) return;
const cfg = loadConfig();
if (cfg.telegram?.enabled === false) {
telegramRuntime = {
...telegramRuntime,
running: false,
lastError: "disabled",
};
if (shouldLogVerbose()) {
logTelegram.debug("telegram provider disabled (telegram.enabled=false)");
}
return;
}
const { token: telegramToken } = resolveTelegramToken(cfg, {
logMissingFile: (message) => logTelegram.warn(message),
});
if (!telegramToken.trim()) {
telegramRuntime = {
...telegramRuntime,
running: false,
lastError: "not configured",
};
// keep quiet by default; this is a normal state
if (shouldLogVerbose()) {
logTelegram.debug(
"telegram provider not configured (no TELEGRAM_BOT_TOKEN)",
);
}
return;
}
let telegramBotLabel = "";
try {
const probe = await probeTelegram(
telegramToken.trim(),
2500,
cfg.telegram?.proxy,
);
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) telegramBotLabel = ` (@${username})`;
} catch (err) {
if (shouldLogVerbose()) {
logTelegram.debug(`bot probe failed: ${String(err)}`);
}
}
logTelegram.info(
`starting provider${telegramBotLabel}${cfg.telegram ? "" : " (no telegram config; token via env)"}`,
);
telegramAbort = new AbortController();
telegramRuntime = {
...telegramRuntime,
running: true,
lastStartAt: Date.now(),
lastError: null,
mode: cfg.telegram?.webhookUrl ? "webhook" : "polling",
};
const task = monitorTelegramProvider({
token: telegramToken.trim(),
runtime: telegramRuntimeEnv,
abortSignal: telegramAbort.signal,
useWebhook: Boolean(cfg.telegram?.webhookUrl),
webhookUrl: cfg.telegram?.webhookUrl,
webhookSecret: cfg.telegram?.webhookSecret,
webhookPath: cfg.telegram?.webhookPath,
})
.catch((err) => {
telegramRuntime = {
...telegramRuntime,
lastError: formatError(err),
};
logTelegram.error(`provider exited: ${formatError(err)}`);
})
.finally(() => {
telegramAbort = null;
telegramTask = null;
telegramRuntime = {
...telegramRuntime,
running: false,
lastStopAt: Date.now(),
};
});
telegramTask = task;
};
const stopTelegramProvider = async () => {
if (!telegramAbort && !telegramTask) return;
telegramAbort?.abort();
try {
await telegramTask;
} catch {
// ignore
}
telegramAbort = null;
telegramTask = null;
telegramRuntime = {
...telegramRuntime,
running: false,
lastStopAt: Date.now(),
};
};
const startDiscordProvider = async () => {
if (discordTask) return;
const cfg = loadConfig();
if (cfg.discord?.enabled === false) {
discordRuntime = {
...discordRuntime,
running: false,
lastError: "disabled",
};
if (shouldLogVerbose()) {
logDiscord.debug("discord provider disabled (discord.enabled=false)");
}
return;
}
const discordToken =
process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? "";
if (!discordToken.trim()) {
discordRuntime = {
...discordRuntime,
running: false,
lastError: "not configured",
};
// keep quiet by default; this is a normal state
if (shouldLogVerbose()) {
logDiscord.debug(
"discord provider not configured (no DISCORD_BOT_TOKEN)",
);
}
return;
}
let discordBotLabel = "";
try {
const probe = await probeDiscord(discordToken.trim(), 2500);
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) discordBotLabel = ` (@${username})`;
} catch (err) {
if (shouldLogVerbose()) {
logDiscord.debug(`bot probe failed: ${String(err)}`);
}
}
logDiscord.info(
`starting provider${discordBotLabel}${cfg.discord ? "" : " (no discord config; token via env)"}`,
);
discordAbort = new AbortController();
discordRuntime = {
...discordRuntime,
running: true,
lastStartAt: Date.now(),
lastError: null,
};
const task = monitorDiscordProvider({
token: discordToken.trim(),
runtime: discordRuntimeEnv,
abortSignal: discordAbort.signal,
slashCommand: cfg.discord?.slashCommand,
mediaMaxMb: cfg.discord?.mediaMaxMb,
historyLimit: cfg.discord?.historyLimit,
})
.catch((err) => {
discordRuntime = {
...discordRuntime,
lastError: formatError(err),
};
logDiscord.error(`provider exited: ${formatError(err)}`);
})
.finally(() => {
discordAbort = null;
discordTask = null;
discordRuntime = {
...discordRuntime,
running: false,
lastStopAt: Date.now(),
};
});
discordTask = task;
};
const stopDiscordProvider = async () => {
if (!discordAbort && !discordTask) return;
discordAbort?.abort();
try {
await discordTask;
} catch {
// ignore
}
discordAbort = null;
discordTask = null;
discordRuntime = {
...discordRuntime,
running: false,
lastStopAt: Date.now(),
};
};
const startSignalProvider = async () => {
if (signalTask) return;
const cfg = loadConfig();
if (!cfg.signal) {
signalRuntime = {
...signalRuntime,
running: false,
lastError: "not configured",
};
// keep quiet by default; this is a normal state
if (shouldLogVerbose()) {
logSignal.debug("signal provider not configured (no signal config)");
}
return;
}
if (cfg.signal?.enabled === false) {
signalRuntime = {
...signalRuntime,
running: false,
lastError: "disabled",
};
if (shouldLogVerbose()) {
logSignal.debug("signal provider disabled (signal.enabled=false)");
}
return;
}
const signalCfg = cfg.signal;
const signalMeaningfullyConfigured = Boolean(
signalCfg.account?.trim() ||
signalCfg.httpUrl?.trim() ||
signalCfg.cliPath?.trim() ||
signalCfg.httpHost?.trim() ||
typeof signalCfg.httpPort === "number" ||
typeof signalCfg.autoStart === "boolean",
);
if (!signalMeaningfullyConfigured) {
signalRuntime = {
...signalRuntime,
running: false,
lastError: "not configured",
};
// keep quiet by default; this is a normal state
if (shouldLogVerbose()) {
logSignal.debug(
"signal provider not configured (signal config present but missing required fields)",
);
}
return;
}
const host = cfg.signal?.httpHost?.trim() || "127.0.0.1";
const port = cfg.signal?.httpPort ?? 8080;
const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`;
logSignal.info(`starting provider (${baseUrl})`);
signalAbort = new AbortController();
signalRuntime = {
...signalRuntime,
running: true,
lastStartAt: Date.now(),
lastError: null,
baseUrl,
};
const task = monitorSignalProvider({
baseUrl,
account: cfg.signal?.account,
cliPath: cfg.signal?.cliPath,
httpHost: cfg.signal?.httpHost,
httpPort: cfg.signal?.httpPort,
autoStart:
typeof cfg.signal?.autoStart === "boolean"
? cfg.signal.autoStart
: undefined,
runtime: signalRuntimeEnv,
abortSignal: signalAbort.signal,
})
.catch((err) => {
signalRuntime = {
...signalRuntime,
lastError: formatError(err),
};
logSignal.error(`provider exited: ${formatError(err)}`);
})
.finally(() => {
signalAbort = null;
signalTask = null;
signalRuntime = {
...signalRuntime,
running: false,
lastStopAt: Date.now(),
};
});
signalTask = task;
};
const stopSignalProvider = async () => {
if (!signalAbort && !signalTask) return;
signalAbort?.abort();
try {
await signalTask;
} catch {
// ignore
}
signalAbort = null;
signalTask = null;
signalRuntime = {
...signalRuntime,
running: false,
lastStopAt: Date.now(),
};
};
const startIMessageProvider = async () => {
if (imessageTask) return;
const cfg = loadConfig();
if (!cfg.imessage) {
imessageRuntime = {
...imessageRuntime,
running: false,
lastError: "not configured",
};
// keep quiet by default; this is a normal state
if (shouldLogVerbose()) {
logIMessage.debug(
"imessage provider not configured (no imessage config)",
);
}
return;
}
if (cfg.imessage?.enabled === false) {
imessageRuntime = {
...imessageRuntime,
running: false,
lastError: "disabled",
};
if (shouldLogVerbose()) {
logIMessage.debug(
"imessage provider disabled (imessage.enabled=false)",
);
}
return;
}
const cliPath = cfg.imessage?.cliPath?.trim() || "imsg";
const dbPath = cfg.imessage?.dbPath?.trim();
logIMessage.info(
`starting provider (${cliPath}${dbPath ? ` db=${dbPath}` : ""})`,
);
imessageAbort = new AbortController();
imessageRuntime = {
...imessageRuntime,
running: true,
lastStartAt: Date.now(),
lastError: null,
cliPath,
dbPath: dbPath ?? null,
};
const task = monitorIMessageProvider({
cliPath,
dbPath,
allowFrom: cfg.imessage?.allowFrom,
includeAttachments: cfg.imessage?.includeAttachments,
mediaMaxMb: cfg.imessage?.mediaMaxMb,
runtime: imessageRuntimeEnv,
abortSignal: imessageAbort.signal,
})
.catch((err) => {
imessageRuntime = {
...imessageRuntime,
lastError: formatError(err),
};
logIMessage.error(`provider exited: ${formatError(err)}`);
})
.finally(() => {
imessageAbort = null;
imessageTask = null;
imessageRuntime = {
...imessageRuntime,
running: false,
lastStopAt: Date.now(),
};
});
imessageTask = task;
};
const stopIMessageProvider = async () => {
if (!imessageAbort && !imessageTask) return;
imessageAbort?.abort();
try {
await imessageTask;
} catch {
// ignore
}
imessageAbort = null;
imessageTask = null;
imessageRuntime = {
...imessageRuntime,
running: false,
lastStopAt: Date.now(),
};
};
const startProviders = async () => {
await startWhatsAppProvider();
await startDiscordProvider();
await startTelegramProvider();
await startSignalProvider();
await startIMessageProvider();
};
const markWhatsAppLoggedOut = (cleared: boolean) => {
whatsappRuntime = {
...whatsappRuntime,
running: false,
connected: false,
lastError: cleared ? "logged out" : whatsappRuntime.lastError,
};
};
const getRuntimeSnapshot = (): ProviderRuntimeSnapshot => ({
whatsapp: { ...whatsappRuntime },
telegram: { ...telegramRuntime },
discord: { ...discordRuntime },
signal: { ...signalRuntime },
imessage: { ...imessageRuntime },
});
return {
getRuntimeSnapshot,
startProviders,
startWhatsAppProvider,
stopWhatsAppProvider,
startTelegramProvider,
stopTelegramProvider,
startDiscordProvider,
stopDiscordProvider,
startSignalProvider,
stopSignalProvider,
startIMessageProvider,
stopIMessageProvider,
markWhatsAppLoggedOut,
};
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from "vitest";
import { defaultVoiceWakeTriggers } from "../infra/voicewake.js";
import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js";
describe("normalizeVoiceWakeTriggers", () => {
test("returns defaults when input is empty", () => {
expect(normalizeVoiceWakeTriggers([])).toEqual(defaultVoiceWakeTriggers());
expect(normalizeVoiceWakeTriggers(null)).toEqual(defaultVoiceWakeTriggers());
});
test("trims and limits entries", () => {
const result = normalizeVoiceWakeTriggers([" hello ", "", "world"]);
expect(result).toEqual(["hello", "world"]);
});
});
describe("formatError", () => {
test("prefers message for Error", () => {
expect(formatError(new Error("boom"))).toBe("boom");
});
test("handles status/code", () => {
expect(formatError({ status: 500, code: "EPIPE" })).toBe("500 EPIPE");
expect(formatError({ status: 404 })).toBe("404");
expect(formatError({ code: "ENOENT" })).toBe("ENOENT");
});
});

View File

@@ -0,0 +1,34 @@
import { defaultVoiceWakeTriggers } from "../infra/voicewake.js";
export function normalizeVoiceWakeTriggers(input: unknown): string[] {
const raw = Array.isArray(input) ? input : [];
const cleaned = raw
.map((v) => (typeof v === "string" ? v.trim() : ""))
.filter((v) => v.length > 0)
.slice(0, 32)
.map((v) => v.slice(0, 64));
return cleaned.length > 0 ? cleaned : defaultVoiceWakeTriggers();
}
export function formatError(err: unknown): string {
if (err instanceof Error) return err.message;
if (typeof err === "string") return err;
const statusValue = (err as { status?: unknown })?.status;
const codeValue = (err as { code?: unknown })?.code;
const statusText =
typeof statusValue === "string" || typeof statusValue === "number"
? String(statusValue)
: undefined;
const codeText =
typeof codeValue === "string" || typeof codeValue === "number"
? String(codeValue)
: undefined;
if (statusText || codeText) {
return [statusText, codeText].filter(Boolean).join(" ");
}
try {
return JSON.stringify(err, null, 2);
} catch {
return String(err);
}
}