feat: support concurrent Reddit accounts

This commit is contained in:
jjbrunton committed 2026-08-08 19:00:38 +01:00
1 parent d819a0f1e6
commit 16a31335d0
51 files changed
+3241 -250

No files matched your search

+70 -2
View File
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
import path from 'path';
import type { Logger } from '../../utils/logger.js';
import type { ActiveChat, ChatCacheFile, InvitationCooldown } from './types.js';
import type { AccountContentRepository } from '../../database/account-repositories.js';
export interface SyncMarkers {
nextBatch: string | null;
@@ -24,8 +25,13 @@ export class ChatCache {
private lastDeltaSyncAt: number | null = null;
private cooldown: InvitationCooldown | null = null;
constructor(private logger: Logger, filePath?: string) {
if (!filePath && process.env.NODE_ENV === 'test') {
constructor(
private logger: Logger,
filePath?: string,
private readonly databaseStore?: AccountContentRepository,
private accountId: string | null = null,
) {
if (!filePath && !databaseStore && process.env.NODE_ENV === 'test') {
// Without this guard a test that constructs a ChatCache (or a
// ChatService that builds one) would silently write to the prod
// ./data/chat-cache.json — concurrent test runs have already torn
@@ -37,6 +43,18 @@ export class ChatCache {
this.filePath = filePath ?? path.join(process.cwd(), 'data', 'chat-cache.json');
}
async switchAccount(accountId: string): Promise<void> {
if (this.accountId === accountId) return;
this.accountId = accountId;
this.chats = [];
this.lastFetchTime = 0;
this.nextBatch = null;
this.lastFullSyncAt = null;
this.lastDeltaSyncAt = null;
this.cooldown = null;
await this.load();
}
get(): ActiveChat[] {
return this.chats;
}
@@ -80,6 +98,20 @@ export class ChatCache {
}
async load(): Promise<void> {
if (this.databaseStore && this.accountId) {
const rows = this.databaseStore.listConversations(this.accountId);
this.chats = rows.flatMap((row) => {
try { return [JSON.parse(row.payloadJson) as ActiveChat]; } catch { return []; }
});
const state = this.databaseStore.getSyncState(this.accountId);
this.nextBatch = state?.nextBatch ?? null;
this.lastFullSyncAt = state?.lastFullSyncAt ?? null;
this.lastDeltaSyncAt = state?.lastDeltaSyncAt ?? null;
try { this.cooldown = state?.cooldownJson ? JSON.parse(state.cooldownJson) as InvitationCooldown : null; } catch { this.cooldown = null; }
this.lastFetchTime = Date.now();
this.logger.info(`Loaded ${this.chats.length} account-owned chats from the database`);
return;
}
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
const parsed = JSON.parse(raw) as ChatCacheFile;
@@ -115,6 +147,10 @@ export class ChatCache {
* sync with `chats: []` and null markers.
*/
async persistCooldown(): Promise<void> {
if (this.databaseStore && this.accountId) {
this.persistDatabaseState();
return;
}
let existing: ChatCacheFile | null = null;
try {
const raw = await fs.readFile(this.filePath, 'utf-8');
@@ -143,6 +179,27 @@ export class ChatCache {
}
async save(chats: ActiveChat[] = this.chats): Promise<void> {
if (this.databaseStore && this.accountId) {
const retained = new Set<string>();
for (const chat of chats) {
const conversationId = chat.roomId ?? chat.username;
retained.add(conversationId);
this.databaseStore.upsertConversation({
accountId: this.accountId,
conversationId,
username: chat.username,
roomName: chat.roomName ?? null,
payloadJson: JSON.stringify(chat),
});
}
for (const existing of this.databaseStore.listConversations(this.accountId)) {
if (!retained.has(existing.conversationId)) {
this.databaseStore.deleteConversation(this.accountId, existing.conversationId);
}
}
this.persistDatabaseState();
return;
}
const payload: ChatCacheFile = {
version: 1,
savedAt: Date.now(),
@@ -159,6 +216,17 @@ export class ChatCache {
}
}
private persistDatabaseState(): void {
if (!this.databaseStore || !this.accountId) return;
this.databaseStore.saveSyncState({
accountId: this.accountId,
nextBatch: this.nextBatch,
lastFullSyncAt: this.lastFullSyncAt,
lastDeltaSyncAt: this.lastDeltaSyncAt,
cooldownJson: this.cooldown ? JSON.stringify(this.cooldown) : null,
});
}
/**
* Write the cache file via a temp+rename so a crashed/interrupted/concurrent
* writer can never leave the on-disk file half-written. rename(2) is atomic
+9
View File
@@ -256,6 +256,15 @@ export class ChatService {
}
}
async switchAccount(accountId: string): Promise<void> {
await this.enqueue(async () => {
await this.cache.switchAccount(accountId);
this.t2ToUsername.clear();
this.unresolvableRooms.clear();
this.invitationCooldown = this.cache.getCooldown();
});
}
async saveCacheExternal(): Promise<void> {
await this.cache.save();
}
+59 -7
View File
@@ -4,6 +4,7 @@ import crypto from 'crypto';
import { z } from 'zod';
import type { Logger } from '../utils/logger.js';
import type { ActiveChat, ChatMedia, ChatMessage } from './chat.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
export interface MediaDownloadResult {
buffer: Buffer;
@@ -15,6 +16,7 @@ export interface MediaDownloader {
}
export interface MediaRecord {
accountId?: string;
id: string;
chatUsername: string | null;
chatRoomId: string | null;
@@ -59,13 +61,16 @@ export class MediaService {
private downloader: MediaDownloader,
private logger: Logger,
mediaRoot?: string,
private readonly databaseStore?: AccountContentRepository,
private readonly getAccountId: () => string | null = () => null,
) {
this.mediaRoot = mediaRoot ?? path.join(process.cwd(), 'data', 'media');
this.indexPath = path.join(this.mediaRoot, 'index.json');
}
async attachMedia(chat: ActiveChat, messages: ScrapedChatMessage[]): Promise<ChatMessage[]> {
await this.loadIndex();
const databaseBacked = Boolean(this.databaseStore && this.getAccountId());
if (!databaseBacked) await this.loadIndex();
const updated: ChatMessage[] = [];
for (const message of messages) {
@@ -79,11 +84,26 @@ export class MediaService {
});
}
await this.saveIndex();
if (!databaseBacked) await this.saveIndex();
return updated;
}
async getMediaItems(): Promise<MediaRecord[]> {
const accountId = this.getAccountId();
if (this.databaseStore && accountId) {
return this.databaseStore.listMedia(accountId).map((item) => ({
accountId: item.accountId,
id: item.mediaId,
chatUsername: item.chatUsername,
chatRoomId: item.chatRoomId,
sender: item.sender,
timestamp: item.timestamp,
originalUrl: item.originalUrl,
fileName: item.fileName,
relativePath: item.relativePath,
createdAt: item.createdAt,
}));
}
await this.loadIndex();
return (this.index?.items || []).slice().sort((a, b) => b.timestamp - a.timestamp);
}
@@ -133,11 +153,23 @@ export class MediaService {
// Try the full-resolution URL first, fall back to original
const fullUrl = this.resolveFullMediaUrl(url);
const id = crypto.createHash('sha1').update(fullUrl).digest('hex');
const accountId = this.getAccountId();
const id = crypto.createHash('sha1').update(`${accountId ?? 'legacy'}\0${fullUrl}`).digest('hex');
if (this.databaseStore && accountId) {
const existingRecord = this.databaseStore.getMedia(accountId, id);
if (existingRecord) {
try {
await fs.access(path.join(this.mediaRoot, existingRecord.relativePath));
return { ...existingRecord, id: existingRecord.mediaId };
} catch {
this.databaseStore.deleteMedia(accountId, id);
}
}
}
const existing = this.index?.items.find((item) => item.id === id);
if (existing) {
// Verify the file still exists on disk
const existingPath = path.join(this.mediaRoot, existing.fileName);
const existingPath = path.join(this.mediaRoot, existing.relativePath);
try {
await fs.access(existingPath);
return existing;
@@ -165,13 +197,17 @@ export class MediaService {
}
const fileName = `${id}${extension}`;
const relativePath = fileName;
const fullPath = path.join(this.mediaRoot, fileName);
const accountDirectory = accountId
? crypto.createHash('sha256').update(accountId).digest('hex').slice(0, 16)
: '';
const relativePath = accountDirectory ? path.join(accountDirectory, fileName) : fileName;
const fullPath = path.join(this.mediaRoot, relativePath);
await fs.mkdir(this.mediaRoot, { recursive: true });
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, download.buffer);
const record: MediaRecord = {
accountId: accountId ?? undefined,
id,
chatUsername: chat.username || null,
chatRoomId: chat.roomId || null,
@@ -183,6 +219,22 @@ export class MediaService {
createdAt: Date.now(),
};
if (this.databaseStore && accountId) {
this.databaseStore.upsertMedia({
accountId,
mediaId: id,
chatUsername: record.chatUsername,
chatRoomId: record.chatRoomId,
sender: record.sender,
timestamp: record.timestamp,
originalUrl: record.originalUrl,
fileName: record.fileName,
relativePath: record.relativePath,
createdAt: record.createdAt,
});
return record;
}
if (!this.index) {
this.index = { version: 1, savedAt: Date.now(), items: [record] };
} else {
@@ -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 });
});
}
+378
View File
@@ -0,0 +1,378 @@
import path from 'node:path';
import type { Cookie } from 'playwright';
import { z } from 'zod';
import type { BrowserClient } from '../browser/client.js';
import type {
RedditAccountPrivateRecord,
RedditAccountsRepository,
AccountContentRepository,
} from '../database/account-repositories.js';
import type {
ConversationOutcomesRepository,
ActivityLogRepository,
ConversationStateRepository,
DeliveryOutboxRepository,
MessagesRepository,
PostsRepository,
RedditUsersRepository,
SuccessfulMessagesRepository,
} from '../database/repositories.js';
import type { RedditApiClient } from '../reddit/api.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import type { AccountAuthContext } from './account-auth-context.js';
import type { ChatCache } from './chat/cache.js';
import type { MatrixClient } from './chat/matrix/client.js';
import type { MatrixCredentials } from './chat/matrix/types.js';
import type { ChatService } from './chat/service.js';
import type { MediaService } from './media.js';
import type { MediaSyncService } from './media-sync.js';
import type { MonitorService } from './monitor.js';
import type { ProcessorService } from './processor.js';
import type { ResponseCheckerService } from './response-checker.js';
/** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */
export interface VerifiedAccountSession {
accountId: string;
redditUsername: string;
matrixUserId: string;
cookies: Cookie[];
matrixCredentials: MatrixCredentials;
updatedAt: number;
}
/**
* The complete set of stateful dependencies owned by one account runtime.
*
* Nothing in this object may be shared with another account runtime except the
* underlying SQLite connection and stateless configuration/AI clients. In
* particular, BrowserClient, AccountAuthContext, ChatCache, MatrixClient,
* RedditApiClient and RateLimiter are intentionally per-account instances.
*/
export interface RedditAccountRuntimeResources {
browserProfileDir: string;
browserClient: BrowserClient;
accountAuth: AccountAuthContext;
matrixClient: MatrixClient;
chatCache: ChatCache;
redditApi: RedditApiClient;
postsRepo: PostsRepository;
messagesRepo: MessagesRepository;
successfulMessagesRepo: SuccessfulMessagesRepository;
deliveryOutboxRepo: DeliveryOutboxRepository;
conversationStateRepo: ConversationStateRepository;
conversationOutcomesRepo: ConversationOutcomesRepository;
redditUsersRepo: RedditUsersRepository;
accountContentRepo: AccountContentRepository;
activityLogRepo: ActivityLogRepository;
monitorService: MonitorService;
processorService: ProcessorService;
responseChecker: ResponseCheckerService;
chatService: ChatService;
mediaService: MediaService;
mediaSyncService: MediaSyncService;
rateLimiter: RateLimiter;
}
export interface RedditAccountRuntimeLifecycle {
/** Start this account's main loop, response schedule and media sync. */
start(): void | Promise<void>;
/** Stop and await this account's schedules/loop, then close its browser. */
stop(): void | Promise<void>;
}
export type RedditAccountRuntimeState =
| 'created'
| 'starting'
| 'running'
| 'stopping'
| 'stopped'
| 'failed';
export interface RedditAccountRuntimeSnapshot {
accountId: string;
redditUsername: string;
matrixUserId: string;
state: RedditAccountRuntimeState;
updatedAt: number;
failure: string | null;
}
/**
* One immutable Reddit identity and every mutable service associated with it.
* There is deliberately no switchAccount method: a session refresh replaces
* the matching runtime instead of mutating shared live state.
*/
export class RedditAccountRuntime {
private state: RedditAccountRuntimeState = 'created';
private failure: string | null = null;
private transition: Promise<void> = Promise.resolve();
constructor(
readonly session: VerifiedAccountSession,
readonly resources: RedditAccountRuntimeResources,
private readonly lifecycle: RedditAccountRuntimeLifecycle,
) {
if (session.accountId !== session.matrixUserId) {
throw new Error('Runtime account ID must match the verified Matrix user ID');
}
if (!session.accountId || !session.redditUsername) {
throw new Error('Runtime requires a verified Reddit and Matrix identity');
}
}
start(): Promise<void> {
return this.enqueue(async () => {
if (this.state === 'running') return;
this.state = 'starting';
this.failure = null;
try {
await this.lifecycle.start();
this.state = 'running';
} catch (error) {
this.state = 'failed';
this.failure = errorMessage(error);
try {
await this.lifecycle.stop();
} catch (cleanupError) {
throw new AggregateError([error, cleanupError], 'Account runtime failed to start and clean up');
}
throw error;
}
});
}
stop(): Promise<void> {
return this.enqueue(async () => {
if (this.state === 'created' || this.state === 'stopped') {
this.state = 'stopped';
return;
}
this.state = 'stopping';
try {
await this.lifecycle.stop();
this.state = 'stopped';
} catch (error) {
this.state = 'failed';
this.failure = errorMessage(error);
throw error;
}
});
}
snapshot(): RedditAccountRuntimeSnapshot {
return {
accountId: this.session.accountId,
redditUsername: this.session.redditUsername,
matrixUserId: this.session.matrixUserId,
state: this.state,
updatedAt: this.session.updatedAt,
failure: this.failure,
};
}
private enqueue(operation: () => Promise<void>): Promise<void> {
const result = this.transition.then(operation, operation);
this.transition = result.catch(() => undefined);
return result;
}
}
export type RedditAccountRuntimeBuilder = (
session: VerifiedAccountSession,
) => Promise<RedditAccountRuntime>;
/** Narrow factory wrapper used as the bootstrap integration seam. */
export class RedditAccountRuntimeFactory {
constructor(private readonly buildRuntime: RedditAccountRuntimeBuilder) {}
async create(record: RedditAccountPrivateRecord): Promise<RedditAccountRuntime> {
return this.buildRuntime(parseStoredSession(record));
}
async createSession(session: VerifiedAccountSession): Promise<RedditAccountRuntime> {
return this.buildRuntime(session);
}
}
export interface RedditAccountRuntimeManagerSnapshot {
runtimes: RedditAccountRuntimeSnapshot[];
}
/**
* Owns all enabled account runtimes. Work for different account IDs runs in
* parallel; operations for the same ID are serialized to avoid duplicate
* loops and browser-profile contention.
*/
export class RedditAccountRuntimeManager {
private readonly runtimes = new Map<string, RedditAccountRuntime>();
private readonly accountOperations = new Map<string, Promise<void>>();
constructor(
private readonly accounts: Pick<RedditAccountsRepository, 'listPublic' | 'get'>,
private readonly factory: RedditAccountRuntimeFactory,
) {}
async startEnabled(): Promise<RedditAccountRuntimeManagerSnapshot> {
const enabled = this.accounts.listPublic().filter(isEnabledAccount);
await Promise.all(enabled.map((account) => this.start(account.accountId)));
return this.snapshot();
}
start(accountId: string): Promise<void> {
return this.forAccount(accountId, async () => {
const existing = this.runtimes.get(accountId);
if (existing?.snapshot().state === 'running') return;
const account = this.requireEnabledAccount(accountId);
const runtime = existing ?? await this.factory.create(account);
await runtime.start();
this.runtimes.set(accountId, runtime);
});
}
/**
* Rebuild only the refreshed account. Other account loops are untouched.
* The old runtime is stopped before concrete construction because a builder
* may open the account's persistent browser profile immediately.
*/
replace(accountId: string): Promise<void> {
return this.forAccount(accountId, async () => {
const account = this.requireEnabledAccount(accountId);
const previous = this.runtimes.get(accountId);
if (previous) await previous.stop();
try {
// Concrete construction opens the account's persistent browser
// profile, so the prior owner must be closed before factory.create.
const replacement = await this.factory.create(account);
await replacement.start();
this.runtimes.set(accountId, replacement);
} catch (error) {
if (previous) {
try {
const restored = await this.factory.createSession(previous.session);
await restored.start();
this.runtimes.set(accountId, restored);
} catch (rollbackError) {
this.runtimes.delete(accountId);
throw new AggregateError(
[error, rollbackError],
`Could not replace or restore Reddit account runtime ${accountId}`,
);
}
} else {
this.runtimes.delete(accountId);
}
throw error;
}
});
}
stop(accountId: string): Promise<void> {
return this.forAccount(accountId, async () => {
const runtime = this.runtimes.get(accountId);
if (!runtime) return;
await runtime.stop();
this.runtimes.delete(accountId);
});
}
async stopAll(): Promise<void> {
await Promise.all([...this.runtimes.keys()].map((accountId) => this.stop(accountId)));
}
get(accountId: string): RedditAccountRuntime | null {
return this.runtimes.get(accountId) ?? null;
}
list(): RedditAccountRuntimeSnapshot[] {
return this.snapshot().runtimes;
}
snapshot(): RedditAccountRuntimeManagerSnapshot {
return {
runtimes: [...this.runtimes.values()]
.map((runtime) => runtime.snapshot())
.sort((left, right) => left.redditUsername.localeCompare(right.redditUsername)),
};
}
private requireEnabledAccount(accountId: string): RedditAccountPrivateRecord {
const account = this.accounts.get(accountId);
if (!account || !isEnabledAccount(account)) {
throw new Error(`Reddit account ${accountId} is not enabled with a verified session`);
}
return account;
}
private forAccount(accountId: string, operation: () => Promise<void>): Promise<void> {
const previous = this.accountOperations.get(accountId) ?? Promise.resolve();
const result = previous.then(operation, operation);
const settled = result.catch(() => undefined).finally(() => {
if (this.accountOperations.get(accountId) === settled) {
this.accountOperations.delete(accountId);
}
});
this.accountOperations.set(accountId, settled);
return result;
}
}
/**
* Deterministic, filesystem-safe profile path for one immutable account ID.
* Bootstrap should clone config and set browser.sessionDir to this value
* before constructing that account's BrowserClient.
*/
export function accountBrowserProfileDir(baseSessionDir: string, accountId: string): string {
if (!accountId) throw new Error('Browser profile requires an account ID');
const encodedAccountId = Buffer.from(accountId, 'utf8').toString('base64url');
return path.join(baseSessionDir, 'accounts', encodedAccountId);
}
function parseStoredSession(record: RedditAccountPrivateRecord): VerifiedAccountSession {
const cookies = StoredCookiesSchema.parse(JSON.parse(record.cookiesJson)) as Cookie[];
const matrixCredentials = StoredMatrixCredentialsSchema.parse(
JSON.parse(record.matrixCredentialsJson),
) as MatrixCredentials;
if (record.matrixUserId !== record.accountId || matrixCredentials.userId !== record.accountId) {
throw new Error('Stored Reddit account and Matrix identities do not match');
}
return {
accountId: record.accountId,
redditUsername: record.redditUsername,
matrixUserId: record.accountId,
cookies,
matrixCredentials,
updatedAt: record.updatedAt,
};
}
const StoredCookiesSchema = z.array(z.object({
name: z.string().min(1),
value: z.string(),
domain: z.string().optional(),
path: z.string().optional(),
expires: z.number().optional(),
httpOnly: z.boolean().optional(),
secure: z.boolean().optional(),
sameSite: z.enum(['Strict', 'Lax', 'None']).optional(),
url: z.string().url().optional(),
}));
const StoredMatrixCredentialsSchema = z.object({
homeserver: z.string().url(),
accessToken: z.string().min(1),
userId: z.string().min(1),
capturedAt: z.number(),
});
function isEnabledAccount(account: {
status: string;
isActive: boolean;
}): boolean {
return account.status === 'verified' && account.isActive;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+96
View File
@@ -0,0 +1,96 @@
import { z } from 'zod';
import type { Cookie } from 'playwright';
import type { RedditAccountsRepository, RedditAccountPublicRecord } from '../database/account-repositories.js';
import type { AccountAuthContext } from './account-auth-context.js';
import type { RedditSessionImporter } from './reddit-session-importer.js';
import type { MatrixCredentials } from './chat/matrix/types.js';
import type { RedditAccountRuntimeManager } from './reddit-account-runtime.js';
import type { BrowserClient } from '../browser/client.js';
const StoredCookiesSchema = z.array(z.object({
name: z.string(), value: z.string(), domain: z.string(), path: z.string(),
expires: z.number(), httpOnly: z.boolean(), secure: z.boolean(),
sameSite: z.enum(['Strict', 'Lax', 'None']),
}));
const StoredMatrixCredentialsSchema = z.object({
homeserver: z.string().url(),
accessToken: z.string().min(1),
userId: z.string().min(1),
capturedAt: z.number(),
});
export interface RedditAccountSummary {
accountId: string;
redditUsername: string;
matrixUserId: string;
isRunning: boolean;
status: 'authenticated' | 'login-required';
createdAt: number;
updatedAt: number;
lastImportedAt: number;
}
export class RedditAccountsService {
constructor(
private readonly accounts: RedditAccountsRepository,
private readonly importer: RedditSessionImporter,
private readonly auth: AccountAuthContext,
private readonly runtimes?: RedditAccountRuntimeManager,
private readonly verificationBrowser?: BrowserClient,
) {}
list(): RedditAccountSummary[] {
return this.accounts.listPublic()
.filter((account) => account.status !== 'legacy-unassigned')
.map((account) => this.toSummary(account));
}
get(accountId: string): RedditAccountSummary | null {
return this.list().find((account) => account.accountId === accountId) ?? null;
}
async activate(accountId: string): Promise<RedditAccountSummary> {
const stored = this.accounts.get(accountId);
if (!stored || stored.status === 'legacy-unassigned') {
throw new Error('Reddit account not found');
}
const cookies = StoredCookiesSchema.parse(JSON.parse(stored.cookiesJson)) as Cookie[];
const credentials = StoredMatrixCredentialsSchema.parse(
JSON.parse(stored.matrixCredentialsJson),
) as MatrixCredentials;
return this.importSession(cookies, credentials);
}
async importSession(cookies: Cookie[], credentials: MatrixCredentials): Promise<RedditAccountSummary> {
try {
const verified = await this.importer.import(cookies, credentials);
if (this.runtimes) {
const existing = this.runtimes.get(verified.accountId);
if (existing) await this.runtimes.replace(verified.accountId);
else await this.runtimes.start(verified.accountId);
}
const account = this.get(verified.accountId);
if (!account) throw new Error('Activated account was not persisted');
return account;
} finally {
await this.verificationBrowser?.close();
}
}
private toSummary(account: RedditAccountPublicRecord): RedditAccountSummary {
const runtime = this.runtimes?.get(account.accountId);
const running = runtime?.snapshot().state === 'running';
return {
accountId: account.accountId,
redditUsername: account.redditUsername,
matrixUserId: account.matrixUserId ?? account.accountId,
isRunning: running ?? (account.accountId === this.auth.getSnapshot().accountId),
status: account.status === 'verified' && (running ?? account.accountId === this.auth.getSnapshot().accountId)
? 'authenticated'
: 'login-required',
createdAt: account.createdAt,
updatedAt: account.updatedAt,
lastImportedAt: account.lastImportedAt ?? account.updatedAt,
};
}
}
+39 -4
View File
@@ -7,15 +7,32 @@ interface BrowserSessionTarget {
importRedditCookies(cookies: Cookie[]): Promise<void>;
isSessionValid(): Promise<boolean>;
getAuthenticatedUsername(): Promise<string | null>;
getAuthenticatedIdentity?(): Promise<{ username: string; accountId: string } | null>;
}
interface ChatSessionTarget {
setMatrixClient(client: MatrixClient): void;
switchAccount?(accountId: string): Promise<void>;
}
type MatrixClientFactory = (credentials: MatrixCredentials) => MatrixClient;
type CredentialStore = (credentials: MatrixCredentials) => void;
export interface VerifiedRedditSessionStore {
save(input: {
accountId: string;
redditUsername: string;
matrixUserId: string;
cookies: Cookie[];
credentials: MatrixCredentials;
}): void;
}
function matrixRedditAccountId(matrixUserId: string): string | null {
const match = matrixUserId.match(/^@t2_([^:]+):reddit\.com$/i);
return match?.[1]?.toLowerCase() ?? null;
}
export class RedditSessionImporter {
constructor(
private readonly browser: BrowserSessionTarget,
@@ -23,9 +40,14 @@ export class RedditSessionImporter {
private readonly createMatrixClient: MatrixClientFactory,
private readonly saveCredentials: CredentialStore,
private readonly accountAuth: AccountAuthContext,
private readonly sessionStore?: VerifiedRedditSessionStore,
) {}
async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<void> {
async import(cookies: Cookie[], credentials: MatrixCredentials): Promise<{
accountId: string;
redditUsername: string;
matrixUserId: string;
}> {
const homeserver = new URL(credentials.homeserver);
if (
homeserver.protocol !== 'https:' ||
@@ -44,15 +66,28 @@ export class RedditSessionImporter {
if (!(await this.browser.isSessionValid())) {
throw new Error('Imported cookies did not authenticate with Reddit');
}
const redditUsername = await this.browser.getAuthenticatedUsername();
const identity = this.browser.getAuthenticatedIdentity
? await this.browser.getAuthenticatedIdentity()
: null;
const redditUsername = identity?.username ?? await this.browser.getAuthenticatedUsername();
if (!redditUsername) throw new Error('Reddit did not return an authenticated account identity');
if (identity) {
const matrixAccountId = matrixRedditAccountId(userId);
if (!matrixAccountId || matrixAccountId !== identity.accountId.toLowerCase().replace(/^t2_/, '')) {
throw new Error('Reddit cookies and Matrix credentials belong to different accounts');
}
}
this.saveCredentials(credentials);
this.chat.setMatrixClient(matrixClient);
this.accountAuth.markAuthenticated({
const account = {
accountId: userId,
redditUsername,
matrixUserId: userId,
});
};
this.sessionStore?.save({ ...account, cookies, credentials });
await this.chat.switchAccount?.(account.accountId);
this.accountAuth.markAuthenticated(account);
return account;
}
}