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

+4 -366
View File
@@ -1,27 +1,7 @@
import { loadConfig } from './config/index.js';
import { createDatabase, runMigrations } from './database/client.js';
import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from './database/repositories.js';
import { BrowserClient } from './browser/client.js';
import { RedditMessagePage } from './browser/message-page.js';
import { RedditApiClient } from './reddit/api.js';
import { OpenRouterClient } from './ai/openrouter.js';
import { MonitorService } from './services/monitor.js';
import { ProcessorService } from './services/processor.js';
import { ResponseCheckerService } from './services/response-checker.js';
import { StrategySelector } from './services/strategy-selector.js';
import { ChatService } from './services/chat.js';
import { MediaService } from './services/media.js';
import { MediaSyncService } from './services/media-sync.js';
import { AutoReplyService } from './services/auto-reply.js';
import { PlaywrightMediaDownloader } from './browser/media-downloader.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { createLogger } from './utils/logger.js';
import { withRetry } from './utils/retry.js';
import { WebServer } from './web/server.js';
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
import { bootstrap } from './bootstrap.js';
import { runMainLoop } from './main-loop.js';
async function main(): Promise<void> {
const logger = createLogger();
@@ -30,7 +10,6 @@ async function main(): Promise<void> {
logger.info('Reddit Bot Starting');
logger.info('='.repeat(60));
// Load configuration
let config;
try {
config = loadConfig();
@@ -40,349 +19,8 @@ async function main(): Promise<void> {
process.exit(1);
}
// Initialize database
const db = createDatabase(config);
runMigrations(db);
const postsRepo = new PostsRepository(db);
const messagesRepo = new MessagesRepository(db);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
logger.info('Database initialized');
// Initialize browser
const browserClient = new BrowserClient(config, logger);
await browserClient.initialize();
const page = await browserClient.getPage();
const messagePage = new RedditMessagePage(page, logger);
// Set dry run mode if configured
messagePage.setDryRun(config.browser.dryRun);
// Verify login
logger.info('Verifying Reddit login status...');
const isLoggedIn = await messagePage.isLoggedIn();
if (!isLoggedIn) {
logger.warn('Session invalid or not logged in.');
// Attempt automated login if credentials are provided
if (config.reddit.username && config.reddit.password) {
logger.info('Credentials found in config/env. Attempting automated login...');
const loginSuccess = await browserClient.login();
if (!loginSuccess) {
logger.error('Automated login failed. Please check your credentials or run "npm run login" locally.');
await browserClient.close();
process.exit(1);
}
} else {
logger.error('Not logged in to Reddit and no credentials provided!');
logger.error('Please run "npm run login" first to authenticate, OR set REDDIT_USERNAME and REDDIT_PASSWORD environment variables.');
await browserClient.close();
process.exit(1);
}
}
logger.info('Reddit login verified');
// Initialize services
const redditApi = new RedditApiClient(config, logger);
redditApi.setBrowserContext(browserClient.getContext()!);
// Create separate pages for concurrent operations
// 1. chatPage: For background response checking
const chatPage = await browserClient.getContext()!.newPage();
// 2. webUIPage: For user-driven chat browsing in the web dashboard
const webUIPage = await browserClient.getContext()!.newPage();
const aiClient = new OpenRouterClient(config, logger);
const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger);
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
const strategySelector = new StrategySelector(config, messagesRepo, logger);
processorService.setStrategySelector(strategySelector);
const responseChecker = new ResponseCheckerService(
chatPage,
messagesRepo,
successfulMessagesRepo,
postsRepo,
logger
);
const mediaDownloader = new PlaywrightMediaDownloader(webUIPage, logger);
const mediaService = new MediaService(mediaDownloader, logger);
const chatService = new ChatService(webUIPage, logger, successfulMessagesRepo, mediaService);
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
const rateLimiter = new RateLimiter(config, logger);
await chatService.loadCache();
// Initialize auto-reply service
const autoReplyService = new AutoReplyService(
chatService,
aiClient,
config,
logger,
// onReplyGenerated
(chat) => {
webServer?.logActivity('info', `Auto-reply pending approval for u/${chat.username}`, chat.username);
webServer?.pushLog('info', `Reply pending approval for u/${chat.username}: "${chat.pendingReply?.slice(0, 80)}..."`);
},
// onUpsellDetected
(chat, keyword) => {
webServer?.logActivity('skipped', `Upsell detected from u/${chat.username} (keyword: "${keyword}")`, chat.username);
webServer?.pushLog('warn', `Upsell detected from u/${chat.username} — keyword: "${keyword}"`);
},
// onAutoSent
(chat) => {
webServer?.logActivity('sent', `Auto-reply sent to u/${chat.username}`, chat.username);
webServer?.pushLog('info', `Auto-reply sent to u/${chat.username}`);
},
);
// Start background media sync
if (config.media.syncEnabled) {
mediaSyncService.start();
}
// Start auto-reply service
if (config.autoReply.enabled) {
autoReplyService.start();
}
// Initialize web dashboard
let webServer: WebServer | null = null;
if (config.web.enabled) {
webServer = new WebServer(
config.web.port,
config,
postsRepo,
messagesRepo,
successfulMessagesRepo,
logger
);
webServer.setChatService(chatService);
webServer.setMediaService(mediaService);
webServer.setMediaSyncService(mediaSyncService);
webServer.setResponseChecker(responseChecker);
webServer.setRateLimiter(rateLimiter);
webServer.setAiClient(aiClient);
webServer.setStrategySelector(strategySelector);
webServer.setAutoReplyService(autoReplyService);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
webServer.start();
}
// Load any existing successful examples
const existingExamples = responseChecker.getSuccessfulExamples(3);
processorService.setSuccessfulExamples(existingExamples);
const successCount = responseChecker.getSuccessCount();
if (successCount > 0) {
logger.info(`Loaded ${successCount} successful message(s) from history`);
}
// Graceful shutdown handler
let isShuttingDown = false;
let responseCheckInterval: NodeJS.Timeout | null = null;
const shutdown = async (): Promise<void> => {
if (isShuttingDown) return;
isShuttingDown = true;
if (responseCheckInterval) {
clearInterval(responseCheckInterval);
}
mediaSyncService.stop();
autoReplyService.stop();
logger.info('Shutting down gracefully...');
webServer?.stop();
await browserClient.close();
db.close();
logger.info('Goodbye!');
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
logger.info('');
logger.info('Bot is now running!');
logger.info(`Monitoring subreddits: ${config.reddit.subreddits.join(', ')}`);
logger.info(`Poll interval: ${config.reddit.pollIntervalMs / 1000} seconds`);
logger.info(`Min delay between messages: ${config.rateLimit.minDelayBetweenMessagesMs / 1000} seconds`);
logger.info(`Max messages per hour: ${config.rateLimit.maxMessagesPerHour}`);
logger.info('');
// Start background response checking
const RESPONSE_CHECK_INTERVAL_MS = Math.max(config.reddit.pollIntervalMs * 5, 60000);
const checkResponses = async () => {
if (isShuttingDown) return;
try {
logger.debug('Checking for responses to sent messages (Background Thread)...');
const responses = await responseChecker.checkForResponses();
if (responses.some((r) => r.gotResponse)) {
// Reload successful examples
const updatedExamples = responseChecker.getSuccessfulExamples(3);
processorService.setSuccessfulExamples(updatedExamples);
}
} catch (error) {
logger.error('Error in background response checker:', error);
}
};
// Run immediately then schedule
checkResponses();
responseCheckInterval = setInterval(checkResponses, RESPONSE_CHECK_INTERVAL_MS);
// Track poll states
let pollCycle = 0;
let lastPausedLog = 0;
// Main loop
while (!isShuttingDown) {
try {
// Check if bot is running (controlled via web UI)
if (webServer && !webServer.isRunning()) {
if (Date.now() - lastPausedLog > 60000) { // Log every minute
logger.info('Bot is paused. Use the web dashboard to start.');
lastPausedLog = Date.now();
}
await sleep(1000); // Check every second for quick start
continue;
}
pollCycle++;
// Get unprocessed posts
const posts = await monitorService.getUnprocessedPosts();
if (posts.length > 0) {
logger.info(`Found ${posts.length} unprocessed posts`);
webServer?.pushLog('info', `Found ${posts.length} unprocessed posts`);
} else {
logger.info('No new posts found. Monitoring...');
}
for (const post of posts) {
if (isShuttingDown) break;
try {
// Mark as processed immediately to avoid duplicates on restart
const postImageUrl = redditApi.getImageUrl(post);
postsRepo.insert({
id: post.id,
subreddit: post.subreddit,
author: post.author,
title: post.title,
imageUrl: postImageUrl,
processedAt: Date.now(),
});
// Check if user is a seller/OnlyFans profile (before spending on AI)
if (config.reddit.filterSellers && config.reddit.sellerKeywords.length > 0) {
const isSeller = await redditApi.isSellerProfile(post.author, config.reddit.sellerKeywords);
if (isSeller) {
logger.info(`Skipping u/${post.author} - seller/OnlyFans profile detected`);
webServer?.logActivity('skipped', `Skipped u/${post.author} (seller profile)`, post.author);
webServer?.pushLog('info', `🚫 Skipped u/${post.author} - seller/OnlyFans profile`);
postsRepo.setError(post.id, 'Seller profile detected');
continue;
}
}
// Refresh examples weighted toward this post's subreddit
const subredditExamples = responseChecker.getSuccessfulExamples(3, post.subreddit);
processorService.setSuccessfulExamples(subredditExamples);
// Process post (download image, analyze, generate message)
const processed = await withRetry(
() => processorService.processPost(post),
{ logger, retries: 2, operationName: 'Post processing' }
);
if (!processed) {
logger.debug(`Skipping post ${post.id} - processing returned null`);
webServer?.logActivity('skipped', `Skipped post by u/${post.author}`, post.author);
continue;
}
// Update database with analysis
postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage);
// Log image analysis and strategy to realtime log
webServer?.pushLog('info', `🖼️ Image analysis for u/${post.author}: ${processed.imageAnalysis}`);
if (processed.strategyId) {
webServer?.pushLog('info', `🎯 Strategy: ${processed.strategyId}`);
}
// Create subject line
const subject = "Stunning";
// Send message with rate limiting
await rateLimiter.throttle(async () => {
logger.info(`Sending message to u/${post.author}...`);
webServer?.pushLog('info', `Sending message to u/${post.author}...`);
const success = await messagePage.sendMessage(
post.author,
subject,
processed.generatedMessage
);
if (success) {
// Message was sent successfully via /message/compose
// Note: We skip chat.reddit.com verification since /message/compose sends to inbox, not chat
// The sendMessage function already confirms success via form submission indicators
postsRepo.markMessageSent(post.id);
// Refresh top chats in background so dashboard is up-to-date
chatService.refreshTopChats(5).catch(err => logger.error('Failed to trigger chat refresh:', err));
messagesRepo.insert({
postId: post.id,
recipient: post.author,
subject,
message: processed.generatedMessage,
sentAt: Date.now(),
strategyId: processed.strategyId,
});
const stats = rateLimiter.getStats();
const currentCount = stats.messagesThisHour + 1;
logger.info(
`Message sent successfully! (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`
);
webServer?.logActivity('sent', `Sent message to u/${post.author}`, post.author, postImageUrl ?? undefined);
webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`);
webServer?.updateStats(currentCount);
} else {
postsRepo.setError(post.id, 'Failed to send message');
webServer?.logActivity('error', `Failed to send to u/${post.author}`, post.author, postImageUrl ?? undefined);
webServer?.pushLog('error', `❌ Failed to send message to u/${post.author}`);
}
return success;
}, (success) => success === true);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error processing post ${post.id}:`, error);
postsRepo.setError(post.id, errorMessage);
}
}
if (!isShuttingDown) {
// Wait before next poll
logger.debug(`Waiting ${config.reddit.pollIntervalMs / 1000} seconds before next poll...`);
await sleep(config.reddit.pollIntervalMs);
}
} catch (error) {
logger.error('Error in main loop:', error);
// Wait a minute on error before retrying
await sleep(60000);
}
}
const app = await bootstrap(config, logger);
await runMainLoop(app);
}
main().catch((error) => {