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,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);
|
||||
}
|
||||
Reference in new issue
Block a user