import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; import { z } from "zod"; import type { AgentKind } from "../agents/index.js"; export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; export type SessionConfig = { scope?: SessionScope; resetTriggers?: string[]; idleMinutes?: number; heartbeatIdleMinutes?: number; store?: string; sessionArgNew?: string[]; sessionArgResume?: string[]; sessionArgBeforeBody?: boolean; sendSystemOnce?: boolean; sessionIntro?: string; typingIntervalSeconds?: number; heartbeatMinutes?: number; mainKey?: string; }; export type LoggingConfig = { level?: "silent" | "fatal" | "error" | "warn" | "info" | "debug" | "trace"; file?: string; }; export type WebReconnectConfig = { initialMs?: number; maxMs?: number; factor?: number; jitter?: number; maxAttempts?: number; // 0 = unlimited }; export type WebConfig = { heartbeatSeconds?: number; reconnect?: WebReconnectConfig; }; export type WebChatConfig = { enabled?: boolean; port?: number; }; export type TelegramConfig = { botToken?: string; requireMention?: boolean; allowFrom?: Array; mediaMaxMb?: number; proxy?: string; webhookUrl?: string; webhookSecret?: string; webhookPath?: string; }; export type GroupChatConfig = { requireMention?: boolean; mentionPatterns?: string[]; historyLimit?: number; }; export type WarelayConfig = { logging?: LoggingConfig; inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) transcribeAudio?: { // Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout. command: string[]; timeoutSeconds?: number; }; groupChat?: GroupChatConfig; reply?: { mode: ReplyMode; text?: string; command?: string[]; heartbeatCommand?: string[]; thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high"; verboseDefault?: "off" | "on"; cwd?: string; template?: string; timeoutSeconds?: number; bodyPrefix?: string; mediaUrl?: string; session?: SessionConfig; mediaMaxMb?: number; typingIntervalSeconds?: number; heartbeatMinutes?: number; agent?: { kind: AgentKind; format?: "text" | "json"; identityPrefix?: string; model?: string; contextTokens?: number; }; }; }; web?: WebConfig; telegram?: TelegramConfig; webchat?: WebChatConfig; }; // New branding path (preferred) export const CONFIG_PATH_CLAWDIS = path.join( os.homedir(), ".clawdis", "clawdis.json", ); const ReplySchema = z .object({ mode: z.union([z.literal("text"), z.literal("command")]), text: z.string().optional(), command: z.array(z.string()).optional(), heartbeatCommand: z.array(z.string()).optional(), thinkingDefault: z .union([ z.literal("off"), z.literal("minimal"), z.literal("low"), z.literal("medium"), z.literal("high"), ]) .optional(), verboseDefault: z.union([z.literal("off"), z.literal("on")]).optional(), cwd: z.string().optional(), template: z.string().optional(), timeoutSeconds: z.number().int().positive().optional(), bodyPrefix: z.string().optional(), mediaUrl: z.string().optional(), mediaMaxMb: z.number().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(), session: z .object({ scope: z .union([z.literal("per-sender"), z.literal("global")]) .optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), heartbeatIdleMinutes: z.number().int().positive().optional(), store: z.string().optional(), sessionArgNew: z.array(z.string()).optional(), sessionArgResume: z.array(z.string()).optional(), sessionArgBeforeBody: z.boolean().optional(), sendSystemOnce: z.boolean().optional(), sessionIntro: z.string().optional(), typingIntervalSeconds: z.number().int().positive().optional(), mainKey: z.string().optional(), }) .optional(), heartbeatMinutes: z.number().int().nonnegative().optional(), agent: z .object({ kind: z.literal("pi"), format: z.union([z.literal("text"), z.literal("json")]).optional(), identityPrefix: z.string().optional(), model: z.string().optional(), contextTokens: z.number().int().positive().optional(), }) .optional(), }) .refine( (val) => val.mode === "text" ? Boolean(val.text) : Boolean(val.command || val.heartbeatCommand), { message: "reply.text is required for mode=text; reply.command or reply.heartbeatCommand is required for mode=command", }, ); const WarelaySchema = z.object({ logging: z .object({ level: z .union([ z.literal("silent"), z.literal("fatal"), z.literal("error"), z.literal("warn"), z.literal("info"), z.literal("debug"), z.literal("trace"), ]) .optional(), file: z.string().optional(), }) .optional(), inbound: z .object({ allowFrom: z.array(z.string()).optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), timestampPrefix: z.union([z.boolean(), z.string()]).optional(), groupChat: z .object({ requireMention: z.boolean().optional(), mentionPatterns: z.array(z.string()).optional(), historyLimit: z.number().int().positive().optional(), }) .optional(), transcribeAudio: z .object({ command: z.array(z.string()), timeoutSeconds: z.number().int().positive().optional(), }) .optional(), reply: ReplySchema.optional(), }) .optional(), web: z .object({ heartbeatSeconds: z.number().int().positive().optional(), reconnect: z .object({ initialMs: z.number().positive().optional(), maxMs: z.number().positive().optional(), factor: z.number().positive().optional(), jitter: z.number().min(0).max(1).optional(), maxAttempts: z.number().int().min(0).optional(), }) .optional(), }) .optional(), webchat: z .object({ enabled: z.boolean().optional(), port: z.number().int().positive().optional(), }) .optional(), telegram: z .object({ botToken: z.string().optional(), requireMention: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), mediaMaxMb: z.number().positive().optional(), proxy: z.string().optional(), webhookUrl: z.string().optional(), webhookSecret: z.string().optional(), webhookPath: z.string().optional(), }) .optional(), }); export function loadConfig(): WarelayConfig { // Read config file (JSON5) if present. const configPath = CONFIG_PATH_CLAWDIS; try { if (!fs.existsSync(configPath)) return {}; const raw = fs.readFileSync(configPath, "utf-8"); const parsed = JSON5.parse(raw); if (typeof parsed !== "object" || parsed === null) return {}; const validated = WarelaySchema.safeParse(parsed); if (!validated.success) { console.error("Invalid config:"); for (const iss of validated.error.issues) { console.error(`- ${iss.path.join(".")}: ${iss.message}`); } return {}; } return validated.data as WarelayConfig; } catch (err) { console.error(`Failed to read config at ${configPath}`, err); return {}; } }