370 lines
11 KiB
TypeScript
370 lines
11 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";
|
|
|
|
export type SessionScope = "per-sender" | "global";
|
|
|
|
export type SessionConfig = {
|
|
scope?: SessionScope;
|
|
resetTriggers?: string[];
|
|
idleMinutes?: number;
|
|
heartbeatIdleMinutes?: number;
|
|
store?: string;
|
|
typingIntervalSeconds?: 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 BrowserConfig = {
|
|
enabled?: boolean;
|
|
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
|
controlUrl?: string;
|
|
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
|
color?: string;
|
|
/** Start Chrome headless (best-effort). Default: false */
|
|
headless?: boolean;
|
|
/** If true: never launch; only attach to an existing browser. Default: false */
|
|
attachOnly?: boolean;
|
|
};
|
|
|
|
export type CronConfig = {
|
|
enabled?: boolean;
|
|
store?: string;
|
|
maxConcurrentRuns?: 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 BridgeBindMode = "auto" | "lan" | "tailnet" | "loopback";
|
|
|
|
export type BridgeConfig = {
|
|
enabled?: boolean;
|
|
port?: number;
|
|
/**
|
|
* Bind address policy for the Iris bridge server.
|
|
* - auto: prefer tailnet IP when present, else LAN (0.0.0.0)
|
|
* - lan: 0.0.0.0 (reachable on local network + any forwarded interfaces)
|
|
* - tailnet: bind only to the Tailscale interface IP (100.64.0.0/10)
|
|
* - loopback: 127.0.0.1
|
|
*/
|
|
bind?: BridgeBindMode;
|
|
};
|
|
|
|
export type WideAreaDiscoveryConfig = {
|
|
enabled?: boolean;
|
|
};
|
|
|
|
export type DiscoveryConfig = {
|
|
wideArea?: WideAreaDiscoveryConfig;
|
|
};
|
|
|
|
export type ClawdisConfig = {
|
|
identity?: {
|
|
name?: string;
|
|
theme?: string;
|
|
emoji?: string;
|
|
};
|
|
logging?: LoggingConfig;
|
|
browser?: BrowserConfig;
|
|
inbound?: {
|
|
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
|
|
/** Agent working directory (preferred). Used as the default cwd for agent runs. */
|
|
workspace?: string;
|
|
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;
|
|
agent?: {
|
|
/** Provider id, e.g. "anthropic" or "openai" (pi-ai catalog). */
|
|
provider?: string;
|
|
/** Model id within provider, e.g. "claude-opus-4-5". */
|
|
model?: string;
|
|
/** Optional display-only context window override (used for % in status UIs). */
|
|
contextTokens?: number;
|
|
/** Default thinking level when no /think directive is present. */
|
|
thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high";
|
|
/** Default verbose level when no /verbose directive is present. */
|
|
verboseDefault?: "off" | "on";
|
|
timeoutSeconds?: number;
|
|
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
|
|
mediaMaxMb?: number;
|
|
typingIntervalSeconds?: number;
|
|
/** Periodic background heartbeat runs (minutes). 0 disables. */
|
|
heartbeatMinutes?: number;
|
|
};
|
|
session?: SessionConfig;
|
|
};
|
|
web?: WebConfig;
|
|
telegram?: TelegramConfig;
|
|
webchat?: WebChatConfig;
|
|
cron?: CronConfig;
|
|
bridge?: BridgeConfig;
|
|
discovery?: DiscoveryConfig;
|
|
};
|
|
|
|
// New branding path (preferred)
|
|
export const CONFIG_PATH_CLAWDIS = path.join(
|
|
os.homedir(),
|
|
".clawdis",
|
|
"clawdis.json",
|
|
);
|
|
|
|
const ClawdisSchema = z.object({
|
|
identity: z
|
|
.object({
|
|
name: z.string().optional(),
|
|
theme: z.string().optional(),
|
|
emoji: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
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(),
|
|
browser: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
controlUrl: z.string().optional(),
|
|
color: z.string().optional(),
|
|
headless: z.boolean().optional(),
|
|
attachOnly: z.boolean().optional(),
|
|
})
|
|
.optional(),
|
|
inbound: z
|
|
.object({
|
|
allowFrom: z.array(z.string()).optional(),
|
|
workspace: 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(),
|
|
agent: z
|
|
.object({
|
|
provider: z.string().optional(),
|
|
model: z.string().optional(),
|
|
contextTokens: z.number().int().positive().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(),
|
|
timeoutSeconds: z.number().int().positive().optional(),
|
|
mediaMaxMb: z.number().positive().optional(),
|
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
|
heartbeatMinutes: z.number().nonnegative().optional(),
|
|
})
|
|
.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(),
|
|
typingIntervalSeconds: z.number().int().positive().optional(),
|
|
mainKey: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.optional(),
|
|
cron: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
store: z.string().optional(),
|
|
maxConcurrentRuns: z.number().int().positive().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(),
|
|
bridge: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
port: z.number().int().positive().optional(),
|
|
bind: z
|
|
.union([
|
|
z.literal("auto"),
|
|
z.literal("lan"),
|
|
z.literal("tailnet"),
|
|
z.literal("loopback"),
|
|
])
|
|
.optional(),
|
|
})
|
|
.optional(),
|
|
discovery: z
|
|
.object({
|
|
wideArea: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
function escapeRegExp(text: string): string {
|
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
function applyIdentityDefaults(cfg: ClawdisConfig): ClawdisConfig {
|
|
const identity = cfg.identity;
|
|
if (!identity) return cfg;
|
|
|
|
const emoji = identity.emoji?.trim();
|
|
const name = identity.name?.trim();
|
|
|
|
const inbound = cfg.inbound ?? {};
|
|
const groupChat = inbound.groupChat ?? {};
|
|
|
|
let mutated = false;
|
|
const next: ClawdisConfig = { ...cfg };
|
|
|
|
if (emoji && !inbound.responsePrefix) {
|
|
next.inbound = { ...inbound, responsePrefix: emoji };
|
|
mutated = true;
|
|
}
|
|
|
|
if (name && !groupChat.mentionPatterns) {
|
|
const parts = name.split(/\s+/).filter(Boolean).map(escapeRegExp);
|
|
const re = parts.length ? parts.join("\\s+") : escapeRegExp(name);
|
|
const pattern = `\\b@?${re}\\b`;
|
|
next.inbound = {
|
|
...(next.inbound ?? inbound),
|
|
groupChat: { ...groupChat, mentionPatterns: [pattern] },
|
|
};
|
|
mutated = true;
|
|
}
|
|
|
|
return mutated ? next : cfg;
|
|
}
|
|
|
|
export function loadConfig(): ClawdisConfig {
|
|
// 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 = ClawdisSchema.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 applyIdentityDefaults(validated.data as ClawdisConfig);
|
|
} catch (err) {
|
|
console.error(`Failed to read config at ${configPath}`, err);
|
|
return {};
|
|
}
|
|
}
|