Add Matrix credential upload route + local seed script
Closes the noVNC-unavailable gap: when Dokploy's reverse proxy strips WebSocket upgrades for noVNC, there's no way to do a one-time interactive Reddit login in the deployed container. Without that, extractMatrixCredentials inside the container hits the /chat/ login wall and never sniffs a bearer. Add POST /api/debug/matrix-credentials that accepts a credentials JSON body, persists it to data/matrix-credentials.json, and hot-swaps it into the running ChatService via setMatrixClient. Also export saveCredentials from the chat module so the route can reuse the same cache-file logic as the in-container sniff. scripts/seed-matrix-creds.ts opens a headed Chromium on the dev's machine, waits for an interactive Reddit login (detected by polling /api/me.json from inside the page), opens www.reddit.com/chat/, sniffs the first authed matrix.redditspace.com request, resolves user_id via /whoami, and POSTs the result to BOT_URL/api/debug/matrix-credentials. End-to-end seeding without ever touching the container. Usage: BOT_URL=https://red.apps.jbrunton.co.uk tsx scripts/seed-matrix-creds.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
e58bccf6b5
commit
0c7df6e1d3
3 files changed
+196
No files matched your search
@@ -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);
|
||||||
|
});
|
||||||
@@ -20,5 +20,6 @@ export type { MatrixCredentials } from './matrix/types.js';
|
|||||||
export {
|
export {
|
||||||
extractMatrixCredentials,
|
extractMatrixCredentials,
|
||||||
loadCachedCredentials,
|
loadCachedCredentials,
|
||||||
|
saveCredentials,
|
||||||
} from './matrix/token-extractor.js';
|
} from './matrix/token-extractor.js';
|
||||||
export { MatrixMediaDownloader } from './matrix/http-media-downloader.js';
|
export { MatrixMediaDownloader } from './matrix/http-media-downloader.js';
|
||||||
@@ -2,6 +2,11 @@ import { Router } from 'express';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import type { WebContext } from '../context.js';
|
import type { WebContext } from '../context.js';
|
||||||
|
import {
|
||||||
|
MatrixClient,
|
||||||
|
saveCredentials,
|
||||||
|
type MatrixCredentials,
|
||||||
|
} from '../../services/chat/index.js';
|
||||||
|
|
||||||
export function createDebugRouter(ctx: WebContext): Router {
|
export function createDebugRouter(ctx: WebContext): Router {
|
||||||
const router = 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<MatrixCredentials>;
|
||||||
|
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) => {
|
router.post('/debug/inspect-dom', async (req, res) => {
|
||||||
if (!ctx.services.chat) {
|
if (!ctx.services.chat) {
|
||||||
res.status(500).json({ error: 'Chat service not initialized' });
|
res.status(500).json({ error: 'Chat service not initialized' });
|
||||||
|
|||||||
Reference in new issue
Block a user