import type { Config } from './config/index.js'; import { attachLogSink, type Logger } from './utils/logger.js'; import { LogBroadcaster } from './web/log-broadcaster.js'; import type Database from 'better-sqlite3'; import { createDatabase, runMigrations } from './database/client.js'; import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository, ActivityLogRepository, RedditUsersRepository, } 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, MatrixClient, MatrixMediaDownloader, MatrixTokenRefresher, type UsernameResolver, type MatrixCredentials, } from './services/chat/index.js'; import { MediaService } from './services/media.js'; import { MediaSyncService } from './services/media-sync.js'; import { RateLimiter } from './utils/rate-limiter.js'; import { fetchWithTimeout } from './utils/fetch-with-timeout.js'; import { WebServer } from './web/server.js'; import { AccountAuthContext, authenticateCurrentAccount, type AuthenticatedAccount, } from './services/account-auth-context.js'; export interface AppContainer { config: Config; logger: Logger; db: Database.Database; browserClient: BrowserClient; messagePage: RedditMessagePage; accountAuth: AccountAuthContext; postsRepo: PostsRepository; messagesRepo: MessagesRepository; successfulMessagesRepo: SuccessfulMessagesRepository; redditApi: RedditApiClient; aiClient: OpenRouterClient; monitorService: MonitorService; processorService: ProcessorService; strategySelector: StrategySelector; responseChecker: ResponseCheckerService; mediaService: MediaService; chatService: ChatService; mediaSyncService: MediaSyncService; rateLimiter: RateLimiter; webServer: WebServer | null; } /** * Builds and wires every service required for the bot. * * Performs: * 1. DB + repos * 2. Browser + login * 3. Three pages (compose / response-check / web-UI) * 4. All services * 5. Optional WebServer * * Returns a populated container. Caller owns lifecycle (start loop, handle * shutdown). */ export async function bootstrap(config: Config, logger: Logger): Promise { // Wire winston → dashboard log buffer immediately so the matrix-token // sniff and other early bootstrap diagnostics are visible in /api/logs. const broadcaster = new LogBroadcaster(); if (config.web.enabled) { attachLogSink(logger, broadcaster); } // Database const db = createDatabase(config); runMigrations(db); const postsRepo = new PostsRepository(db); const messagesRepo = new MessagesRepository(db); const successfulMessagesRepo = new SuccessfulMessagesRepository(db); const activityLogRepo = new ActivityLogRepository(db); const redditUsersRepo = new RedditUsersRepository(db); logger.info('Database initialized'); // Browser const browserClient = new BrowserClient(config, logger); await browserClient.initialize(); const page = await browserClient.getPage(); const messagePage = new RedditMessagePage(page, logger); messagePage.setDryRun(config.browser.dryRun); const accountAuth = new AccountAuthContext(logger); // Login verification. When the web dashboard is enabled we treat a missing // session as recoverable — the dashboard exposes a noVNC console so a human // can complete the login flow in the running headed browser. logger.info('Verifying Reddit login status...'); let loggedIn = await messagePage.isLoggedIn(); if (!loggedIn) { logger.warn('Session invalid or not logged in.'); if (config.reddit.username && config.reddit.password) { logger.info('Credentials found in config/env. Attempting automated login...'); loggedIn = await browserClient.login(); } if (!loggedIn) { if (config.web.enabled) { logger.warn('Not logged in. Open the dashboard and click "Log in to Reddit" to authenticate via the noVNC console.'); logger.warn('The bot will stay paused until a valid session is detected.'); } else { logger.error('Not logged in to Reddit and no recovery path (web dashboard disabled).'); logger.error('Run "npm run login" locally, OR set REDDIT_USERNAME and REDDIT_PASSWORD, OR enable the web dashboard.'); await browserClient.close(); process.exit(1); } } } if (loggedIn) { logger.info('Reddit login verified'); } // Reddit API client (shares browser context for auth) const redditApi = new RedditApiClient(config, logger); redditApi.setBrowserContext(browserClient.getContext()!); // Dedicated page for the response checker (background scraping that // doesn't conflict with the dashboard). const chatPage = await browserClient.getContext()!.newPage(); // AI + services 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 chatStrategySelector = new StrategySelector(config, messagesRepo, logger, 'chatStrategies'); processorService.setChatStrategySelector(chatStrategySelector); const responseChecker = new ResponseCheckerService( chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger, accountAuth, ); // ---- Matrix chat backend (replaces the Playwright scraper) ---- // A cached bearer token is not accepted as application authentication. // The current Chrome profile must yield a fresh token and Matrix identity // before the shared account context unlocks any bot behavior. const matrixCreds: MatrixCredentials = { homeserver: 'https://matrix.redditspace.com', accessToken: '', userId: '', capturedAt: Date.now(), }; const matrixClient = new MatrixClient(matrixCreds, logger); // 2. Build the media downloader + service on top of the Matrix client so // `mxc://` URIs resolve through the same auth + rate-limiter pipeline. const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger); const mediaService = new MediaService(mediaDownloader, logger); // 3. Token refresher — on 401, restore the persistent Reddit session when // possible, re-sniff once, and swap credentials into the chat service. // Concurrent callers share that refresh rather than opening extra pages. const credentialState: { latest: MatrixCredentials | null } = { latest: null }; const tokenRefresher = new MatrixTokenRefresher( browserClient, logger, (fresh) => { credentialState.latest = fresh; matrixClient.updateCredentials(fresh); }, ); accountAuth.setAuthenticator(() => authenticateCurrentAccount({ getRedditUsername: () => browserClient.getAuthenticatedUsername(), refreshMatrixCredentials: async () => { credentialState.latest = null; await tokenRefresher.refresh(); return credentialState.latest as MatrixCredentials | null; }, })); if (loggedIn) { logger.info('Verifying full Reddit and Matrix account authentication...'); await accountAuth.ensureFullyAuthenticated(); } else { accountAuth.invalidate('Interactive Reddit login is required'); } // 4. Username resolver — turns t2 IDs into real Reddit usernames so the // dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint. const usernameResolver: UsernameResolver = { async resolveT2Ids(ids) { if (ids.length === 0) return {}; const out: Record = {}; for (let i = 0; i < ids.length; i += 100) { const chunk = ids.slice(i, i + 100).join(','); try { const res = await fetchWithTimeout( // www.reddit.com serves this unauthenticated; the api.reddit.com // host 404s the same path without an OAuth token. `https://www.reddit.com/api/user_data_by_account_ids.json?ids=${encodeURIComponent(chunk)}`, { headers: { 'User-Agent': config.reddit.userAgent }, timeoutMs: 15_000 }, ); if (!res.ok) { logger.debug(`user_data_by_account_ids → ${res.status}`); continue; } const json = (await res.json()) as Record; for (const [t2, info] of Object.entries(json)) { if (info?.name) out[t2] = info.name; } } catch (err) { logger.debug('user_data_by_account_ids failed:', err); } } return out; }, }; const chatService = new ChatService( matrixClient, logger, successfulMessagesRepo, mediaService, tokenRefresher, usernameResolver, undefined, messagesRepo, redditUsersRepo, accountAuth, ); const mediaSyncService = new MediaSyncService( chatService, logger, config.media.syncIntervalMs, accountAuth, ); const rateLimiter = new RateLimiter(config, logger); await chatService.loadCache(); let startupSyncAccountId: string | null = null; let startupSyncInFlightAccountId: string | null = null; const startAuthenticatedAccount = async (account: AuthenticatedAccount): Promise => { if ( startupSyncAccountId === account.accountId || startupSyncInFlightAccountId === account.accountId ) return; startupSyncInFlightAccountId = account.accountId; try { const { kind, chats } = await chatService.runStartupSync(); await chatService.enrichUsernames(); if (accountAuth.isFullyAuthenticated()) { startupSyncAccountId = account.accountId; logger.info(`[chat] startup sync (${kind}) complete for ${account.redditUsername}: ${chats} chats`); } } finally { startupSyncInFlightAccountId = null; } }; accountAuth.onAuthenticated((account) => startAuthenticatedAccount(account)); const authenticatedAccount = accountAuth.getSnapshot(); if ( authenticatedAccount.status === 'authenticated' && authenticatedAccount.accountId && authenticatedAccount.redditUsername && authenticatedAccount.matrixUserId ) { void startAuthenticatedAccount({ accountId: authenticatedAccount.accountId, redditUsername: authenticatedAccount.redditUsername, matrixUserId: authenticatedAccount.matrixUserId, }).catch((err) => logger.error('[chat] startup sync failed:', err)); } let webServer: WebServer | null = null; if (config.web.enabled) { webServer = new WebServer( config.web.port, config, postsRepo, messagesRepo, successfulMessagesRepo, activityLogRepo, logger, broadcaster, ); webServer.setChatService(chatService); // Stream sync-progress frames over the dashboard's SSE channel so the // full-sync UI can show live phase + per-room progress. chatService.setProgressListener((progress) => { webServer!.broadcastSyncProgress(progress as Record | null); }); chatService.setNewReplyListener(({ username, replySnippet }) => { webServer!.logActivity( 'response', `Reply from u/${username}: ${replySnippet}`, username, ); webServer!.pushLog('info', `💬 Reply from u/${username}`); }); webServer.setMediaService(mediaService); webServer.setMediaSyncService(mediaSyncService); webServer.setResponseChecker(responseChecker); webServer.setRateLimiter(rateLimiter); webServer.setAiClient(aiClient); webServer.setStrategySelector(strategySelector); webServer.setBrowserClient(browserClient); webServer.setAccountAuthContext(accountAuth); webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard')); } // Start background services if (config.media.syncEnabled) mediaSyncService.start(); if (webServer) webServer.start(); // Prime processor with successful examples already on disk. const existingExamples = responseChecker.getSuccessfulExamples(3); processorService.setSuccessfulExamples(existingExamples); const successCount = responseChecker.getSuccessCount(); if (successCount > 0) { logger.info(`Loaded ${successCount} successful message(s) from history`); } return { config, logger, db, browserClient, messagePage, accountAuth, postsRepo, messagesRepo, successfulMessagesRepo, redditApi, aiClient, monitorService, processorService, strategySelector, responseChecker, mediaService, chatService, mediaSyncService, rateLimiter, webServer, }; }