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:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
+4
-366
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user