252 lines
8.8 KiB
TypeScript
252 lines
8.8 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,
|
|
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 { 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';
|
|
|
|
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(),
|
|
) {
|
|
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,
|
|
},
|
|
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(createDashboardSessionRouter(dashboardToken));
|
|
this.app.use(createDashboardAuth(dashboardToken));
|
|
|
|
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', 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));
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
logActivity(type: ActivityItem['type'], message: string, recipient?: string, imageUrl?: string): void {
|
|
this.activity.push(type, message, recipient, imageUrl);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|