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