import { z } from 'zod'; import fs from 'fs'; import path from 'path'; import YAML from 'yaml'; import dotenv from 'dotenv'; import { TONE_KEYS } from './tone-profiles.js'; dotenv.config(); export const MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS = 20; export const ConversationBehaviourSchema = z.object({ mode: z.enum(['legacy', 'r4r', 'r4r-media', 'manual']).default('legacy'), maxAutomatedTurns: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS).default(3), rapportTurnsBeforeMediaAsk: z.number().int().min(1).max(MAX_CONFIGURED_AUTOMATED_CONVERSATION_TURNS - 1).default(3), }).superRefine((behaviour, context) => { if (behaviour.mode === 'r4r-media' && behaviour.rapportTurnsBeforeMediaAsk >= behaviour.maxAutomatedTurns) { context.addIssue({ code: 'custom', path: ['rapportTurnsBeforeMediaAsk'], message: 'Rapport turns must be lower than the maximum automated turns.', }); } }).default({ mode: 'legacy', maxAutomatedTurns: 3, rapportTurnsBeforeMediaAsk: 3 }); export type ConversationBehaviour = z.infer; export const SubredditConfigSchema = z.object({ name: z.string(), enabled: z.boolean().default(true), type: z.enum(['image', 'chat']).default('image'), weight: z.number().positive().optional(), tone: z.enum(TONE_KEYS).default('default'), scripted: z.string().optional(), conversation: ConversationBehaviourSchema, }); export type SubredditConfig = z.infer; const SubredditEntrySchema = z .union([z.string().transform((s) => ({ name: s })), z.object({}).passthrough()]) .pipe(SubredditConfigSchema); export const ConfigSchema = z.object({ reddit: z.object({ subreddits: z.array(SubredditEntrySchema).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), prioritizeRecentPosts: z.boolean().default(false), recentWindowMinutes: z.number().positive().default(60), maxReadyCandidates: z.number().int().min(1).max(500).default(25), sellerKeywords: z .array(z.string()) .default([ 'onlyfans', 'only fans', 'fansly', 'manyvids', 'linktree', 'linktr.ee', 'allmylinks', 'beacons.ai', 'cash.app', 'cashapp', 'venmo', 'paypal', 'telegram', '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', ]), sellerDomains: z .array(z.string()) .default([ 'onlyfans.com', 'fansly.com', 'fanvue.com', 'loyalfans.com', 'manyvids.com', 'linktr.ee', 'allmylinks.com', 'beacons.ai', 'beacons.page', 't.me', 'telegram.me', 'throne.com', 'throne.me', 'fan.ly', 'chaturbate.com', 'myfreecams.com', 'pornhub.com', 'xvideos.com', 'cash.app', 'cashapp.com', 'venmo.com', 'paypal.me', 'paypal.com', 'admireme.vip', 'fancentro.com', 'justforfans.app', 'justfor.fans', ]), }), conversations: z.object({ /** * Ends a conversation outright when the other party pushes it to another * app, to contact details, or to a payment rail. This is a safety control * rather than a lead-quality filter, so it is independent of * `reddit.filterSellers`. */ offPlatformKill: z.boolean().default(true), /** Whether a configured seller keyword or domain also ends the conversation. */ killOnSellerKeyword: z.boolean().default(true), /** * Second-stage AI screening of inbound replies. Runs only when the * deterministic rules come up clean, and fails open. */ aiReplyScreening: z.object({ enabled: z.boolean().default(false), killConfidence: z.number().min(0).max(1).default(0.8), flagConfidence: z.number().min(0).max(1).default(0.6), }).default({ enabled: false, killConfidence: 0.8, flagConfidence: 0.6 }), }).default({ offPlatformKill: true, killOnSellerKeyword: true, aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 }, }), pushNotifications: z.object({ enabled: z.boolean().default(false), /** Base URL of the ntfy server, e.g. https://ntfy.example.com. */ serverUrl: z.string().default('https://ntfy.sh'), /** Single ntfy topic every selected event is published to. */ topic: z.string().default(''), /** * Bearer token for a protected topic. Environment-owned (NTFY_AUTH_TOKEN) * like the other secrets, so it is never written to the settings record. */ authToken: z.string().optional(), priority: z.enum(['min', 'low', 'default', 'high', 'max']).default('default'), events: z.object({ imageReceived: z.boolean().default(true), replyReceived: z.boolean().default(false), messageFailed: z.boolean().default(false), botStopped: z.boolean().default(false), rateLimited: z.boolean().default(false), sessionExpired: z.boolean().default(true), syncFailed: z.boolean().default(false), }).default({ imageReceived: true, replyReceived: false, messageFailed: false, botStopped: false, rateLimited: false, sessionExpired: true, syncFailed: false, }), }).default({ enabled: false, serverUrl: 'https://ntfy.sh', topic: '', priority: 'default', events: { imageReceived: true, replyReceived: false, messageFailed: false, botStopped: false, rateLimited: false, sessionExpired: true, syncFailed: false, }, }), 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'), chatFallbackModels: z.array(z.string()).default([]), imageAnalysisPrompt: z.string(), messageGenerationPrompt: z.string(), chatOpenerPrompt: z.string().default( 'Write a truthful, respectful opener to an adult Reddit user who explicitly asked to chat.\n\nPost title: {title}\nPost body: {selftext}\nAuthor: u/{author}\nSelf-declared gender: {gender}\n\nUse British English where natural. Reference something specific from the post, keep it to 1-2 sentences, and end with an easy invitation to reply. Do not invent age, relationship status, location, identity, shared interests, or personal history. Avoid pressure, sexual escalation, clinical language, and generic compliments. Just write the message.', ), }), rateLimit: z.object({ minDelayBetweenMessagesMs: z.number().default(300000), maxMessagesPerHour: z.number().default(8), jitterMs: z.number().default(60000), }).default({ minDelayBetweenMessagesMs: 300000, maxMessagesPerHour: 8, jitterMs: 60000 }), browser: z.object({ sessionDir: z.string().default('./data/browser-session'), dryRun: z.boolean().default(false), }).default({ sessionDir: './data/browser-session', dryRun: false }), database: z.object({ path: z.string().default('./data/reddit-bot.db'), }).default({ path: './data/reddit-bot.db' }), media: z.object({ syncIntervalMs: z.number().default(1800000), // 30 minutes syncEnabled: z.boolean().default(true), }).default({ syncIntervalMs: 1800000, syncEnabled: true }), strategies: z.object({ explorationRate: z.number().min(0).max(1).default(0.2), minSamples: z.number().int().positive().transform((value) => Math.max(20, value)).default(20), responseWindowHours: z.number().positive().default(12), variants: z.array(z.object({ id: z.string(), weight: z.number().default(1), promptOverride: z.string(), })).default([]), }).default({ explorationRate: 0.2, minSamples: 20, responseWindowHours: 12, variants: [] }), chatStrategies: z.object({ explorationRate: z.number().min(0).max(1).default(0.2), minSamples: z.number().int().positive().transform((value) => Math.max(20, value)).default(20), responseWindowHours: z.number().positive().default(12), variants: z.array(z.object({ id: z.string(), weight: z.number().default(1), promptOverride: z.string(), })).default([]), }).default({ explorationRate: 0.2, minSamples: 20, responseWindowHours: 12, variants: [] }), web: z.object({ enabled: z.boolean().default(true), port: z.number().default(3000), /** Prefer WEB_DASHBOARD_TOKEN so secrets do not live in config.yaml. */ authToken: z.string().min(8).optional(), /** Optional least-privilege bearer token for GET/SSE agent access. */ agentReadToken: z.string().min(8).optional(), /** Optional bearer token for /api/v1 controls and jobs as well as reads. */ agentControlToken: z.string().min(8).optional(), /** Dangerous diagnostic endpoints are disabled unless explicitly enabled. */ debugEnabled: z.boolean().default(false), }).default({ enabled: true, port: 3000, debugEnabled: false }), }); export type Config = z.infer; export interface ConfigPersistence { load(environment?: NodeJS.ProcessEnv): Config | null; save(config: Config): void; } /** * Loads the database-backed configuration. A legacy YAML file is imported * exactly once when the database has not yet been seeded, which makes the * migration safe for existing installations. */ export function loadConfig(store?: ConfigPersistence): Config { const storedConfig = store?.load(process.env); if (storedConfig) return storedConfig; 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; if (!rawConfig.database) rawConfig.database = {}; // The database must be located before application settings can be read, so // its path is an environment-owned bootstrap value rather than app config. rawConfig.database.path = process.env.DATABASE_PATH ?? './data/reddit-bot.db'; if (process.env.NTFY_AUTH_TOKEN?.trim()) { if (!rawConfig.pushNotifications) rawConfig.pushNotifications = {}; rawConfig.pushNotifications.authToken = process.env.NTFY_AUTH_TOKEN; } if (process.env.WEB_DASHBOARD_TOKEN !== undefined) { if (!rawConfig.web) rawConfig.web = {}; rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN; } if (process.env.REDDIT_BOT_AGENT_READ_TOKEN?.trim()) { if (!rawConfig.web) rawConfig.web = {}; rawConfig.web.agentReadToken = process.env.REDDIT_BOT_AGENT_READ_TOKEN; } if (process.env.REDDIT_BOT_AGENT_CONTROL_TOKEN?.trim()) { if (!rawConfig.web) rawConfig.web = {}; rawConfig.web.agentControlToken = process.env.REDDIT_BOT_AGENT_CONTROL_TOKEN; } 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}`); } if (result.data.web.enabled && !result.data.web.authToken) { throw new Error('WEB_DASHBOARD_TOKEN environment variable or web.authToken configuration is required when the dashboard is enabled'); } store?.save(result.data); return result.data; }