import crypto from 'node:crypto'; import path from 'node:path'; import type Database from 'better-sqlite3'; import type { Config } from '../config/index.js'; import type { Logger } from '../utils/logger.js'; import { BrowserClient } from '../browser/client.js'; import { PostsRepository, MessagesRepository, SuccessfulMessagesRepository, DeliveryOutboxRepository, ConversationStateRepository, ConversationOutcomesRepository, RedditUsersRepository, ActivityLogRepository, } from '../database/repositories.js'; import { AccountContentRepository } from '../database/account-repositories.js'; import { RedditApiClient } from '../reddit/api.js'; import { OpenRouterClient } from '../ai/openrouter.js'; import { MonitorService } from './monitor.js'; import { ProcessorService, isSkip } from './processor.js'; import { ResponseCheckerService } from './response-checker.js'; import { StrategySelector } from './strategy-selector.js'; import { AccountAuthContext } from './account-auth-context.js'; import { ChatCache } from './chat/cache.js'; import { ChatService, MatrixClient, MatrixMediaDownloader, MatrixTokenRefresher, MatrixUsernameResolver, type UsernameResolver, } from './chat/index.js'; import { MediaService } from './media.js'; import { MediaSyncService } from './media-sync.js'; import { RateLimiter } from '../utils/rate-limiter.js'; import { RedditAccountRuntime, RedditAccountRuntimeFactory, accountBrowserProfileDir, type RedditAccountRuntimeResources, type VerifiedAccountSession, } from './reddit-account-runtime.js'; export interface ConcreteRuntimeFactoryOptions { config: Config; logger: Logger; db: Database.Database; isBotRunning?: () => boolean; } export function createConcreteRedditAccountRuntimeFactory( options: ConcreteRuntimeFactoryOptions, ): RedditAccountRuntimeFactory { return new RedditAccountRuntimeFactory((session) => buildRuntime(options, session)); } async function buildRuntime( { config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions, session: VerifiedAccountSession, ): Promise { const browserProfileDir = accountBrowserProfileDir( path.resolve(process.cwd(), config.browser.sessionDir), session.accountId, ); const accountConfig: Config = { ...config, browser: { ...config.browser, sessionDir: browserProfileDir }, web: { ...config.web, enabled: false }, }; const browserClient = new BrowserClient(accountConfig, logger); await browserClient.initialize(); try { await browserClient.importRedditCookies(session.cookies); const identity = await browserClient.getAuthenticatedIdentity(); const expectedRedditId = matrixRedditId(session.matrixUserId); if (!identity || !expectedRedditId || identity.accountId.toLowerCase().replace(/^t2_/, '') !== expectedRedditId) { throw new Error(`Stored Reddit cookies no longer verify for u/${session.redditUsername}`); } const matrixClient = new MatrixClient(session.matrixCredentials, logger); if (await matrixClient.whoami() !== session.matrixUserId) { throw new Error(`Stored Matrix session no longer verifies for u/${session.redditUsername}`); } const accountAuth = new AccountAuthContext(logger); accountAuth.markAuthenticated({ accountId: session.accountId, redditUsername: identity.username, matrixUserId: session.matrixUserId, }); accountAuth.setAuthenticator(async () => { const current = await browserClient.getAuthenticatedIdentity(); if (!current || matrixRedditId(session.matrixUserId) !== current.accountId.toLowerCase().replace(/^t2_/, '')) return null; return await matrixClient.whoami() === session.matrixUserId ? { accountId: session.accountId, redditUsername: current.username, matrixUserId: session.matrixUserId } : null; }); const postsRepo = new PostsRepository(db, session.accountId); const messagesRepo = new MessagesRepository(db, session.accountId); const successfulMessagesRepo = new SuccessfulMessagesRepository(db, session.accountId); const deliveryOutboxRepo = new DeliveryOutboxRepository(db, session.accountId); const conversationStateRepo = new ConversationStateRepository(db, session.accountId); const conversationOutcomesRepo = new ConversationOutcomesRepository(db, session.accountId); const redditUsersRepo = new RedditUsersRepository(db); const accountContentRepo = new AccountContentRepository(db); const activityLogRepo = new ActivityLogRepository(db, session.accountId); const redditApi = new RedditApiClient(accountConfig, logger); redditApi.setBrowserContext(browserClient.getContext()!); const chatPage = await browserClient.getContext()!.newPage(); const aiClient = new OpenRouterClient(accountConfig, logger); const monitorService = new MonitorService(redditApi, postsRepo, messagesRepo, accountConfig, logger); const processorService = new ProcessorService(redditApi, aiClient, accountConfig, logger); processorService.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger)); processorService.setChatStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger, 'chatStrategies')); const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger); const mediaService = new MediaService( mediaDownloader, logger, undefined, accountContentRepo, () => session.accountId, ); const usernameResolverImpl = new MatrixUsernameResolver(matrixClient); const usernameResolver: UsernameResolver = { resolveT2Ids: (ids) => usernameResolverImpl.resolveT2Ids(ids), resolveUsernameToT2: (username) => redditApi.resolveUsernameToT2(username), }; const chatCache = new ChatCache(logger, undefined, accountContentRepo, session.accountId); const chatService = new ChatService( matrixClient, logger, successfulMessagesRepo, mediaService, new MatrixTokenRefresher(), usernameResolver, chatCache, messagesRepo, redditUsersRepo, accountAuth, ); await chatService.loadCache(); const mediaSyncService = new MediaSyncService(chatService, logger, accountConfig.media.syncIntervalMs, accountAuth); const responseChecker = new ResponseCheckerService( chatPage, messagesRepo, successfulMessagesRepo, postsRepo, logger, accountAuth, conversationOutcomesRepo, ); const rateLimiter = new RateLimiter(accountConfig, logger, undefined, { sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since), }); await rateLimiter.initialize(); const resources: RedditAccountRuntimeResources = { browserProfileDir, browserClient, accountAuth, matrixClient, chatCache, redditApi, postsRepo, messagesRepo, successfulMessagesRepo, deliveryOutboxRepo, conversationStateRepo, conversationOutcomesRepo, redditUsersRepo, accountContentRepo, activityLogRepo, monitorService, processorService, responseChecker, chatService, mediaService, mediaSyncService, rateLimiter, }; const lifecycle = new AccountRuntimeLoop(resources, accountConfig, logger, isBotRunning); return new RedditAccountRuntime(session, resources, lifecycle); } catch (error) { await browserClient.close(); throw error; } } class AccountRuntimeLoop { private controller: AbortController | null = null; private loop: Promise | null = null; constructor( private readonly resources: RedditAccountRuntimeResources, private readonly config: Config, private readonly logger: Logger, private readonly isBotRunning: () => boolean, ) {} start(): void { if (this.loop) return; this.controller = new AbortController(); this.resources.mediaSyncService.start(); void this.resources.chatService.runStartupSync() .then(() => this.resources.chatService.enrichUsernames()) .catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error)); this.loop = this.run(this.controller.signal).finally(() => { this.loop = null; }); } async stop(): Promise { this.controller?.abort(); this.resources.mediaSyncService.stop(); await this.loop?.catch((error) => { if (!(error instanceof DOMException && error.name === 'AbortError')) throw error; }); await this.resources.browserClient.close(); } private async run(signal: AbortSignal): Promise { let nextResponseCheckAt = 0; const responseInterval = Math.max(this.config.reddit.pollIntervalMs * 5, 60_000); while (!signal.aborted) { try { if (!this.isBotRunning()) { await delay(1_000, signal); continue; } const auth = this.resources.accountAuth; if (!auth.isFullyAuthenticated() && !(await auth.ensureFullyAuthenticated())) { await delay(1_000, signal); continue; } if (Date.now() >= nextResponseCheckAt) { nextResponseCheckAt = Date.now() + responseInterval; await this.resources.responseChecker.checkForResponses(); } if (!this.resources.chatService.getInvitationCooldown()) { await this.processPosts(signal); } await delay(this.config.reddit.pollIntervalMs, signal); } catch (error) { if (signal.aborted) return; this.logger.error(`Account runtime loop failed for ${this.resources.accountAuth.getSnapshot().redditUsername}:`, error); await delay(60_000, signal); } } } private async processPosts(signal: AbortSignal): Promise { const { monitorService, postsRepo, messagesRepo, redditApi, processorService, rateLimiter, chatService, responseChecker } = this.resources; const posts = await monitorService.getUnprocessedPosts(); for (const post of posts) { if (signal.aborted) return; const subreddit = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === post.subreddit.toLowerCase()); const isChat = subreddit?.type === 'chat'; const imageUrl = isChat ? null : redditApi.getImageUrl(post); postsRepo.insert({ id: post.id, subreddit: post.subreddit, author: post.author, title: post.title, postUrl: redditApi.getPostUrl(post), imageUrl, processedAt: Date.now(), }); try { if ( this.config.reddit.filterSellers && (this.config.reddit.sellerKeywords.length > 0 || this.config.reddit.sellerDomains.length > 0) && await redditApi.isSellerProfile(post.author, this.config.reddit.sellerKeywords, this.config.reddit.sellerDomains) ) { postsRepo.setError(post.id, 'Seller profile detected'); this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (seller profile)`, recipient: post.author }); continue; } processorService.setSuccessfulExamples(responseChecker.getSuccessfulExamples(3, post.subreddit)); const processed = await processorService.processPost(post); if (isSkip(processed)) { postsRepo.setError(post.id, processed.skipReason); this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'skipped', message: `Skipped post by u/${post.author} (${processed.skipReason})`, recipient: post.author }); continue; } postsRepo.updateAnalysis(post.id, processed.imageAnalysis, processed.generatedMessage); await rateLimiter.throttle(async () => { const chat = await chatService.startConversation(post.author, processed.generatedMessage, post.author_fullname); if (!chat) return false; postsRepo.markMessageSent(post.id); messagesRepo.insert({ postId: post.id, recipient: post.author, subject: post.author, message: processed.generatedMessage, sentAt: Date.now(), strategyId: processed.strategyId, chatRoomId: chat.roomId, }); this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'sent', message: `Sent message to u/${post.author} (r/${post.subreddit})`, recipient: post.author, imageUrl: imageUrl ?? undefined, postTitle: post.title, postUrl: redditApi.getPostUrl(post), }); void chatService.refreshTopChats(5); return true; }, (success) => success, signal); } catch (error) { if (signal.aborted) return; postsRepo.setError(post.id, error instanceof Error ? error.message : String(error)); this.resources.activityLogRepo.insert({ timestamp: Date.now(), type: 'error', message: `Failed processing post by u/${post.author}`, recipient: post.author }); this.logger.error(`Account runtime failed processing post ${post.id}:`, error); } } } } function matrixRedditId(matrixUserId: string): string | null { return matrixUserId.match(/^@t2_([^:]+):reddit\.com$/i)?.[1]?.toLowerCase() ?? null; } function delay(ms: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(new DOMException('Operation aborted', 'AbortError')); return new Promise((resolve, reject) => { const onAbort = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); reject(new DOMException('Operation aborted', 'AbortError')); }; const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms); signal.addEventListener('abort', onAbort, { once: true }); }); }