/** * Matrix network sniffer. * * Watches every request to matrix.redditspace.com made by chat.reddit.com, * captures method/URL/body for both request and response, and writes the * sequence to data/matrix-sniff-.json when you exit (Ctrl-C). * * Usage: * tsx scripts/matrix-sniff.ts * * Then in the browser that pops up: log in if needed → click "Start chat" * → pick a recipient → send a message → come back here and press Ctrl-C. * * The script does NOT initiate any Matrix calls itself. It only observes * what the UI sends, so there's no risk of an accidental outbound message. */ import fs from 'fs'; import path from 'path'; import { chromium, type Request, type Response } from 'playwright'; import { createLogger } from '../src/utils/logger.js'; const SESSION_DIR = path.resolve(process.cwd(), './data/browser-session'); const OUT_DIR = path.resolve(process.cwd(), './data'); const MATRIX_HOST_RE = /matrix\.(redditspace|reddit)\.com$/i; interface CapturedCall { index: number; startedAt: string; method: string; url: string; pathLabel: string; requestHeaders: Record; requestBody: string | null; requestBodyJson: unknown | null; responseStatus: number | null; responseHeaders: Record | null; responseBody: string | null; responseBodyJson: unknown | null; durationMs: number | null; } /** Best-effort short label so the live log is readable. */ function labelFor(method: string, urlStr: string): string { try { const url = new URL(urlStr); // Strip /_matrix/client/v3 prefix for readability. let p = url.pathname.replace(/^\/_matrix\/(client|media)\/(v3|v1)/, ''); // Truncate long IDs in path segments. p = p .split('/') .map((seg) => (seg.length > 24 ? seg.slice(0, 12) + '…' + seg.slice(-6) : seg)) .join('/'); return `${method} ${p}`; } catch { return `${method} ${urlStr}`; } } function safeParseJson(s: string | null): unknown | null { if (!s) return null; try { return JSON.parse(s); } catch { return null; } } async function main() { const logger = createLogger(); if (!fs.existsSync(OUT_DIR)) fs.mkdirSync(OUT_DIR, { recursive: true }); const outFile = path.join(OUT_DIR, `matrix-sniff-${new Date().toISOString().replace(/[:.]/g, '-')}.json`); logger.info('[sniff] launching browser (using persistent session)...'); const context = await chromium.launchPersistentContext(SESSION_DIR, { headless: false, viewport: { width: 1280, height: 800 }, }); const calls: CapturedCall[] = []; // Map request → index so we can hydrate it when the response arrives. const reqIndex = new WeakMap(); const reqStart = new WeakMap(); let serial = 0; const onRequest = (request: Request) => { try { const u = new URL(request.url()); if (!MATRIX_HOST_RE.test(u.hostname)) return; } catch { return; } const idx = serial++; reqIndex.set(request, idx); reqStart.set(request, Date.now()); const body = request.postData(); const call: CapturedCall = { index: idx, startedAt: new Date().toISOString(), method: request.method(), url: request.url(), pathLabel: labelFor(request.method(), request.url()), requestHeaders: scrubHeaders(request.headers()), requestBody: body ?? null, requestBodyJson: safeParseJson(body ?? null), responseStatus: null, responseHeaders: null, responseBody: null, responseBodyJson: null, durationMs: null, }; calls[idx] = call; logger.info(`[sniff] #${idx} → ${call.pathLabel}`); if (body && body.length < 600) { logger.info(` req body: ${body}`); } else if (body) { logger.info(` req body: ${body.length}B (truncated in log; full saved to file)`); } }; const onResponse = async (response: Response) => { const request = response.request(); const idx = reqIndex.get(request); if (idx === undefined) return; const call = calls[idx]; if (!call) return; call.responseStatus = response.status(); call.responseHeaders = scrubHeaders(response.headers()); const started = reqStart.get(request); call.durationMs = started ? Date.now() - started : null; // Only capture text bodies, and skip large binaries. const ct = (response.headers()['content-type'] || '').toLowerCase(); if (ct.includes('json') || ct.includes('text')) { try { const text = await response.text(); if (text.length <= 100_000) { call.responseBody = text; call.responseBodyJson = safeParseJson(text); } else { call.responseBody = `[truncated: ${text.length}B ${ct}]`; } } catch (err) { call.responseBody = `[read error: ${(err as Error).message}]`; } } else if (ct) { call.responseBody = `[binary: ${ct}]`; } logger.info(`[sniff] #${idx} ← ${call.responseStatus} (${call.durationMs}ms)`); }; context.on('request', onRequest); context.on('response', onResponse); const page = context.pages()[0] ?? (await context.newPage()); await page.goto('https://chat.reddit.com/', { waitUntil: 'domcontentloaded' }); logger.info(''); logger.info('═══════════════════════════════════════════════════════════'); logger.info(' Ready. In the browser:'); logger.info(' 1. Click "Start chat" (or the + icon)'); logger.info(' 2. Pick a recipient and send a short message'); logger.info(' 3. Come back here and press Ctrl-C to save & exit.'); logger.info('═══════════════════════════════════════════════════════════'); logger.info(''); const flush = async () => { logger.info(`[sniff] writing ${calls.length} calls to ${outFile}`); fs.writeFileSync(outFile, JSON.stringify({ capturedAt: new Date().toISOString(), calls }, null, 2)); try { await context.close(); } catch { /* ignore */ } }; // Save on Ctrl-C. process.on('SIGINT', async () => { logger.info('[sniff] SIGINT — flushing...'); await flush(); process.exit(0); }); // Also save if the user closes the browser window. context.on('close', async () => { logger.info('[sniff] browser closed — flushing...'); await flush(); process.exit(0); }); } /** * Remove the bearer token before writing to disk — the captured JSON file * is going to sit in the repo's data dir, no need to leave creds in it. */ function scrubHeaders(headers: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(headers)) { if (k.toLowerCase() === 'authorization') { out[k] = v.toLowerCase().startsWith('bearer ') ? 'Bearer ' : ''; } else if (k.toLowerCase() === 'cookie') { out[k] = ''; } else { out[k] = v; } } return out; } main().catch((err) => { console.error('FATAL:', err); process.exit(1); });