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

+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);
}