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