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:
1 parent
dfa0580859
commit
633bb0ec04
84 files changed
+19278
-3888
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user