import { Router } from 'express'; import type { WebContext } from '../context.js'; import { ConfigSchema, SubredditConfigSchema, type SubredditConfig } from '../../config/index.js'; import { TONE_KEYS } from '../../config/tone-profiles.js'; import { z } from 'zod'; const SubredditsUpdateSchema = z.array(SubredditConfigSchema).min(1); const AdvancedConfigSchema = z.strictObject({ reddit: ConfigSchema.shape.reddit.omit({ subreddits: true, prioritizeRecentPosts: true, recentWindowMinutes: true, maxReadyCandidates: true, }), openrouter: ConfigSchema.shape.openrouter.omit({ apiKey: true }), browser: z.strictObject({ sessionDir: z.string() }), media: ConfigSchema.shape.media, strategies: ConfigSchema.shape.strategies, chatStrategies: ConfigSchema.shape.chatStrategies, }); function advancedConfig(ctx: WebContext): z.infer { const { subreddits: _subreddits, prioritizeRecentPosts: _prioritize, recentWindowMinutes: _window, maxReadyCandidates: _maxReady, ...reddit } = ctx.config.reddit; const { apiKey: _apiKey, ...openrouter } = ctx.config.openrouter; const { dryRun: _dryRun, ...browser } = ctx.config.browser; return { reddit, openrouter, browser, media: ctx.config.media, strategies: ctx.config.strategies, chatStrategies: ctx.config.chatStrategies, }; } export function createConfigRouter(ctx: WebContext): Router { const router = Router(); router.get('/config', (_req, res) => { res.json({ rateLimit: ctx.runtimeConfig.rateLimit, browser: { dryRun: ctx.runtimeConfig.browser.dryRun }, reddit: ctx.runtimeConfig.reddit, subreddits: ctx.config.reddit.subreddits, tones: TONE_KEYS, advanced: advancedConfig(ctx), }); }); router.put('/config', (req, res) => { try { const updates = req.body; const configStore = ctx.services.configStore; if (!configStore) throw new Error('Configuration persistence is unavailable'); const parsedSubreddits = updates.subreddits === undefined ? null : SubredditsUpdateSchema.safeParse(updates.subreddits); if (parsedSubreddits && !parsedSubreddits.success) { res.status(400).json({ error: 'Invalid subreddits payload', detail: parsedSubreddits.error.format() }); return; } const parsedAdvanced = updates.advanced === undefined ? null : AdvancedConfigSchema.safeParse(updates.advanced); if (parsedAdvanced && !parsedAdvanced.success) { res.status(400).json({ error: 'Invalid advanced configuration', detail: parsedAdvanced.error.format() }); return; } if (updates.rateLimit) { if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') { ctx.runtimeConfig.rateLimit.minDelayBetweenMessagesMs = Math.max( 0, updates.rateLimit.minDelayBetweenMessagesMs, ); } if (typeof updates.rateLimit.maxMessagesPerHour === 'number') { ctx.runtimeConfig.rateLimit.maxMessagesPerHour = Math.max( 1, updates.rateLimit.maxMessagesPerHour, ); } if (typeof updates.rateLimit.jitterMs === 'number') { ctx.runtimeConfig.rateLimit.jitterMs = Math.max(0, updates.rateLimit.jitterMs); } } if (updates.browser) { if (typeof updates.browser.dryRun === 'boolean') { ctx.runtimeConfig.browser.dryRun = updates.browser.dryRun; } } if (updates.reddit) { if (typeof updates.reddit.prioritizeRecentPosts === 'boolean') { ctx.runtimeConfig.reddit.prioritizeRecentPosts = updates.reddit.prioritizeRecentPosts; } if (typeof updates.reddit.recentWindowMinutes === 'number' && updates.reddit.recentWindowMinutes > 0) { ctx.runtimeConfig.reddit.recentWindowMinutes = updates.reddit.recentWindowMinutes; } if (typeof updates.reddit.maxReadyCandidates === 'number' && Number.isInteger(updates.reddit.maxReadyCandidates) && updates.reddit.maxReadyCandidates >= 1 && updates.reddit.maxReadyCandidates <= 500) { ctx.runtimeConfig.reddit.maxReadyCandidates = updates.reddit.maxReadyCandidates; } } // Mirror into the loaded config so getStats reflects new values. ctx.config.rateLimit.minDelayBetweenMessagesMs = ctx.runtimeConfig.rateLimit.minDelayBetweenMessagesMs; ctx.config.rateLimit.maxMessagesPerHour = ctx.runtimeConfig.rateLimit.maxMessagesPerHour; ctx.config.rateLimit.jitterMs = ctx.runtimeConfig.rateLimit.jitterMs; ctx.config.browser.dryRun = ctx.runtimeConfig.browser.dryRun; ctx.config.reddit.prioritizeRecentPosts = ctx.runtimeConfig.reddit.prioritizeRecentPosts; ctx.config.reddit.recentWindowMinutes = ctx.runtimeConfig.reddit.recentWindowMinutes; ctx.config.reddit.maxReadyCandidates = ctx.runtimeConfig.reddit.maxReadyCandidates; if (parsedSubreddits?.success) { const next: SubredditConfig[] = parsedSubreddits.data; const seen = new Set(); for (const s of next) { const key = s.name.toLowerCase(); if (seen.has(key)) { res.status(400).json({ error: `Duplicate subreddit: ${s.name}` }); return; } seen.add(key); } ctx.config.reddit.subreddits = next; } if (parsedAdvanced?.success) { Object.assign(ctx.config.reddit, parsedAdvanced.data.reddit); Object.assign(ctx.config.openrouter, parsedAdvanced.data.openrouter); Object.assign(ctx.config.browser, parsedAdvanced.data.browser); ctx.config.media = parsedAdvanced.data.media; ctx.config.strategies = parsedAdvanced.data.strategies; ctx.config.chatStrategies = parsedAdvanced.data.chatStrategies; } configStore.save(ctx.config); ctx.getOnConfigChange()?.(ctx.runtimeConfig); ctx.activity.push('info', 'Config updated via dashboard'); ctx.broadcaster.push('info', 'Configuration updated via dashboard', updates); res.json({ success: true, config: ctx.runtimeConfig, subreddits: ctx.config.reddit.subreddits, advanced: advancedConfig(ctx), }); } catch (error) { ctx.logger.error('Failed to update config:', error); res.status(400).json({ error: String(error) }); } }); return router; }