import fs from 'fs'; import { paths, DATA_DIR } from './paths.js'; export interface ChannelConfig { enabled: boolean; /** 'channel' = just talk to me (self-chat only), 'business' = admin/customer mode, 'assistant' = personal assistant in conversations */ mode: 'channel' | 'business' | 'assistant'; /** Phone numbers with admin access (owner, secretary, etc.) — business mode only */ admins?: string[]; /** Active skill for customer-facing mode (folder name in workspace/skills/) */ skill?: string; /** Opt-in: process messages in group chats (default false). Channel mode ignores this. */ allowGroups?: boolean; /** Assistant mode only. When false (default) ONLY the account owner can trigger the agent * with `@botname`. When true, ANYONE who tags the bot (in a DM or group) can drive it. * DANGER: the triggerer gains control of an agent that can run Bash, edit files, etc. * Only enable for fully trusted shared use (e.g. a partner). See the WhatsApp SKILL.md. */ allowOthersToTrigger?: boolean; } export interface AlexaChannelConfig { enabled: boolean; /** Per-user shared secret minted by the relay when the user first pairs an Alexa device. * Used to verify that inbound /api/channels/alexa/handle calls actually came from the relay. */ sharedSecret?: string; } export interface TelegramChannelConfig { enabled: boolean; /** Same semantics as WhatsApp: 'channel' = just talk to me (owner DM only), 'business' = admin/customer, * 'assistant' = personal assistant in conversations. */ mode: 'channel' | 'business' | 'assistant'; /** Telegram NUMERIC user IDs (not phone numbers) with admin access — business mode only. */ admins?: string[]; /** Active skill for customer-facing mode (folder name in workspace/skills/). */ skill?: string; /** Opt-in: process messages in group chats (default false). Channel mode ignores this. */ allowGroups?: boolean; /** Assistant mode only — see ChannelConfig.allowOthersToTrigger. DANGEROUS when true. */ allowOthersToTrigger?: boolean; /** The bot's own access token (Bot API), from the user's own @BotFather bot (pasted at connect). * Held locally — the Bloby long-polls Telegram DIRECTLY with this token; no relay is involved. */ botToken?: string; /** The bot's @username (no @). For display + deep links. */ botUsername?: string; /** Telegram user_id of the human who created/owns the bot. Treated as the admin/"self" identity: * in channel mode only this user's 1:1 DMs reach the agent. */ ownerUserId?: string; } export interface BotConfig { port: number; username: string; ai: { provider: 'openai' | 'anthropic' | 'ollama' | 'pi' | ''; model: string; apiKey: string; baseUrl?: string; }; tunnel: { mode: 'off' | 'quick' | 'named'; name?: string; domain?: string; configPath?: string; }; relay: { token: string; tier: string; url: string; }; wallet?: { privateKey: string; address: string; }; channels?: { whatsapp?: ChannelConfig; alexa?: AlexaChannelConfig; telegram?: TelegramChannelConfig; }; tunnelUrl?: string; } const DEFAULTS: BotConfig = { port: 7400, username: '', ai: { provider: '', model: '', apiKey: '' }, tunnel: { mode: 'quick' }, relay: { token: '', tier: '', url: '' }, }; // One-shot model migrations: stored model id → replacement. // Applied once on load; the rewritten config is persisted so each migration runs at most once per install. const MODEL_MIGRATIONS: Record = { 'claude-opus-4-6': 'claude-opus-4-7[1m]', 'claude-haiku-4-5-20251001': 'claude-haiku-4-5', }; export function loadConfig(): BotConfig { if (!fs.existsSync(paths.config)) throw new Error('No config. Run `bloby init`.'); let config: BotConfig; try { config = JSON.parse(fs.readFileSync(paths.config, 'utf-8')); } catch { // Torn/truncated write — recover from the .bak mirror rather than crashing // the supervisor (or, worse, letting the CLI regenerate a fresh wallet). const bak = `${paths.config}.bak`; if (fs.existsSync(bak)) { config = JSON.parse(fs.readFileSync(bak, 'utf-8')); try { saveConfig(config); } catch {} } else { throw new Error('config.json is corrupt and no backup (.bak) was found.'); } } let dirty = false; // Backward compat: migrate old { enabled: boolean } → { mode } if ('enabled' in config.tunnel && !('mode' in config.tunnel)) { config.tunnel = { mode: config.tunnel.enabled ? 'quick' : 'off' }; dirty = true; } // Model migrations: bump stored model ids that were superseded by a newer release. const currentModel = config.ai?.model; if (currentModel && MODEL_MIGRATIONS[currentModel]) { config.ai.model = MODEL_MIGRATIONS[currentModel]; dirty = true; } if (dirty) saveConfig(config); return config; } export function saveConfig(config: BotConfig): void { fs.mkdirSync(DATA_DIR, { recursive: true }); // Atomic write: temp file + rename, then mirror to .bak. config.json holds the // funded wallet and is written by BOTH this supervisor and the CLI, so a plain // writeFileSync can be observed half-written (the Jun-9 0-byte-creds class of // bug, here applied to the one file that must never be lost). const json = JSON.stringify(config, null, 2); const tmp = `${paths.config}.${process.pid}.tmp`; try { fs.writeFileSync(tmp, json); try { fs.renameSync(tmp, paths.config); } catch (err) { // Windows: rename over a file another process holds open can EPERM/EEXIST. const code = (err as NodeJS.ErrnoException)?.code; if (process.platform === 'win32' && (code === 'EPERM' || code === 'EEXIST')) { fs.copyFileSync(tmp, paths.config); fs.unlinkSync(tmp); } else throw err; } try { fs.copyFileSync(paths.config, `${paths.config}.bak`); } catch {} } catch (err) { try { fs.unlinkSync(tmp); } catch {} throw err; } }