Files
RedditBot/src/bootstrap.ts
T

370 lines
13 KiB
TypeScript

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';
import { ProcessNativeLoginHandoff } from './services/native-login-handoff.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<AppContainer> {
// 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.web.enabled && 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<string, string> = {};
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<string, { name?: string }>;
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<void> => {
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<string, unknown> | 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.setNativeLoginHandoff(new ProcessNativeLoginHandoff());
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,
};
}