diff --git a/scripts/seed-matrix-creds.ts b/scripts/seed-matrix-creds.ts new file mode 100644 index 0000000..4bb1d94 --- /dev/null +++ b/scripts/seed-matrix-creds.ts @@ -0,0 +1,156 @@ +/** + * 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); +}); diff --git a/src/services/chat/index.ts b/src/services/chat/index.ts index de197e9..0e46cd7 100644 --- a/src/services/chat/index.ts +++ b/src/services/chat/index.ts @@ -20,5 +20,6 @@ export type { MatrixCredentials } from './matrix/types.js'; export { extractMatrixCredentials, loadCachedCredentials, + saveCredentials, } from './matrix/token-extractor.js'; export { MatrixMediaDownloader } from './matrix/http-media-downloader.js'; diff --git a/src/web/routes/debug.ts b/src/web/routes/debug.ts index 2837d46..482ce35 100644 --- a/src/web/routes/debug.ts +++ b/src/web/routes/debug.ts @@ -2,6 +2,11 @@ import { Router } from 'express'; import path from 'path'; import fs from 'fs'; import type { WebContext } from '../context.js'; +import { + MatrixClient, + saveCredentials, + type MatrixCredentials, +} from '../../services/chat/index.js'; export function createDebugRouter(ctx: WebContext): Router { const router = Router(); @@ -60,6 +65,40 @@ export function createDebugRouter(ctx: WebContext): Router { } }); + // Accepts a Matrix credentials JSON body, persists it to the cache file, + // and swaps it into the running ChatService. Lets us seed the deployed bot + // from a sniff run on a developer's machine when the in-container sniff + // can't reach an authed /chat/ surface and noVNC isn't an option. + // + // Body shape: { homeserver, accessToken, userId?, capturedAt? } + router.post('/debug/matrix-credentials', (req, res) => { + const body = (req.body ?? {}) as Partial; + if (!body.homeserver || !body.accessToken) { + res.status(400).json({ error: 'homeserver and accessToken are required' }); + return; + } + const creds: MatrixCredentials = { + homeserver: body.homeserver, + accessToken: body.accessToken, + userId: body.userId ?? '', + capturedAt: body.capturedAt ?? Date.now(), + }; + try { + saveCredentials(creds); + ctx.logger.info( + `[matrix] credentials uploaded via /api/debug/matrix-credentials (user=${creds.userId || 'unknown'})`, + ); + if (ctx.services.chat) { + ctx.services.chat.setMatrixClient(new MatrixClient(creds, ctx.logger)); + ctx.logger.info('[matrix] uploaded credentials swapped into running ChatService'); + } + res.json({ success: true, homeserver: creds.homeserver, userId: creds.userId }); + } catch (error) { + ctx.logger.error('Matrix credential upload failed:', error); + res.status(500).json({ error: String(error) }); + } + }); + router.post('/debug/inspect-dom', async (req, res) => { if (!ctx.services.chat) { res.status(500).json({ error: 'Chat service not initialized' });