211 lines
8.2 KiB
TypeScript
211 lines
8.2 KiB
TypeScript
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<MatrixCredentials | null> {
|
|
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<MatrixCredentials> {
|
|
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<MatrixCredentials>((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<string, { count: number; sawAuth: boolean }>();
|
|
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<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<MatrixCredentials>((_, 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);
|
|
}
|
|
}
|