/** * Local helper for seeding the deployed bot's Matrix credentials when the * in-container sniff can't reach an authed /chat/ surface and noVNC isn't * an option (e.g. Dokploy behind a WebSocket-stripping proxy). * * Flow: * 1. Opens a headed Chromium against a throwaway local profile. * 2. You log in to Reddit interactively in that window. * 3. The script navigates to chat.reddit.com and watches for the first * authed request to matrix.redditspace.com. * 4. The captured bearer is POSTed to the deployed bot's * /api/debug/matrix-credentials endpoint. * * Usage: * BOT_URL=https://red.apps.jbrunton.co.uk \ * tsx scripts/seed-matrix-creds.ts * * Optional: PROFILE_DIR=./tmp/seed-session to persist your session across * runs. Otherwise a temp dir is used. */ import fs from 'fs'; import os from 'os'; import path from 'path'; import { chromium, type Request } from 'playwright'; import { createLogger } from '../src/utils/logger.js'; import { fetchWithTimeout } from '../src/utils/fetch-with-timeout.js'; const MATRIX_HOST_RE = /matrix\.(redditspace|reddit)\.com$/i; async function main() { const logger = createLogger(); const botUrl = process.env.BOT_URL?.replace(/\/$/, ''); if (!botUrl) { logger.error('BOT_URL env var is required (e.g. https://red.apps.jbrunton.co.uk)'); process.exit(1); } const profileDir = process.env.PROFILE_DIR ?? fs.mkdtempSync(path.join(os.tmpdir(), 'reddit-seed-')); logger.info(`[seed] using profile dir: ${profileDir}`); logger.info('[seed] launching browser — log in to Reddit in the window that opens'); const context = await chromium.launchPersistentContext(profileDir, { headless: false, viewport: { width: 1280, height: 800 }, }); const page = context.pages()[0] ?? (await context.newPage()); await page.goto('https://www.reddit.com/login', { waitUntil: 'domcontentloaded' }); logger.info('[seed] waiting for you to finish login (we watch for /api/me.json success)...'); // Poll /api/me.json from inside the page until it returns a user — that's // our signal that login (including any 2FA / captcha steps) is complete. const loggedIn = await page.waitForFunction( async () => { try { const r = await fetch('/api/me.json', { credentials: 'include' }); if (!r.ok) return false; const j = await r.json(); return Boolean(j && j.data && j.data.name); } catch { return false; } }, { timeout: 5 * 60_000, polling: 2_000 }, ).then(() => true).catch(() => false); if (!loggedIn) { logger.error('[seed] gave up waiting for login after 5 minutes'); await context.close(); process.exit(1); } logger.info('[seed] login detected — opening chat surface to sniff Matrix bearer'); let captured: { homeserver: string; accessToken: string } | null = null; const onRequest = (request: Request) => { if (captured) return; try { const url = new URL(request.url()); if (!MATRIX_HOST_RE.test(url.hostname)) return; const headers = request.headers(); const auth = headers['authorization'] || headers['Authorization']; if (!auth || !auth.toLowerCase().startsWith('bearer ')) return; const token = auth.slice('bearer '.length).trim(); if (!token) return; captured = { homeserver: `${url.protocol}//${url.host}`, accessToken: token }; logger.info(`[seed] captured bearer for ${captured.homeserver}`); } catch { // ignore malformed } }; page.on('request', onRequest); await page.goto('https://www.reddit.com/chat/', { waitUntil: 'domcontentloaded' }); const deadline = Date.now() + 60_000; while (!captured && Date.now() < deadline) { await page.waitForTimeout(500); } if (!captured) { logger.error('[seed] did not see any authed matrix.redditspace.com requests after 60s'); logger.error('[seed] try clicking around the chat UI in the open browser, then re-run'); await context.close(); process.exit(1); } // Resolve user_id via whoami so the bot has it cached too. let userId = ''; try { const r = await fetchWithTimeout( `${captured.homeserver}/_matrix/client/v3/account/whoami`, { headers: { Authorization: `Bearer ${captured.accessToken}` }, timeoutMs: 15_000, }, ); if (r.ok) { const body = (await r.json()) as { user_id?: string }; if (body.user_id) userId = body.user_id; } } catch (e) { logger.warn(`[seed] whoami failed (continuing without user_id): ${(e as Error).message}`); } const payload = { homeserver: captured.homeserver, accessToken: captured.accessToken, userId, capturedAt: Date.now(), }; logger.info(`[seed] POSTing credentials to ${botUrl}/api/debug/matrix-credentials`); const res = await fetchWithTimeout(`${botUrl}/api/debug/matrix-credentials`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), timeoutMs: 30_000, }); const text = await res.text(); if (!res.ok) { logger.error(`[seed] upload failed: ${res.status} ${text}`); await context.close(); process.exit(1); } logger.info(`[seed] upload succeeded: ${text}`); await context.close(); } main().catch((err) => { console.error(err); process.exit(1); });