import fs from 'fs'; import path from 'path'; import type { BrowserContext } from 'playwright'; import type { Logger } from '../../../utils/logger.js'; import type { MatrixCredentials } from './types.js'; import { fetchWithTimeout } from '../../../utils/fetch-with-timeout.js'; const DEFAULT_CACHE_PATH = path.resolve(process.cwd(), 'data/matrix-credentials.json'); /** Reddit chat homeserver. The bearer token is host-scoped to this origin. */ const MATRIX_HOST_RE = /matrix\.(redditspace|reddit)\.com$/i; export interface TokenExtractorOptions { /** Where to persist the captured token. */ cachePath?: string; /** Max ms to wait for a Matrix-bound request to appear. */ timeoutMs?: number; } export class MatrixBrowserLoginRequiredError extends Error { constructor(loginUrl: string) { super( `Reddit redirected Matrix token capture to the login page (${loginUrl}). ` + 'Restore the browser session before retrying.', ); this.name = 'MatrixBrowserLoginRequiredError'; } } export async function loadCachedCredentials( cachePath = DEFAULT_CACHE_PATH, ): Promise { try { if (!fs.existsSync(cachePath)) return null; const raw = fs.readFileSync(cachePath, 'utf-8'); const parsed = JSON.parse(raw) as MatrixCredentials; if (!parsed.accessToken || !parsed.homeserver || !parsed.userId) return null; return parsed; } catch { return null; } } export function saveCredentials( creds: MatrixCredentials, cachePath = DEFAULT_CACHE_PATH, ): void { const dir = path.dirname(cachePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(cachePath, JSON.stringify(creds, null, 2), 'utf-8'); } /** * Sniffs the Matrix bearer token by opening chat.reddit.com in the supplied * (already-logged-in) browser context and watching outbound requests for any * call to the Matrix homeserver. The first request carrying an * `Authorization: Bearer ...` header is captured. * * Requires the context to already hold a valid Reddit session — call this * after BrowserClient.login() or with a persistent context that has cookies. */ export async function extractMatrixCredentials( context: BrowserContext, logger: Logger, options: TokenExtractorOptions = {}, ): Promise { const timeoutMs = options.timeoutMs ?? 30_000; const cachePath = options.cachePath ?? DEFAULT_CACHE_PATH; const page = await context.newPage(); try { let resolveCreds: (c: MatrixCredentials) => void; let rejectCreds: (err: Error) => void; const credsPromise = new Promise((resolve, reject) => { resolveCreds = resolve; rejectCreds = reject; }); let done = false; // Track every host the page touches so we can tell, on timeout, whether // chat.reddit.com failed to load at all, loaded but never reached the // Matrix homeserver, or reached it but without a bearer header. const seenHosts = new Map(); const onRequest = (request: import('playwright').Request) => { if (done) return; try { const url = new URL(request.url()); const isMatrix = MATRIX_HOST_RE.test(url.hostname); const headers = request.headers(); const auth = headers['authorization'] || headers['Authorization']; const hasAuth = !!auth && auth.toLowerCase().startsWith('bearer '); const entry = seenHosts.get(url.hostname) ?? { count: 0, sawAuth: false }; entry.count += 1; if (hasAuth) entry.sawAuth = true; seenHosts.set(url.hostname, entry); if (!isMatrix) return; if (!hasAuth) return; const token = auth!.slice('bearer '.length).trim(); if (!token) return; const homeserver = `${url.protocol}//${url.host}`; done = true; logger.info(`[matrix] captured bearer token for ${homeserver}`); resolveCreds({ homeserver, accessToken: token, userId: '', capturedAt: Date.now(), }); } catch { // swallow malformed URLs } }; page.on('request', onRequest); page.on('framenavigated', (frame) => { if (done || frame !== page.mainFrame()) return; try { const url = new URL(frame.url()); const isRedditHost = url.hostname === 'reddit.com' || url.hostname === 'www.reddit.com'; if (isRedditHost && url.pathname.startsWith('/login')) { done = true; logger.warn('[matrix] chat page redirected to Reddit login; token capture aborted'); rejectCreds(new MatrixBrowserLoginRequiredError(url.toString())); } } catch { // Ignore transient or malformed navigation URLs. } }); // Reddit now redirects `chat.reddit.com` → `www.reddit.com/chat/`. Going // direct to the canonical URL avoids one redirect hop and means the // session cookie the bot already has for www.reddit.com is applied to // the very first request, rather than only after a cross-subdomain // bounce that's been bouncing logged-in sessions to the login wall. // // We start the navigation but don't await it — the request listener // resolves credsPromise the moment the SPA fires off its first authed // call to matrix.redditspace.com, which may happen well before the page // reaches `networkidle`. Awaiting goto would block past that point. logger.info('[matrix] loading www.reddit.com/chat/ to capture access token...'); void page .goto('https://www.reddit.com/chat/', { waitUntil: 'domcontentloaded', timeout: timeoutMs, }) .catch((err) => { logger.warn( `[matrix] page.goto error (ignored, still listening): ${err instanceof Error ? err.message : String(err)}`, ); }); let timeoutHandle: ReturnType | undefined; const timeout = new Promise((_, reject) => { timeoutHandle = setTimeout( () => reject(new Error(`Matrix token capture timed out after ${timeoutMs}ms`)), timeoutMs, ); }); let captured: MatrixCredentials; try { captured = await Promise.race([credsPromise, timeout]); } catch (err) { if (err instanceof MatrixBrowserLoginRequiredError) throw err; // On timeout, surface enough info to tell the three failure modes apart: // (a) chat.reddit.com never loaded → page.url() still about:blank, almost no hosts // (b) page loaded, no Matrix host hit → reddit hosts present, no matrix.* // (c) Matrix hit, but no Authorization → matrix.* present with sawAuth: false try { const finalUrl = page.url(); const hostSummary = Array.from(seenHosts.entries()) .map(([host, info]) => `${host}=${info.count}${info.sawAuth ? '*' : ''}`) .join(', '); logger.error( `[matrix] timeout diagnostics: finalUrl=${finalUrl} hosts=[${hostSummary || 'none'}]`, ); const shotDir = path.dirname(cachePath); if (!fs.existsSync(shotDir)) fs.mkdirSync(shotDir, { recursive: true }); const shotPath = path.join(shotDir, 'matrix-debug.png'); await page.screenshot({ path: shotPath, fullPage: true }).catch(() => undefined); logger.error(`[matrix] saved debug screenshot to ${shotPath}`); } catch (diagErr) { logger.warn('[matrix] failed to collect timeout diagnostics:', diagErr); } throw err; } finally { if (timeoutHandle) clearTimeout(timeoutHandle); } // Resolve the user_id by calling /whoami so the caller knows who we are. const whoamiRes = await fetchWithTimeout( `${captured.homeserver}/_matrix/client/v3/account/whoami`, { headers: { Authorization: `Bearer ${captured.accessToken}` }, timeoutMs: 15_000, }, ); if (whoamiRes.ok) { const body = (await whoamiRes.json()) as { user_id?: string }; if (body.user_id) captured.userId = body.user_id; } else { logger.warn(`[matrix] whoami returned ${whoamiRes.status}; userId left blank`); } saveCredentials(captured, cachePath); logger.info(`[matrix] credentials saved to ${cachePath} (user=${captured.userId || 'unknown'})`); return captured; } finally { await page.close().catch(() => undefined); } }