Restructure chat service, web routes, and add test suite

Split monolithic chat service into modular structure with Matrix client
support, extract web server into per-resource route modules, introduce
bootstrap/main-loop separation, and add comprehensive vitest coverage
across services, repositories, and utilities. Adds project docs and
CLAUDE.md agent instructions.
This commit is contained in:
jjbrunton committed 2026-05-21 01:32:10 +01:00
1 parent dfa0580859
commit 633bb0ec04
84 files changed
+19278 -3888

No files matched your search

+297
View File
@@ -0,0 +1,297 @@
import type { Config } from './config/index.js';
import type { Logger } from './utils/logger.js';
import type Database from 'better-sqlite3';
import { createDatabase, runMigrations } from './database/client.js';
import {
PostsRepository,
MessagesRepository,
SuccessfulMessagesRepository,
ActivityLogRepository,
} 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,
extractMatrixCredentials,
loadCachedCredentials,
type TokenRefresher,
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';
export interface AppContainer {
config: Config;
logger: Logger;
db: Database.Database;
browserClient: BrowserClient;
messagePage: RedditMessagePage;
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> {
// 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);
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);
// 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,
);
// ---- Matrix chat backend (replaces the Playwright scraper) ----
// 1. Get credentials: prefer cached, fall back to a fresh sniff via the
// persistent browser session that we just verified is logged in.
let matrixCreds: MatrixCredentials | null = await loadCachedCredentials();
if (!matrixCreds) {
logger.info('No cached Matrix credentials — extracting via browser session...');
matrixCreds = await extractMatrixCredentials(browserClient.getContext()!, logger);
} else {
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
}
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, re-sniff via the running browser session
// and swap the new credentials into the chat service.
let chatService: ChatService;
const tokenRefresher: TokenRefresher = {
async refresh() {
const fresh = await extractMatrixCredentials(browserClient.getContext()!, logger);
const next = new MatrixClient(fresh, logger);
chatService.setMatrixClient(next);
},
};
// 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(
`https://api.reddit.com/api/user_data_by_account_ids?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;
},
};
chatService = new ChatService(
matrixClient,
logger,
successfulMessagesRepo,
mediaService,
tokenRefresher,
usernameResolver,
);
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
const rateLimiter = new RateLimiter(config, logger);
await chatService.loadCache();
// Lazy enrichment in the background — first sync may show t2 IDs briefly.
chatService.enrichUsernames().catch((err) => logger.debug('enrichUsernames failed:', err));
// Fire-and-forget startup sync: full if we've never backfilled, delta
// otherwise. Non-blocking so the rest of the bot can come up immediately;
// the dashboard's sync-status banner shows progress.
chatService
.runStartupSync()
.then(({ kind, chats }) => {
logger.info(`[chat] startup sync (${kind}) complete: ${chats} chats`);
})
.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,
);
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.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,
postsRepo,
messagesRepo,
successfulMessagesRepo,
redditApi,
aiClient,
monitorService,
processorService,
strategySelector,
responseChecker,
mediaService,
chatService,
mediaSyncService,
rateLimiter,
webServer,
};
}