feat: replace browser login with imported Reddit sessions
This commit is contained in:
1 parent
2b0545afcf
commit
c483c7b8be
76 files changed
+2048
-2828
No files matched your search
+54
-122
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user