Restructure chat service, web routes, and add test suite

Split monolithic chat service into modular structure with Matrix client
support, extract web server into per-resource route modules, introduce
bootstrap/main-loop separation, and add comprehensive vitest coverage
across services, repositories, and utilities. Adds project docs and
CLAUDE.md agent instructions.
This commit is contained in:
jjbrunton committed 2026-05-21 01:32:10 +01:00
1 parent dfa0580859
commit 633bb0ec04
84 files changed
+19278 -3888

No files matched your search

+59 -18
View File
@@ -6,9 +6,21 @@ 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(z.string()).min(1),
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']),
@@ -52,6 +64,35 @@ export const ConfigSchema = z.object({
'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(),
}),
@@ -60,39 +101,30 @@ export const ConfigSchema = z.object({
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 }),
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),
@@ -102,10 +134,19 @@ export const ConfigSchema = z.object({
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>;