Files
RedditBot/src/web/routes/config.ts
T

218 lines
8.7 KiB
TypeScript

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<typeof AdvancedConfigSchema> {
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<string>();
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;
}