feat: support concurrent Reddit accounts
This commit is contained in:
1 parent
d819a0f1e6
commit
16a31335d0
51 files changed
+3241
-250
No files matched your search
@@ -0,0 +1,290 @@
|
||||
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<RedditAccountRuntime> {
|
||||
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<void> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user