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.
218 lines
7.1 KiB
TypeScript
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);
|
|
});
|