import express from 'express'; import path from 'path'; import type { Server } from 'http'; import type { AddressInfo } from 'node:net'; import type { Logger } from '../utils/logger.js'; import type { Config } from '../config/index.js'; import type { PostsRepository, MessagesRepository, SuccessfulMessagesRepository, ConversationStateRepository, 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 { StrategySelector } from '../services/strategy-selector.js'; import type { BrowserClient } from '../browser/client.js'; import type { RedditSessionImporter } from '../services/reddit-session-importer.js'; import type { AccountAuthContext } from '../services/account-auth-context.js'; import type { ConfigStore } from '../config/persistence.js'; import type { RedditAccountsService } from '../services/reddit-accounts.js'; import type { RedditAccountRuntimeManager } from '../services/reddit-account-runtime.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'; import { createDashboardAuth, createDashboardSessionRouter } from './auth.js'; import { resolveDashboardAssets } from './dashboard-assets.js'; import { createAgentRouter, createHealthRouter } from './routes/agent.js'; import { createAttentionRouter } from './routes/attention.js'; import type { AiUsageRepository } from '../database/ai-usage-repository.js'; 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 activity: ActivityLog; private broadcaster: LogBroadcaster; private botRunning = false; private messagesThisHour = 0; 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 activityLogRepo: ActivityLogRepository, private logger: Logger, broadcaster?: LogBroadcaster, private workspaceDirectory = process.cwd(), private aiUsageRepo?: AiUsageRepository, ) { this.broadcaster = broadcaster ?? new LogBroadcaster(); 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 }, reddit: { prioritizeRecentPosts: config.reddit.prioritizeRecentPosts, recentWindowMinutes: config.reddit.recentWindowMinutes, }, }; 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, aiUsage: this.aiUsageRepo, }, services: this.services, getBotRunning: () => this.botRunning, setBotRunning: (running) => { this.botRunning = running; }, getMessagesThisHour: () => this.messagesThisHour, getOnConfigChange: () => this.onConfigChange, }; } private setupRoutes(): void { const dashboardToken = this.config.web.authToken; if (!dashboardToken) { throw new Error('Web dashboard requires web.authToken or WEB_DASHBOARD_TOKEN'); } this.app.use(express.json({ limit: '64kb' })); this.app.use(createHealthRouter()); this.app.use(createDashboardSessionRouter(dashboardToken)); this.app.use(createDashboardAuth(dashboardToken, { readToken: this.config.web.agentReadToken, controlToken: this.config.web.agentControlToken, })); const dashboardAssets = resolveDashboardAssets(this.workspaceDirectory); // index.html serves the entire inline JS — never cache it. this.app.use((req, res, next) => { if (req.path === '/' || req.path === '/index.html') { res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); return res.sendFile(dashboardAssets.indexFile); } next(); }); // Media can contain private chat attachments, so it is intentionally behind // the same dashboard authentication middleware as the API and UI. this.app.use('/media', express.static(path.join(this.workspaceDirectory, 'data', 'media'), { index: false })); this.app.use(express.static(dashboardAssets.directory, { index: false })); const ctx = this.context(); this.app.use('/api', createAgentRouter(ctx)); this.app.use('/api', createStatsRouter(ctx)); this.app.use('/api', createAttentionRouter(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)); if (this.config.web.debugEnabled) { 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)); this.app.use('/api', (_req, res) => { res.status(404).json({ error: 'API endpoint not found' }); }); // SPA catch-all. this.app.get(/.*/, (_req, res) => { res.sendFile(dashboardAssets.indexFile); }); } // ---------- public API used by the bootstrapper ---------- isRunning(): boolean { return this.botRunning; } get dashboardEvents(): LogBroadcaster['dashboardEvents'] { return this.broadcaster.dashboardEvents; } setChatService(chatService: ChatService): void { this.services.chat = chatService; } setMediaService(mediaService: MediaService): void { this.services.media = mediaService; } setMediaSyncService(mediaSyncService: MediaSyncService): void { this.services.mediaSync = mediaSyncService; } setResponseChecker(responseChecker: ResponseCheckerService): void { this.services.responseChecker = responseChecker; } setRateLimiter(rateLimiter: RateLimiter): void { this.services.rateLimiter = rateLimiter; } setAiClient(aiClient: OpenRouterClient): void { this.services.ai = aiClient; } setStrategySelector(strategySelector: StrategySelector): void { this.services.strategySelector = strategySelector; } setBrowserClient(browserClient: BrowserClient): void { this.services.browser = browserClient; } setSessionImporter(sessionImporter: RedditSessionImporter): void { this.services.sessionImporter = sessionImporter; } setAccountAuth(accountAuth: AccountAuthContext): void { this.services.accountAuth = accountAuth; } setConversationStateRepository(conversationState: ConversationStateRepository): void { this.services.conversationState = conversationState; } setConfigStore(configStore: ConfigStore): void { this.services.configStore = configStore; } setRedditAccountsService(accounts: RedditAccountsService): void { this.services.redditAccounts = accounts; } setAccountRuntimeManager(runtimes: RedditAccountRuntimeManager): void { this.services.accountRuntimes = runtimes; } logActivity( type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string, origin?: { postTitle: string; postUrl: string }, ): void { this.activity.push(type, message, recipient, imageUrl, origin); } pushLog(level: LogEntry['level'], message: string, data?: unknown): void { this.broadcaster.push(level, message, data); } /** Fan a sync-progress frame out over the existing log SSE stream. */ broadcastSyncProgress(progress: Record | null): void { this.broadcaster.broadcastEvent('sync-progress', { progress }); } getRuntimeConfig(): RuntimeConfig { return this.runtimeConfig; } setOnConfigChange(callback: (config: RuntimeConfig) => void): void { this.onConfigChange = callback; } updateStats(messagesThisHour: number): void { this.messagesThisHour = messagesThisHour; } start(): Promise { return new Promise((resolve, reject) => { const server = this.app.listen(this.port); this.server = server; server.once('error', reject); server.once('listening', () => { const address = server.address() as AddressInfo | null; const listeningPort = address?.port ?? this.port; this.logger.info(`Web dashboard running at http://localhost:${listeningPort}`); this.broadcaster.push('info', `Web dashboard started on port ${listeningPort}`); resolve(listeningPort); }); }); } stop(): void { this.broadcaster.closeAll(); if (this.server) { this.server.close(); this.server = null; } } /** Exposed for health probes and integration tests when configured with port 0. */ getListeningPort(): number | null { const address = this.server?.address(); return address && typeof address !== 'string' ? (address as AddressInfo).port : null; } }