233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
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 SubredditConfigSchema = z.object({
|
|
name: z.string(),
|
|
type: z.enum(['image', 'chat']).default('image'),
|
|
weight: z.number().positive().optional(),
|
|
tone: z.enum(TONE_KEYS).default('default'),
|
|
scripted: z.string().optional(),
|
|
});
|
|
|
|
export type SubredditConfig = z.infer<typeof SubredditConfigSchema>;
|
|
|
|
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),
|
|
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',
|
|
]),
|
|
sellerDomains: z
|
|
.array(z.string())
|
|
.default([
|
|
'onlyfans.com',
|
|
'fansly.com',
|
|
'fanvue.com',
|
|
'loyalfans.com',
|
|
'manyvids.com',
|
|
'linktr.ee',
|
|
'allmylinks.com',
|
|
'beacons.ai',
|
|
'beacons.page',
|
|
'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',
|
|
]),
|
|
}),
|
|
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(30, value)).default(30),
|
|
responseWindowHours: z.number().positive().default(48),
|
|
variants: z.array(z.object({
|
|
id: z.string(),
|
|
weight: z.number().default(1),
|
|
promptOverride: z.string(),
|
|
})).default([]),
|
|
}).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 48, variants: [] }),
|
|
chatStrategies: z.object({
|
|
explorationRate: z.number().min(0).max(1).default(0.2),
|
|
minSamples: z.number().int().positive().transform((value) => Math.max(30, value)).default(30),
|
|
responseWindowHours: z.number().positive().default(48),
|
|
variants: z.array(z.object({
|
|
id: z.string(),
|
|
weight: z.number().default(1),
|
|
promptOverride: z.string(),
|
|
})).default([]),
|
|
}).default({ explorationRate: 0.2, minSamples: 30, responseWindowHours: 48, 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<typeof ConfigSchema>;
|
|
|
|
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.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;
|
|
}
|