refactor: add gateway server helper modules
This commit is contained in:
227
src/gateway/server-http.ts
Normal file
227
src/gateway/server-http.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
672
src/gateway/server-providers.ts
Normal file
672
src/gateway/server-providers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
27
src/gateway/server-utils.test.ts
Normal file
27
src/gateway/server-utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
34
src/gateway/server-utils.ts
Normal file
34
src/gateway/server-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user