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

+89 -11
View File
@@ -39,6 +39,13 @@ import { reportReply } from './web/event-reporting.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext } from './services/account-auth-context.js';
import { ConfigStore } from './config/persistence.js';
import { RedditAccountsRepository, AccountContentRepository } from './database/account-repositories.js';
import { RedditAccountsService } from './services/reddit-accounts.js';
import type { Cookie } from 'playwright';
import { ChatCache } from './services/chat/cache.js';
import { RedditAccountRuntimeManager } from './services/reddit-account-runtime.js';
import { createConcreteRedditAccountRuntimeFactory } from './services/reddit-account-runtime-builder.js';
import { importLegacyAccountContent } from './database/legacy-content-import.js';
export interface AppContainer {
config: Config;
@@ -47,6 +54,8 @@ export interface AppContainer {
browserClient: BrowserClient;
accountAuth: AccountAuthContext;
redditAccountsRepo: RedditAccountsRepository;
accountRuntimeManager: RedditAccountRuntimeManager;
postsRepo: PostsRepository;
messagesRepo: MessagesRepository;
@@ -97,14 +106,25 @@ export async function bootstrap(
// Database
const db = existingDatabase ?? createDatabase(config);
runMigrations(db);
const postsRepo = new PostsRepository(db);
const messagesRepo = new MessagesRepository(db);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
const activityLogRepo = new ActivityLogRepository(db);
const bootstrapAccountsRepo = new RedditAccountsRepository(db);
const bootstrapAccount = bootstrapAccountsRepo.listPublic()
.filter((account) => account.status === 'verified')
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
const bootstrapAccountId = bootstrapAccount?.accountId;
const postsRepo = new PostsRepository(db, bootstrapAccountId);
const messagesRepo = new MessagesRepository(db, bootstrapAccountId);
const successfulMessagesRepo = new SuccessfulMessagesRepository(db, bootstrapAccountId);
const activityLogRepo = new ActivityLogRepository(db, bootstrapAccountId);
const redditUsersRepo = new RedditUsersRepository(db);
const deliveryOutboxRepo = new DeliveryOutboxRepository(db);
const conversationStateRepo = new ConversationStateRepository(db);
const conversationOutcomesRepo = new ConversationOutcomesRepository(db);
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, bootstrapAccountId);
const conversationStateRepo = new ConversationStateRepository(db, bootstrapAccountId);
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, bootstrapAccountId);
const redditAccountsRepo = bootstrapAccountsRepo;
const accountContentRepo = new AccountContentRepository(db);
const legacyImport = importLegacyAccountContent(db);
if (legacyImport.conversations > 0 || legacyImport.media > 0) {
logger.info(`Imported legacy unassigned content into SQLite (${legacyImport.conversations} conversations, ${legacyImport.media} media items)`);
}
logger.info('Database initialized');
// Browser
@@ -112,6 +132,18 @@ export async function bootstrap(
const accountAuth = new AccountAuthContext(logger);
await browserClient.initialize();
const storedAccount = bootstrapAccount;
if (storedAccount) {
const privateAccount = redditAccountsRepo.get(storedAccount.accountId);
if (privateAccount) {
try {
await browserClient.importRedditCookies(JSON.parse(privateAccount.cookiesJson) as Cookie[]);
} catch (error) {
logger.warn(`Could not restore stored Reddit cookies for u/${storedAccount.redditUsername}:`, error);
}
}
}
// The extension import is the only session acquisition path. A missing
// session is recoverable: the dashboard remains available while all
// account-bound work waits for a verified dump.
@@ -151,7 +183,10 @@ export async function bootstrap(
// Matrix credentials come only from a verified extension dump (or its
// persisted cache on restart). The backend never opens Reddit Chat to
// sniff or refresh a token.
let matrixCreds: MatrixCredentials | null = await loadCachedCredentials();
const storedPrivateAccount = storedAccount ? redditAccountsRepo.get(storedAccount.accountId) : null;
let matrixCreds: MatrixCredentials | null = storedPrivateAccount
? JSON.parse(storedPrivateAccount.matrixCredentialsJson) as MatrixCredentials
: await loadCachedCredentials();
if (matrixCreds) {
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
}
@@ -185,7 +220,13 @@ export async function bootstrap(
// 2. Build the media downloader + service on top of the Matrix client so
// `mxc://` URIs resolve through the same auth + rate-limiter pipeline.
const mediaDownloader = new MatrixMediaDownloader(activeMatrixClient, logger);
const mediaService = new MediaService(mediaDownloader, logger);
const mediaService = new MediaService(
mediaDownloader,
logger,
undefined,
accountContentRepo,
() => accountAuth.getSnapshot().accountId,
);
const indexedMedia = await mediaService.getMediaItems();
const receivedImageRooms = new Set(
@@ -224,7 +265,7 @@ export async function bootstrap(
mediaService,
tokenRefresher,
usernameResolver,
undefined,
new ChatCache(logger, undefined, accountContentRepo, storedAccount?.accountId ?? null),
messagesRepo,
redditUsersRepo,
accountAuth,
@@ -247,13 +288,42 @@ export async function bootstrap(
mediaDownloader.setMatrixClient(client);
matrixUsernameResolver.setMatrixClient(client);
},
switchAccount(accountId) {
return chatService.switchAccount(accountId);
},
},
(credentials) => new MatrixClient(credentials, logger),
saveCredentials,
accountAuth,
{
save(input) {
redditAccountsRepo.upsertVerified({
accountId: input.accountId,
redditUsername: input.redditUsername,
matrixUserId: input.matrixUserId,
cookiesJson: JSON.stringify(input.cookies),
matrixCredentialsJson: JSON.stringify(input.credentials),
});
},
},
);
let webServer: WebServer | null = null;
const accountRuntimeManager = new RedditAccountRuntimeManager(
redditAccountsRepo,
createConcreteRedditAccountRuntimeFactory({
config,
logger,
db,
isBotRunning: () => webServer?.isRunning() ?? true,
}),
);
const redditAccounts = new RedditAccountsService(
redditAccountsRepo,
sessionImporter,
accountAuth,
accountRuntimeManager,
browserClient,
);
if (config.web.enabled) {
webServer = new WebServer(
config.web.port,
@@ -330,6 +400,8 @@ export async function bootstrap(
webServer.setBrowserClient(browserClient);
webServer.setSessionImporter(sessionImporter);
webServer.setAccountAuth(accountAuth);
webServer.setRedditAccountsService(redditAccounts);
webServer.setAccountRuntimeManager(accountRuntimeManager);
webServer.setConversationStateRepository(conversationStateRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
@@ -337,6 +409,10 @@ export async function bootstrap(
// Start background services
if (webServer) webServer.start();
await accountRuntimeManager.startEnabled().catch((error) => {
logger.error('One or more Reddit account runtimes failed to start:', error);
});
if (accountRuntimeManager.list().length > 0) await browserClient.close();
// Prime processor with successful examples already on disk.
const existingExamples = responseChecker.getSuccessfulExamples(3);
@@ -352,6 +428,8 @@ export async function bootstrap(
db,
browserClient,
accountAuth,
redditAccountsRepo,
accountRuntimeManager,
postsRepo,
messagesRepo,
successfulMessagesRepo,