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

+97 -690
View File
@@ -1,765 +1,181 @@
import express from 'express';
import path from 'path';
import type { Server } from 'http';
import type { Response } from 'express';
import type { Logger } from '../utils/logger.js';
import type { PostsRepository, MessagesRepository, SuccessfulMessagesRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js';
import type { ChatService, ActiveChat } from '../services/chat.js';
import type {
PostsRepository,
MessagesRepository,
SuccessfulMessagesRepository,
ActivityLogRepository,
} from '../database/repositories.js';
import type { ChatService } from '../services/chat.js';
import type { MediaService } from '../services/media.js';
import type { MediaSyncService } from '../services/media-sync.js';
import type { ResponseCheckerService } from '../services/response-checker.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { AutoReplyService } from '../services/auto-reply.js';
import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js';
import { ActivityLog } from './activity-log.js';
import { LogBroadcaster } from './log-broadcaster.js';
import type { WebContext } from './context.js';
import type { ActivityItem, LogEntry, RuntimeConfig } from './types.js';
import { createStatsRouter } from './routes/stats.js';
import { createChatsRouter } from './routes/chats.js';
import { createConversationsRouter } from './routes/conversations.js';
import { createMediaRouter } from './routes/media.js';
import { createControlRouter } from './routes/control.js';
import { createDebugRouter } from './routes/debug.js';
import { createLogsRouter } from './routes/logs.js';
import { createAnalyticsRouter } from './routes/analytics.js';
import { createConfigRouter } from './routes/config.js';
import { createLoginRouter } from './routes/login.js';
export interface BotStats {
isRunning: boolean;
dryRunMode: boolean;
totalProcessed: number;
totalMessagesSent: number;
totalResponses: number;
responseRate: number;
messagesThisHour: number;
maxMessagesPerHour: number;
subreddits: string[];
targetGenders: string[];
recentActivity: ActivityItem[];
}
export interface ActivityItem {
timestamp: number;
type: 'sent' | 'skipped' | 'response' | 'error' | 'info' | 'debug';
message: string;
recipient?: string;
imageUrl?: string;
}
export interface LogEntry {
timestamp: number;
level: 'info' | 'warn' | 'error' | 'debug';
message: string;
data?: unknown;
}
export interface RuntimeConfig {
rateLimit: {
minDelayBetweenMessagesMs: number;
maxMessagesPerHour: number;
jitterMs: number;
};
browser: {
dryRun: boolean;
};
}
export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js';
/**
* Thin Express shell. Owns the server lifecycle, the activity log,
* the SSE log broadcaster, and a `WebContext` that route modules read from.
*
* Service references (chat, media, etc.) are still injected via `set*`
* methods because `index.ts` builds them after the WebServer to keep
* setup ordering identical to the pre-refactor code.
*/
export class WebServer {
private app: express.Application;
private server: Server | null = null;
private activityLog: ActivityItem[] = [];
private realtimeLog: LogEntry[] = [];
private sseClients: Set<Response> = new Set();
private botRunning = false; // Start stopped
private activity: ActivityLog;
private broadcaster = new LogBroadcaster();
private botRunning = false;
private messagesThisHour = 0;
private chatService: ChatService | null = null;
private mediaService: MediaService | null = null;
private mediaSyncService: MediaSyncService | null = null;
private responseChecker: ResponseCheckerService | null = null;
private rateLimiter: RateLimiter | null = null;
private aiClient: OpenRouterClient | null = null;
private autoReplyService: AutoReplyService | null = null;
private strategySelector: StrategySelector | null = null;
private runtimeConfig: RuntimeConfig;
private onConfigChange: ((config: RuntimeConfig) => void) | null = null;
private services: WebContext['services'] = {};
constructor(
private port: number,
private config: Config,
private postsRepo: PostsRepository,
private messagesRepo: MessagesRepository,
private successfulMessagesRepo: SuccessfulMessagesRepository,
private logger: Logger
private activityLogRepo: ActivityLogRepository,
private logger: Logger,
) {
this.app = express();
// Initialize runtime config from loaded config
this.activity = new ActivityLog(activityLogRepo);
this.runtimeConfig = {
rateLimit: {
minDelayBetweenMessagesMs: config.rateLimit.minDelayBetweenMessagesMs,
maxMessagesPerHour: config.rateLimit.maxMessagesPerHour,
jitterMs: config.rateLimit.jitterMs,
},
browser: {
dryRun: config.browser.dryRun,
},
browser: { dryRun: config.browser.dryRun },
};
this.app = express();
this.setupRoutes();
}
private context(): WebContext {
return {
logger: this.logger,
config: this.config,
runtimeConfig: this.runtimeConfig,
activity: this.activity,
broadcaster: this.broadcaster,
repos: {
posts: this.postsRepo,
messages: this.messagesRepo,
successful: this.successfulMessagesRepo,
},
services: this.services,
getBotRunning: () => this.botRunning,
setBotRunning: (running) => {
this.botRunning = running;
},
getMessagesThisHour: () => this.messagesThisHour,
getOnConfigChange: () => this.onConfigChange,
};
}
private setupRoutes(): void {
this.app.use('/media', express.static(path.join(process.cwd(), 'data', 'media')));
// Serve static files
this.app.use(express.static(path.join(process.cwd(), 'public')));
this.app.use(express.json());
// API: Get stats
this.app.get('/api/stats', (req, res) => {
const stats = this.getStats();
res.json(stats);
});
const ctx = this.context();
this.app.use('/api', createStatsRouter(ctx));
this.app.use('/api', createChatsRouter(ctx));
this.app.use('/api', createConversationsRouter(ctx));
this.app.use('/api', createMediaRouter(ctx));
this.app.use('/api', createControlRouter(ctx));
this.app.use('/api', createDebugRouter(ctx));
this.app.use('/api', createLogsRouter(ctx));
this.app.use('/api', createAnalyticsRouter(ctx));
this.app.use('/api', createConfigRouter(ctx));
this.app.use('/api', createLoginRouter(ctx));
// API: Get recent activity
this.app.get('/api/activity', (req, res) => {
res.json(this.activityLog.slice(-50).reverse());
});
// API: Look up posts by author username
this.app.get('/api/posts/by-author/:username', (req, res) => {
const posts = this.postsRepo.getByAuthor(req.params.username);
res.json(posts);
});
// API: Get recent messages
this.app.get('/api/messages', (req, res) => {
const messages = this.messagesRepo.getRecent(20);
res.json(messages);
});
// API: Get successful messages (ones that got responses)
this.app.get('/api/successful', (req, res) => {
const successful = this.successfulMessagesRepo.getRecent(10);
res.json(successful);
});
// API: Get active chats (from cache - last 48 hours)
// Does NOT fetch from Reddit - use /api/chats/fetch-all to refresh from Reddit
this.app.get('/api/chats', async (req, res) => {
if (!this.chatService) {
res.json([]);
return;
}
try {
const allChats = await this.chatService.getCachedConversations();
// Filter to last 48 hours for "active" view
const cutoffTime = Date.now() - (48 * 60 * 60 * 1000);
const activeChats = allChats.filter(chat => chat.lastMessageTime >= cutoffTime);
res.json(activeChats);
} catch (error) {
this.logger.error('Failed to fetch chats:', error);
res.json([]);
}
});
// API: Fetch all chats/messages and refresh cache
this.app.post('/api/chats/fetch-all', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const chats = await this.chatService.fetchAllMessages();
res.json({ success: true, count: chats.length });
} catch (error) {
this.logger.error('Failed to fetch all chats:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Get cached conversations with full message history
this.app.get('/api/conversations', async (req, res) => {
if (!this.chatService) {
res.json([]);
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
res.json(conversations);
} catch (error) {
this.logger.error('Failed to fetch conversations:', error);
res.json([]);
}
});
// API: Get a single cached conversation by room ID or username
this.app.get('/api/conversations/:id', async (req, res) => {
if (!this.chatService) {
res.status(404).json({ error: 'Chat service not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
res.json(conversation);
} catch (error) {
this.logger.error('Failed to fetch conversation:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Suggest a reply for a conversation using AI
this.app.post('/api/conversations/:id/suggest-reply', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
if (!this.aiClient) {
res.status(500).json({ error: 'AI client not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (!conversation.messages || conversation.messages.length === 0) {
res.status(400).json({ error: 'No messages in conversation to generate a reply from' });
return;
}
const hasReply = conversation.messages.some(m => !m.isSelf);
if (!hasReply) {
res.status(400).json({ error: 'No reply from user yet - nothing to respond to' });
return;
}
const suggestedReply = await this.aiClient.generateReply(
conversation.messages.map(m => ({ sender: m.sender, text: m.text, isSelf: m.isSelf })),
conversation.username
);
res.json({ suggestedReply });
} catch (error) {
this.logger.error('Failed to generate suggested reply:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Send a reply in a chat conversation
this.app.post('/api/conversations/:id/send-reply', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const { message } = req.body;
if (!message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ error: 'Message is required' });
return;
}
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (!conversation.url) {
res.status(400).json({ error: 'No chat URL for this conversation' });
return;
}
const success = await this.chatService.sendChatMessage(conversation.url, message.trim());
if (success) {
this.logActivity('sent', `Sent chat reply to u/${conversation.username}`, conversation.username);
this.pushLog('info', `Chat reply sent to u/${conversation.username}`);
res.json({ success: true });
// Refresh chats in background so cache reflects the sent message
this.chatService.refreshTopChats(5).catch(err =>
this.logger.error('Failed to refresh chats after send-reply:', err)
);
} else {
res.status(500).json({ error: 'Failed to send chat message' });
}
} catch (error) {
this.logger.error('Failed to send chat reply:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Approve a pending auto-reply
this.app.post('/api/conversations/:id/approve-reply', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (conversation.replyStatus !== 'pending' || !conversation.pendingReply) {
res.status(400).json({ error: 'No pending reply to approve' });
return;
}
if (!conversation.url) {
res.status(400).json({ error: 'No chat URL for this conversation' });
return;
}
const message = conversation.pendingReply;
const success = await this.chatService.sendChatMessage(conversation.url, message);
if (success) {
conversation.replyStatus = 'sent';
conversation.pendingReply = undefined;
await this.chatService.saveCacheExternal();
this.logActivity('sent', `Approved & sent auto-reply to u/${conversation.username}`, conversation.username);
this.pushLog('info', `Auto-reply approved and sent to u/${conversation.username}`);
res.json({ success: true });
// Refresh chats in background so cache reflects the sent message
this.chatService.refreshTopChats(5).catch(err =>
this.logger.error('Failed to refresh chats after approve-reply:', err)
);
} else {
res.status(500).json({ error: 'Failed to send message' });
}
} catch (error) {
this.logger.error('Failed to approve reply:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Reject a pending auto-reply
this.app.post('/api/conversations/:id/reject-reply', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
if (conversation.replyStatus !== 'pending') {
res.status(400).json({ error: 'No pending reply to reject' });
return;
}
conversation.pendingReply = undefined;
conversation.replyStatus = undefined;
// Clear processed state so it can be re-evaluated later if needed
if (this.autoReplyService) {
this.autoReplyService.clearProcessedState(conversation.username);
}
await this.chatService.saveCacheExternal();
this.logActivity('info', `Rejected auto-reply for u/${conversation.username}`, conversation.username);
this.pushLog('info', `Auto-reply rejected for u/${conversation.username}`);
res.json({ success: true });
} catch (error) {
this.logger.error('Failed to reject reply:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Dismiss upsell detection flag
this.app.post('/api/conversations/:id/dismiss-upsell', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const conversations = await this.chatService.getCachedConversations();
const id = decodeURIComponent(req.params.id);
const conversation = conversations.find(
(c: ActiveChat) => c.roomId === id || c.username === id || c.roomName === id
);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
conversation.replyStatus = undefined;
conversation.pendingReply = undefined;
await this.chatService.saveCacheExternal();
res.json({ success: true });
} catch (error) {
this.logger.error('Failed to dismiss upsell flag:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Get downloaded chat media
this.app.get('/api/media', async (req, res) => {
if (!this.mediaService) {
res.json([]);
return;
}
try {
const items = await this.mediaService.getMediaItems();
res.json(items.map((item) => ({
...item,
urlPath: `/media/${encodeURIComponent(item.relativePath)}`,
})));
} catch (error) {
this.logger.error('Failed to fetch media index:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Get media sync status
this.app.get('/api/media/sync', (req, res) => {
if (!this.mediaSyncService) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
res.json(this.mediaSyncService.getStatus());
});
// API: Trigger media sync from dashboard
this.app.post('/api/media/sync', async (req, res) => {
if (!this.mediaSyncService) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
try {
const full = req.body?.full === true;
const started = await this.mediaSyncService.triggerSync(full);
if (!started) {
res.json({ success: false, reason: 'Sync already in progress' });
return;
}
this.pushLog('info', `Media sync triggered from dashboard (${full ? 'full' : 'incremental'})`);
res.json({ success: true });
} catch (error) {
this.logger.error('Media sync trigger failed:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Full media sync - walks ALL conversations
this.app.post('/api/media/sync/full', async (req, res) => {
if (!this.mediaSyncService) {
res.status(503).json({ error: 'Media sync service not initialized' });
return;
}
try {
const started = await this.mediaSyncService.triggerSync(true);
if (!started) {
res.json({ success: false, reason: 'Sync already in progress' });
return;
}
this.pushLog('info', 'Full media sync triggered from dashboard (all conversations)');
res.json({ success: true });
} catch (error) {
this.logger.error('Full media sync trigger failed:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Start bot
this.app.post('/api/start', (req, res) => {
this.botRunning = true;
this.logActivity('sent', 'Bot started by user');
this.logger.info('Bot started via web UI');
res.json({ success: true, running: true });
});
// API: Stop bot
this.app.post('/api/stop', (req, res) => {
this.botRunning = false;
this.logActivity('skipped', 'Bot stopped by user');
this.logger.info('Bot stopped via web UI');
res.json({ success: true, running: false });
});
// API: Debug inspect chat DOM
this.app.post('/api/debug/inspect-dom', async (req, res) => {
if (!this.chatService) {
res.status(500).json({ error: 'Chat service not initialized' });
return;
}
try {
const chatUrl = req.body?.url;
if (!chatUrl) {
res.status(400).json({ error: 'url is required' });
return;
}
const result = await this.chatService.debugInspectDom(chatUrl);
res.json(result);
} catch (error) {
this.logger.error('DOM inspection failed:', error);
res.status(500).json({ error: String(error) });
}
});
// API: Debug check responses
this.app.post('/api/debug/check-responses', async (req, res) => {
if (!this.responseChecker) {
res.status(500).json({ error: 'Response checker not initialized' });
return;
}
try {
this.logger.info('Manual response check triggered via API');
const results = await this.responseChecker.checkForResponses();
res.json({ success: true, results });
} catch (error) {
this.logger.error('Manual response check failed:', error);
res.status(500).json({ error: String(error) });
}
});
// API: SSE endpoint for realtime logs
this.app.get('/api/logs/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
// Send initial connection message
res.write(`data: ${JSON.stringify({ type: 'connected', timestamp: Date.now() })}\n\n`);
// Add client to SSE set
this.sseClients.add(res);
// Send recent logs as initial data
this.realtimeLog.slice(-50).forEach(log => {
res.write(`data: ${JSON.stringify({ type: 'log', ...log })}\n\n`);
});
// Remove client on disconnect
req.on('close', () => {
this.sseClients.delete(res);
});
});
// API: Get recent logs (for initial load)
this.app.get('/api/logs', (req, res) => {
const limit = parseInt(req.query.limit as string) || 100;
res.json(this.realtimeLog.slice(-limit));
});
// API: Get active timers
this.app.get('/api/timers', (req, res) => {
if (!this.rateLimiter) {
res.status(503).json({ error: 'Rate limiter not initialized' });
return;
}
res.json(this.rateLimiter.getTimers());
});
// API: Analytics - response rate by subreddit
this.app.get('/api/analytics/by-subreddit', (req, res) => {
res.json(this.messagesRepo.getSubredditStats());
});
// API: Analytics - response rate by strategy
this.app.get('/api/analytics/by-strategy', (req, res) => {
if (this.strategySelector) {
res.json(this.strategySelector.getStats());
} else {
res.json(this.messagesRepo.getStrategyStats());
}
});
// API: Analytics - daily response rate trend
this.app.get('/api/analytics/trend', (req, res) => {
const days = parseInt(req.query.days as string) || 30;
res.json(this.messagesRepo.getResponseRateTrend(days));
});
// API: Get runtime config
this.app.get('/api/config', (req, res) => {
res.json({
rateLimit: this.runtimeConfig.rateLimit,
browser: {
dryRun: this.runtimeConfig.browser.dryRun,
},
});
});
// API: Update runtime config
this.app.put('/api/config', (req, res) => {
try {
const updates = req.body;
// Validate and apply rate limit updates
if (updates.rateLimit) {
if (typeof updates.rateLimit.minDelayBetweenMessagesMs === 'number') {
this.runtimeConfig.rateLimit.minDelayBetweenMessagesMs = Math.max(0, updates.rateLimit.minDelayBetweenMessagesMs);
}
if (typeof updates.rateLimit.maxMessagesPerHour === 'number') {
this.runtimeConfig.rateLimit.maxMessagesPerHour = Math.max(1, updates.rateLimit.maxMessagesPerHour);
}
if (typeof updates.rateLimit.jitterMs === 'number') {
this.runtimeConfig.rateLimit.jitterMs = Math.max(0, updates.rateLimit.jitterMs);
}
}
// Validate and apply browser updates
if (updates.browser) {
if (typeof updates.browser.dryRun === 'boolean') {
this.runtimeConfig.browser.dryRun = updates.browser.dryRun;
}
}
// Also update the original config object so getStats reflects new values
this.config.rateLimit.minDelayBetweenMessagesMs = this.runtimeConfig.rateLimit.minDelayBetweenMessagesMs;
this.config.rateLimit.maxMessagesPerHour = this.runtimeConfig.rateLimit.maxMessagesPerHour;
this.config.rateLimit.jitterMs = this.runtimeConfig.rateLimit.jitterMs;
this.config.browser.dryRun = this.runtimeConfig.browser.dryRun;
// Notify listeners of config change
if (this.onConfigChange) {
this.onConfigChange(this.runtimeConfig);
}
this.logActivity('info', `Config updated via dashboard`);
this.pushLog('info', 'Configuration updated via dashboard', updates);
res.json({ success: true, config: this.runtimeConfig });
} catch (error) {
this.logger.error('Failed to update config:', error);
res.status(400).json({ error: String(error) });
}
});
// Serve dashboard for all other routes
this.app.get(/.*/, (req, res) => {
// SPA catch-all.
this.app.get(/.*/, (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
});
}
// ---------- public API used by the bootstrapper ----------
isRunning(): boolean {
return this.botRunning;
}
setChatService(chatService: ChatService): void {
this.chatService = chatService;
this.services.chat = chatService;
}
setMediaService(mediaService: MediaService): void {
this.mediaService = mediaService;
this.services.media = mediaService;
}
setMediaSyncService(mediaSyncService: MediaSyncService): void {
this.mediaSyncService = mediaSyncService;
this.services.mediaSync = mediaSyncService;
}
setResponseChecker(responseChecker: ResponseCheckerService): void {
this.responseChecker = responseChecker;
this.services.responseChecker = responseChecker;
}
setRateLimiter(rateLimiter: RateLimiter): void {
this.rateLimiter = rateLimiter;
this.services.rateLimiter = rateLimiter;
}
setAiClient(aiClient: OpenRouterClient): void {
this.aiClient = aiClient;
this.services.ai = aiClient;
}
setStrategySelector(strategySelector: StrategySelector): void {
this.strategySelector = strategySelector;
this.services.strategySelector = strategySelector;
}
setAutoReplyService(autoReplyService: AutoReplyService): void {
this.autoReplyService = autoReplyService;
}
private getStats(): BotStats {
const messages = this.messagesRepo.getRecent(1000);
const totalSent = messages.length;
const totalResponses = messages.filter((m) => m.gotResponse).length;
return {
isRunning: this.botRunning,
dryRunMode: this.config.browser.dryRun,
totalProcessed: this.getTotalProcessed(),
totalMessagesSent: totalSent,
totalResponses,
responseRate: totalSent > 0 ? Math.round((totalResponses / totalSent) * 100) : 0,
messagesThisHour: this.messagesThisHour,
maxMessagesPerHour: this.config.rateLimit.maxMessagesPerHour,
subreddits: this.config.reddit.subreddits,
targetGenders: this.config.reddit.targetGenders,
recentActivity: this.activityLog.slice(-10).reverse(),
};
}
private getTotalProcessed(): number {
// This is a simple count - in production you'd query the DB
return this.activityLog.filter((a) => a.type === 'sent' || a.type === 'skipped').length;
setBrowserClient(browserClient: BrowserClient): void {
this.services.browser = browserClient;
}
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
const item: ActivityItem = {
timestamp: Date.now(),
type,
message,
recipient,
imageUrl,
};
this.activityLog.push(item);
// Keep only last 200 items
if (this.activityLog.length > 200) {
this.activityLog = this.activityLog.slice(-200);
}
this.activity.push(type, message, recipient, imageUrl);
}
/**
* Push a log entry to all connected SSE clients
*/
pushLog(level: LogEntry['level'], message: string, data?: unknown): void {
const entry: LogEntry = {
timestamp: Date.now(),
level,
message,
data,
};
this.broadcaster.push(level, message, data);
}
this.realtimeLog.push(entry);
// Keep only last 500 log entries
if (this.realtimeLog.length > 500) {
this.realtimeLog = this.realtimeLog.slice(-500);
}
// Broadcast to all SSE clients
const eventData = JSON.stringify({ type: 'log', ...entry });
this.sseClients.forEach(client => {
try {
client.write(`data: ${eventData}\n\n`);
} catch {
// Client disconnected, will be cleaned up on close event
this.sseClients.delete(client);
}
});
/** Fan a sync-progress frame out over the existing log SSE stream. */
broadcastSyncProgress(progress: Record<string, unknown> | null): void {
this.broadcaster.broadcastEvent('sync-progress', { progress });
}
/**
* Get the current runtime configuration
*/
getRuntimeConfig(): RuntimeConfig {
return this.runtimeConfig;
}
/**
* Set a callback to be notified when config changes
*/
setOnConfigChange(callback: (config: RuntimeConfig) => void): void {
this.onConfigChange = callback;
}
@@ -771,21 +187,12 @@ export class WebServer {
start(): void {
this.server = this.app.listen(this.port, () => {
this.logger.info(`Web dashboard running at http://localhost:${this.port}`);
this.pushLog('info', `Web dashboard started on port ${this.port}`);
this.broadcaster.push('info', `Web dashboard started on port ${this.port}`);
});
}
stop(): void {
// Close all SSE connections
this.sseClients.forEach(client => {
try {
client.end();
} catch {
// Ignore errors when closing
}
});
this.sseClients.clear();
this.broadcaster.closeAll();
if (this.server) {
this.server.close();
this.server = null;