Files
RedditBot/src/web/server.ts
T

303 lines
11 KiB
TypeScript

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<string, unknown> | 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<number> {
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;
}
}