feat: replace browser login with imported Reddit sessions

This commit is contained in:
jjbrunton committed 2026-08-08 16:48:59 +01:00
1 parent 2b0545afcf
commit c483c7b8be
76 files changed
+2048 -2828

No files matched your search

+54 -122
View File
@@ -9,9 +9,11 @@ import {
SuccessfulMessagesRepository,
ActivityLogRepository,
RedditUsersRepository,
ConversationStateRepository,
ConversationOutcomesRepository,
DeliveryOutboxRepository,
} from './database/repositories.js';
import { BrowserClient } from './browser/client.js';
import { RedditMessagePage } from './browser/message-page.js';
import { RedditApiClient } from './reddit/api.js';
import { OpenRouterClient } from './ai/openrouter.js';
import { MonitorService } from './services/monitor.js';
@@ -23,6 +25,8 @@ import {
MatrixClient,
MatrixMediaDownloader,
MatrixTokenRefresher,
loadCachedCredentials,
saveCredentials,
type UsernameResolver,
type MatrixCredentials,
} from './services/chat/index.js';
@@ -32,12 +36,7 @@ import { RateLimiter } from './utils/rate-limiter.js';
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js';
import {
AccountAuthContext,
authenticateCurrentAccount,
type AuthenticatedAccount,
} from './services/account-auth-context.js';
import { ProcessNativeLoginHandoff } from './services/native-login-handoff.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
export interface AppContainer {
config: Config;
@@ -45,12 +44,13 @@ export interface AppContainer {
db: Database.Database;
browserClient: BrowserClient;
messagePage: RedditMessagePage;
accountAuth: AccountAuthContext;
postsRepo: PostsRepository;
messagesRepo: MessagesRepository;
successfulMessagesRepo: SuccessfulMessagesRepository;
deliveryOutboxRepo: DeliveryOutboxRepository;
conversationStateRepo: ConversationStateRepository;
conversationOutcomesRepo: ConversationOutcomesRepository;
redditApi: RedditApiClient;
aiClient: OpenRouterClient;
@@ -71,7 +71,7 @@ export interface AppContainer {
*
* Performs:
* 1. DB + repos
* 2. Browser + login
* 2. Managed browser + imported-session verification
* 3. Three pages (compose / response-check / web-UI)
* 4. All services
* 5. Optional WebServer
@@ -95,47 +95,29 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
const successfulMessagesRepo = new SuccessfulMessagesRepository(db);
const activityLogRepo = new ActivityLogRepository(db);
const redditUsersRepo = new RedditUsersRepository(db);
const deliveryOutboxRepo = new DeliveryOutboxRepository(db);
const conversationStateRepo = new ConversationStateRepository(db);
const conversationOutcomesRepo = new ConversationOutcomesRepository(db);
logger.info('Database initialized');
// Browser
const browserClient = new BrowserClient(config, logger);
await browserClient.initialize();
const page = await browserClient.getPage();
const messagePage = new RedditMessagePage(page, logger);
messagePage.setDryRun(config.browser.dryRun);
const accountAuth = new AccountAuthContext(logger);
// Login verification. When the web dashboard is enabled we treat a missing
// session as recoverable — the dashboard exposes a noVNC console so a human
// can complete the login flow in the running headed browser.
// 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.
logger.info('Verifying Reddit login status...');
let loggedIn = await messagePage.isLoggedIn();
const loggedIn = await browserClient.isSessionValid();
if (!loggedIn) {
logger.warn('Session invalid or not logged in.');
if (!config.web.enabled && config.reddit.username && config.reddit.password) {
logger.info('Credentials found in config/env. Attempting automated login...');
loggedIn = await browserClient.login();
}
if (!loggedIn) {
if (config.web.enabled) {
logger.warn('Not logged in. Open the dashboard and click "Log in to Reddit" to authenticate via the noVNC console.');
logger.warn('The bot will stay paused until a valid session is detected.');
} else {
logger.error('Not logged in to Reddit and no recovery path (web dashboard disabled).');
logger.error('Run "npm run login" locally, OR set REDDIT_USERNAME and REDDIT_PASSWORD, OR enable the web dashboard.');
await browserClient.close();
process.exit(1);
}
}
logger.warn('Reddit session is logged out. Account work is paused until a session dump is imported from the extension.');
}
if (loggedIn) {
logger.info('Reddit login verified');
}
broadcaster.dashboardEvents.redditSession({
state: loggedIn ? 'authenticated' : 'login-required',
username: loggedIn ? config.reddit.username : undefined,
username: loggedIn ? await browserClient.getAuthenticatedUsername() ?? undefined : undefined,
});
// Reddit API client (shares browser context for auth)
@@ -161,19 +143,24 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
successfulMessagesRepo,
postsRepo,
logger,
accountAuth,
conversationOutcomesRepo,
);
// ---- Matrix chat backend (replaces the Playwright scraper) ----
// A cached bearer token is not accepted as application authentication.
// The current Chrome profile must yield a fresh token and Matrix identity
// before the shared account context unlocks any bot behavior.
const matrixCreds: MatrixCredentials = {
homeserver: 'https://matrix.redditspace.com',
accessToken: '',
userId: '',
capturedAt: Date.now(),
};
// 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();
if (matrixCreds) {
logger.info(`Loaded cached Matrix credentials (user=${matrixCreds.userId})`);
}
if (!matrixCreds) {
matrixCreds = {
homeserver: 'https://matrix.redditspace.com',
accessToken: '',
userId: '',
capturedAt: Date.now(),
};
}
const matrixClient = new MatrixClient(matrixCreds, logger);
// 2. Build the media downloader + service on top of the Matrix client so
@@ -181,34 +168,9 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
const mediaDownloader = new MatrixMediaDownloader(matrixClient, logger);
const mediaService = new MediaService(mediaDownloader, logger);
// 3. Token refresher — on 401, restore the persistent Reddit session when
// possible, re-sniff once, and swap credentials into the chat service.
// Concurrent callers share that refresh rather than opening extra pages.
const credentialState: { latest: MatrixCredentials | null } = { latest: null };
const tokenRefresher = new MatrixTokenRefresher(
browserClient,
logger,
(fresh) => {
credentialState.latest = fresh;
matrixClient.updateCredentials(fresh);
},
);
accountAuth.setAuthenticator(() => authenticateCurrentAccount({
getRedditUsername: () => browserClient.getAuthenticatedUsername(),
refreshMatrixCredentials: async () => {
credentialState.latest = null;
await tokenRefresher.refresh();
return credentialState.latest as MatrixCredentials | null;
},
}));
if (loggedIn) {
logger.info('Verifying full Reddit and Matrix account authentication...');
await accountAuth.ensureFullyAuthenticated();
} else {
accountAuth.invalidate('Interactive Reddit login is required');
}
// A 401 waits for a new extension dump instead of opening a browser login
// or token-sniff flow.
const tokenRefresher = new MatrixTokenRefresher();
// 4. Username resolver — turns t2 IDs into real Reddit usernames so the
// dashboard isn't full of `t2_abc123`. Uses Reddit's bulk endpoint.
@@ -251,52 +213,22 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
undefined,
messagesRepo,
redditUsersRepo,
accountAuth,
);
const mediaSyncService = new MediaSyncService(
chatService,
logger,
config.media.syncIntervalMs,
accountAuth,
);
const rateLimiter = new RateLimiter(config, logger);
const mediaSyncService = new MediaSyncService(chatService, logger, config.media.syncIntervalMs);
const rateLimiter = new RateLimiter(config, logger, undefined, {
sentMessageHistory: async (since) => messagesRepo.getSentTimestampsSince(since),
});
await rateLimiter.initialize();
await chatService.loadCache();
let startupSyncAccountId: string | null = null;
let startupSyncInFlightAccountId: string | null = null;
const startAuthenticatedAccount = async (account: AuthenticatedAccount): Promise<void> => {
if (
startupSyncAccountId === account.accountId ||
startupSyncInFlightAccountId === account.accountId
) return;
startupSyncInFlightAccountId = account.accountId;
try {
const { kind, chats } = await chatService.runStartupSync();
await chatService.enrichUsernames();
if (accountAuth.isFullyAuthenticated()) {
startupSyncAccountId = account.accountId;
logger.info(`[chat] startup sync (${kind}) complete for ${account.redditUsername}: ${chats} chats`);
}
} finally {
startupSyncInFlightAccountId = null;
}
};
accountAuth.onAuthenticated((account) => startAuthenticatedAccount(account));
const authenticatedAccount = accountAuth.getSnapshot();
if (
authenticatedAccount.status === 'authenticated' &&
authenticatedAccount.accountId &&
authenticatedAccount.redditUsername &&
authenticatedAccount.matrixUserId
) {
void startAuthenticatedAccount({
accountId: authenticatedAccount.accountId,
redditUsername: authenticatedAccount.redditUsername,
matrixUserId: authenticatedAccount.matrixUserId,
}).catch((err) => logger.error('[chat] startup sync failed:', err));
}
const sessionImporter = new RedditSessionImporter(
browserClient,
chatService,
(credentials) => new MatrixClient(credentials, logger),
saveCredentials,
);
let webServer: WebServer | null = null;
if (config.web.enabled) {
@@ -363,13 +295,12 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
webServer.setAiClient(aiClient);
webServer.setStrategySelector(strategySelector);
webServer.setBrowserClient(browserClient);
webServer.setAccountAuthContext(accountAuth);
webServer.setNativeLoginHandoff(new ProcessNativeLoginHandoff());
webServer.setSessionImporter(sessionImporter);
webServer.setConversationStateRepository(conversationStateRepo);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
// Start background services
if (config.media.syncEnabled) mediaSyncService.start();
if (webServer) webServer.start();
// Prime processor with successful examples already on disk.
@@ -385,11 +316,12 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
logger,
db,
browserClient,
messagePage,
accountAuth,
postsRepo,
messagesRepo,
successfulMessagesRepo,
deliveryOutboxRepo,
conversationStateRepo,
conversationOutcomesRepo,
redditApi,
aiClient,
monitorService,