import { z } from 'zod'; import fs from 'fs'; import path from 'path'; import YAML from 'yaml'; import dotenv from 'dotenv'; dotenv.config(); export const ConfigSchema = z.object({ reddit: z.object({ subreddits: z.array(z.string()).min(1), pollIntervalMs: z.number().default(120000), userAgent: z.string().default('RedditBot/1.0'), targetGenders: z.array(z.enum(['male', 'female', 'other', 'unknown'])).default(['female']), filterSellers: z.boolean().default(true), sellerKeywords: z .array(z.string()) .default([ 'onlyfans', 'only fans', 'fansly', 'manyvids', 'linktree', 'linktr.ee', 'allmylinks', 'beacons.ai', 'cash.app', 'cashapp', 'venmo', 'paypal', 'subscribe', 'premium snap', 'premiumsnap', 'fan.ly', 'fanvue', 'loyalfans', 'pornhub.com', 'xvideos.com', 'chaturbate', 'myfreecams', 'cam girl', 'camgirl', 'buy my', 'custom content', 'custom vid', 'selling content', 'paid content', 'tip me', 'spoil me', 'findom', 'send tribute', 'wishlist', 'throne.com', ]), username: z.string().optional(), password: z.string().optional(), }), openrouter: z.object({ apiKey: z.string(), imageAnalysisModel: z.string().default('google/gemini-3-flash-preview'), imageAnalysisFallbackModels: z.array(z.string()).default([]), chatModel: z.string().default('google/gemini-3-flash-preview'), imageAnalysisPrompt: z.string(), messageGenerationPrompt: z.string(), }), rateLimit: z.object({ minDelayBetweenMessagesMs: z.number().default(300000), maxMessagesPerHour: z.number().default(8), jitterMs: z.number().default(60000), }), browser: z.object({ sessionDir: z.string().default('./data/browser-session'), headless: z.boolean().default(false), dryRun: z.boolean().default(false), }), database: z.object({ path: z.string().default('./data/reddit-bot.db'), }), media: z.object({ syncIntervalMs: z.number().default(1800000), // 30 minutes syncEnabled: z.boolean().default(true), }).default({ syncIntervalMs: 1800000, syncEnabled: true }), autoReply: z.object({ enabled: z.boolean().default(false), autoSend: z.boolean().default(false), // If true, send replies automatically. If false, require manual approval checkIntervalMs: z.number().default(300000), // 5 minutes upsellKeywords: z.array(z.string()).default([ 'onlyfans', 'only fans', 'fansly', 'manyvids', 'linktree', 'linktr.ee', 'subscribe', 'premium', 'paid content', 'custom content', 'custom vid', 'cashapp', 'cash app', 'venmo', 'paypal', 'tip me', 'spoil me', 'wishlist', 'throne.com', 'link in bio', 'check my profile', 'fanvue', 'loyalfans', 'cam show', 'buy my', 'selling', 'dm me for price', 'menu', 'rates', 'ppv', ]), }).default({ enabled: false, autoSend: false, checkIntervalMs: 300000, upsellKeywords: [] }), strategies: z.object({ explorationRate: z.number().min(0).max(1).default(0.2), minSamples: z.number().default(5), variants: z.array(z.object({ id: z.string(), weight: z.number().default(1), promptOverride: z.string(), })).default([]), }).default({ explorationRate: 0.2, minSamples: 5, variants: [] }), web: z.object({ enabled: z.boolean().default(true), port: z.number().default(3000), }), }); export type Config = z.infer; export function loadConfig(): Config { const configPath = path.resolve(process.cwd(), 'config.yaml'); if (!fs.existsSync(configPath)) { throw new Error(`Config file not found at ${configPath}`); } const configFile = fs.readFileSync(configPath, 'utf-8'); const rawConfig = YAML.parse(configFile); // Inject API key from environment if (!rawConfig.openrouter) { rawConfig.openrouter = {}; } rawConfig.openrouter.apiKey = process.env.OPENROUTER_API_KEY || rawConfig.openrouter.apiKey; // Inject Reddit credentials from environment if (process.env.REDDIT_USERNAME) { rawConfig.reddit.username = process.env.REDDIT_USERNAME; } if (process.env.REDDIT_PASSWORD) { rawConfig.reddit.password = process.env.REDDIT_PASSWORD; } // Override browser settings from environment if (process.env.BROWSER_HEADLESS !== undefined) { if (!rawConfig.browser) rawConfig.browser = {}; rawConfig.browser.headless = process.env.BROWSER_HEADLESS === 'true'; } if (!rawConfig.openrouter.apiKey) { throw new Error('OPENROUTER_API_KEY environment variable is required'); } const result = ConfigSchema.safeParse(rawConfig); if (!result.success) { const errors = result.error.issues .map((e) => ` - ${String(e.path.join('.'))}: ${e.message}`) .join('\n'); throw new Error(`Invalid configuration:\n${errors}`); } return result.data; }