Files
RedditBot/scripts/matrix-sniff.ts
T
jjbrunton 633bb0ec04 Restructure chat service, web routes, and add test suite
Split monolithic chat service into modular structure with Matrix client
support, extract web server into per-resource route modules, introduce
bootstrap/main-loop separation, and add comprehensive vitest coverage
across services, repositories, and utilities. Adds project docs and
CLAUDE.md agent instructions.
2026-05-21 01:32:10 +01:00

218 lines
7.1 KiB
TypeScript

/**
* 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-<timestamp>.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<string, string>;
requestBody: string | null;
requestBodyJson: unknown | null;
responseStatus: number | null;
responseHeaders: Record<string, string> | 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<Request, number>();
const reqStart = new WeakMap<Request, number>();
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<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(headers)) {
if (k.toLowerCase() === 'authorization') {
out[k] = v.toLowerCase().startsWith('bearer ') ? 'Bearer <redacted>' : '<redacted>';
} else if (k.toLowerCase() === 'cookie') {
out[k] = '<redacted>';
} else {
out[k] = v;
}
}
return out;
}
main().catch((err) => {
console.error('FATAL:', err);
process.exit(1);
});