291 lines
13 KiB
TypeScript
291 lines
13 KiB
TypeScript
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 });
|
|
});
|
|
}
|