277 lines
8.0 KiB
TypeScript
277 lines
8.0 KiB
TypeScript
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<string | number>;
|
|
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 {};
|
|
}
|
|
}
|