import { Router } from 'express'; import type { WebContext } from '../context.js'; import { ConfigSchema, PhysiqueTargetingSchema, 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, physiqueTargeting: true, }).extend({ physiqueTargeting: PhysiqueTargetingSchema.optional() }), openrouter: ConfigSchema.shape.openrouter.omit({ apiKey: true }), browser: z.strictObject({ sessionDir: z.string() }), conversations: ConfigSchema.shape.conversations, 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, physiqueTargeting: _physiqueTargeting, ...reddit } = ctx.config.reddit; const { apiKey: _apiKey, ...openrouter } = ctx.config.openrouter; const { dryRun: _dryRun, ...browser } = ctx.config.browser; return { reddit, openrouter, browser, conversations: ctx.config.conversations, 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; } const parsedPhysiqueTargeting = updates.reddit?.physiqueTargeting === undefined ? null : PhysiqueTargetingSchema.safeParse(updates.reddit.physiqueTargeting); if (parsedPhysiqueTargeting && !parsedPhysiqueTargeting.success) { res.status(400).json({ error: 'Invalid physique targeting configuration', detail: parsedPhysiqueTargeting.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; } if (parsedPhysiqueTargeting?.success) { Object.assign(ctx.runtimeConfig.reddit.physiqueTargeting, parsedPhysiqueTargeting.data); } } // 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; Object.assign(ctx.config.reddit.physiqueTargeting, ctx.runtimeConfig.reddit.physiqueTargeting); 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) { // Physique targeting has dedicated controls and is deliberately not // owned by the advanced JSON. Cached dashboard bundles may still send // an older copy here, so accept it but never let it overwrite the // explicitly parsed top-level setting. const { physiqueTargeting: _legacyPhysiqueTargeting, ...advancedReddit } = parsedAdvanced.data.reddit; Object.assign(ctx.config.reddit, advancedReddit); Object.assign(ctx.config.openrouter, parsedAdvanced.data.openrouter); Object.assign(ctx.config.browser, parsedAdvanced.data.browser); // Assigned field by field, never replaced: running account runtimes // hold a reference to these objects and read the kill controls on every // reply, so a dashboard save applies without a restart. const nextConversations = parsedAdvanced.data.conversations; ctx.config.conversations.offPlatformKill = nextConversations.offPlatformKill; ctx.config.conversations.killOnSellerKeyword = nextConversations.killOnSellerKeyword; Object.assign(ctx.config.conversations.aiReplyScreening, nextConversations.aiReplyScreening); ctx.config.media = parsedAdvanced.data.media; ctx.config.strategies = parsedAdvanced.data.strategies; ctx.config.chatStrategies = parsedAdvanced.data.chatStrategies; } configStore.save(ctx.config); if (parsedPhysiqueTargeting?.success && ctx.services.candidateQueue) { const reconciliation = ctx.services.candidateQueue.reconcilePhysiqueTargeting( parsedPhysiqueTargeting.data, ); ctx.activity.push( 'info', `Physique targeting reconciled ${reconciliation.retained} prepared image candidate(s)` + ` and rejected ${reconciliation.rejected}`, ); } 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; }