- Blur NSFW media gallery images until hover/reveal - Surface chat sync progress in the dashboard header - Extend chat service, cache, and monitor with delta-media handling - Add tests for chat cooldown, delta media, and monitor flows Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
201 lines
7.4 KiB
TypeScript
201 lines
7.4 KiB
TypeScript
import { z } from 'zod';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import YAML from 'yaml';
|
||
import dotenv from 'dotenv';
|
||
|
||
dotenv.config();
|
||
|
||
export const SubredditConfigSchema = z.object({
|
||
name: z.string(),
|
||
type: z.enum(['image', 'chat']).default('image'),
|
||
weight: z.number().positive().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',
|
||
]),
|
||
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'),
|
||
chatFallbackModels: z.array(z.string()).default([]),
|
||
imageAnalysisPrompt: z.string(),
|
||
messageGenerationPrompt: z.string(),
|
||
chatOpenerPrompt: z.string().default(
|
||
'You are writing a cold opener DM to a Reddit user who just posted in a chat/sexting subreddit looking for someone to chat with.\n\nPost title: {title}\nPost body: {selftext}\nAuthor: u/{author}\nDetected gender of poster: {gender}\n\nYOUR PERSONA:\n- You are male, based in the UK, pick a random age between 30 and 35 for this message\n- Roughly half the time, mention (or imply) you\'re married/have a wife and are looking for something on the side; the other half, don\'t mention relationship status\n- Don\'t force the married angle if their post wouldn\'t vibe with it\n- Use British English spelling and phrasing where it feels natural\n\nWrite a casual Reddit DM (1-2 sentences) opening a chat.\n\nMUST follow:\n- Lead with your age/gender/location tag (e.g. "32M UK", "34M UK married") when it fits naturally\n- Reference something SPECIFIC from their title/body (their mood, what they\'re into, vibe)\n- Be confident and direct, not formal\n- Use proper capitalisation, no AI/clinical language\n- End with a hook that invites a reply (ask them something, or offer something playful)\n\nDO NOT:\n- Use words like "captivating", "alluring", "intriguing"\n- Open with "Hey there" / "How\'s your day"\n- Write more than 2 sentences\n- Claim to be a different age/gender than the persona above\n- Use em dashes (—) or en dashes (–). Use commas, full stops, or separate sentences instead.\n\nJust write the message, nothing else.',
|
||
),
|
||
}),
|
||
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'),
|
||
headless: z.boolean().default(false),
|
||
dryRun: z.boolean().default(false),
|
||
}).default({ sessionDir: './data/browser-session', headless: false, 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().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: [] }),
|
||
chatStrategies: 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),
|
||
}).default({ enabled: true, port: 3000 }),
|
||
});
|
||
|
||
export type Config = z.infer<typeof ConfigSchema>;
|
||
|
||
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;
|
||
}
|