feat: replace browser login with imported Reddit sessions
This commit is contained in:
1 parent
2b0545afcf
commit
c483c7b8be
76 files changed
+2048
-2828
No files matched your search
@@ -1,133 +0,0 @@
|
||||
import { chromium } from 'playwright';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import readline from 'readline';
|
||||
|
||||
const SESSION_DIR = path.resolve(process.cwd(), './data/browser-session');
|
||||
|
||||
async function waitForEnter(prompt: string): Promise<void> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, () => {
|
||||
rl.close();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function manualLogin(): Promise<void> {
|
||||
console.log('='.repeat(60));
|
||||
console.log('Reddit Bot - Manual Login');
|
||||
console.log('='.repeat(60));
|
||||
console.log();
|
||||
|
||||
// Ensure session directory exists
|
||||
if (!fs.existsSync(SESSION_DIR)) {
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
console.log(`Session will be saved to: ${SESSION_DIR}`);
|
||||
console.log();
|
||||
|
||||
const context = await chromium.launchPersistentContext(SESSION_DIR, {
|
||||
headless: false,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Opening Reddit login page...');
|
||||
await page.goto('https://www.reddit.com/login/');
|
||||
|
||||
console.log();
|
||||
console.log('-'.repeat(60));
|
||||
console.log('Please log in to Reddit in the browser window.');
|
||||
console.log();
|
||||
console.log('After logging in successfully:');
|
||||
console.log('1. Make sure you see your username in the top right');
|
||||
console.log('2. Come back here and press Enter');
|
||||
console.log('-'.repeat(60));
|
||||
console.log();
|
||||
|
||||
await waitForEnter('Press Enter when you have logged in... ');
|
||||
|
||||
// Verify login
|
||||
console.log();
|
||||
console.log('Verifying login status...');
|
||||
|
||||
await page.goto('https://www.reddit.com');
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const visibleIndicators = [
|
||||
'[data-testid="user-drawer-button"]',
|
||||
'button[aria-label*="User menu"]',
|
||||
];
|
||||
|
||||
let isLoggedIn = false;
|
||||
for (const selector of visibleIndicators) {
|
||||
const el = page.locator(selector).first();
|
||||
if (await el.isVisible().catch(() => false)) {
|
||||
isLoggedIn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
const attributeIndicators = [
|
||||
'reddit-header-large[is-logged-in="true"]',
|
||||
'shreddit-post-overflow-menu[current-user-id]',
|
||||
];
|
||||
for (const selector of attributeIndicators) {
|
||||
const el = await page.$(selector);
|
||||
if (el) {
|
||||
isLoggedIn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
isLoggedIn = await page.evaluate(async () => {
|
||||
try {
|
||||
const res = await fetch('https://www.reddit.com/api/me.json', { credentials: 'include' });
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
return Boolean(data && data.name);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (isLoggedIn) {
|
||||
console.log('SUCCESS! You are logged in.');
|
||||
console.log(`Session saved to: ${SESSION_DIR}`);
|
||||
console.log();
|
||||
console.log('You can now run the bot with: npm start');
|
||||
} else {
|
||||
console.log('WARNING: Login could not be verified.');
|
||||
console.log('The session has been saved, but you may need to try again.');
|
||||
console.log();
|
||||
console.log('Tips:');
|
||||
console.log('- Make sure you completed the login process');
|
||||
console.log('- Try clicking on your profile to ensure the page loaded');
|
||||
console.log('- Run this script again if needed');
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log('Closing browser...');
|
||||
await context.close();
|
||||
|
||||
console.log('Done!');
|
||||
}
|
||||
|
||||
manualLogin().catch((error) => {
|
||||
console.error('Error during login:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -1,686 +0,0 @@
|
||||
/**
|
||||
* Matrix-API prototype CLI.
|
||||
*
|
||||
* Usage:
|
||||
* tsx scripts/matrix-test.ts # capture token + list rooms
|
||||
* tsx scripts/matrix-test.ts --refresh # force re-extract token
|
||||
* tsx scripts/matrix-test.ts --room <id> # dump message history for a room
|
||||
* tsx scripts/matrix-test.ts --send <id> "hello" # send a test message
|
||||
* tsx scripts/matrix-test.ts --dump # download every room's full
|
||||
* # history + images to disk
|
||||
*
|
||||
* Reuses the bot's persistent browser session at ./data/browser-session,
|
||||
* so you must already be logged into Reddit there (run `npm run login` first
|
||||
* if you haven't).
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { chromium } from 'playwright';
|
||||
import { createLogger } from '../src/utils/logger.js';
|
||||
import { MatrixClient, MatrixAuthError } from '../src/services/chat/matrix/client.js';
|
||||
import {
|
||||
extractMatrixCredentials,
|
||||
loadCachedCredentials,
|
||||
} from '../src/services/chat/matrix/token-extractor.js';
|
||||
import type { MatrixTimelineEvent } from '../src/services/chat/matrix/types.js';
|
||||
|
||||
const SESSION_DIR = path.resolve(process.cwd(), './data/browser-session');
|
||||
|
||||
interface Args {
|
||||
refresh: boolean;
|
||||
roomId: string | null;
|
||||
sendBody: string | null;
|
||||
sendRoomId: string | null;
|
||||
dump: boolean;
|
||||
sync: boolean;
|
||||
forceBackfill: boolean;
|
||||
dumpDir: string;
|
||||
maxPages: number;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const out: Args = {
|
||||
refresh: false,
|
||||
roomId: null,
|
||||
sendBody: null,
|
||||
sendRoomId: null,
|
||||
dump: false,
|
||||
sync: false,
|
||||
forceBackfill: false,
|
||||
dumpDir: 'data/matrix-dump',
|
||||
maxPages: 200,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--refresh') out.refresh = true;
|
||||
else if (a === '--room') out.roomId = argv[++i] ?? null;
|
||||
else if (a === '--dump') out.dump = true;
|
||||
else if (a === '--sync') out.sync = true;
|
||||
else if (a === '--force-backfill') out.forceBackfill = true;
|
||||
else if (a === '--out') out.dumpDir = argv[++i] ?? out.dumpDir;
|
||||
else if (a === '--max-pages') out.maxPages = Number(argv[++i] ?? '200');
|
||||
else if (a === '--send') {
|
||||
out.sendRoomId = argv[++i] ?? null;
|
||||
out.sendBody = argv[++i] ?? null;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extOfContentType(ct: string): string {
|
||||
const base = ct.split(';')[0].trim().toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
'image/heic': 'heic',
|
||||
'image/avif': 'avif',
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm',
|
||||
'video/quicktime': 'mov',
|
||||
'audio/mpeg': 'mp3',
|
||||
'audio/ogg': 'ogg',
|
||||
'application/octet-stream': 'bin',
|
||||
};
|
||||
return map[base] ?? base.split('/')[1] ?? 'bin';
|
||||
}
|
||||
|
||||
function safeSlug(s: string): string {
|
||||
return s.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 80);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a unique directory name for a room: `<displayName>__<short room-id>`.
|
||||
* Many Reddit chat rooms share names like "Stunning" — without the suffix
|
||||
* they collide on disk and silently overwrite each other's manifests.
|
||||
*/
|
||||
function roomDirSlug(roomId: string, displayName: string | null): string {
|
||||
// Strip `!` and `:reddit.com` so the hash is short but stable.
|
||||
const idCore = roomId.replace(/^!/, '').split(':')[0].slice(0, 8);
|
||||
const base = displayName ? safeSlug(displayName) : 'room';
|
||||
return `${base}__${idCore}`;
|
||||
}
|
||||
|
||||
/** Run `worker` over `items` with at most `limit` in flight. Preserves order. */
|
||||
async function pMap<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
worker: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (true) {
|
||||
const idx = next++;
|
||||
if (idx >= items.length) return;
|
||||
results[idx] = await worker(items[idx], idx);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
}
|
||||
|
||||
const ROOM_CONCURRENCY = 6;
|
||||
const IMAGE_CONCURRENCY = 6;
|
||||
|
||||
async function paginateAllMessages(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
maxPages: number,
|
||||
): Promise<MatrixTimelineEvent[]> {
|
||||
const all: MatrixTimelineEvent[] = [];
|
||||
let from: string | undefined = undefined;
|
||||
for (let i = 0; i < maxPages; i++) {
|
||||
const res = await client.getRoomMessages(roomId, { from, limit: 100, dir: 'b' });
|
||||
all.push(...res.chunk);
|
||||
if (!res.end || res.end === from || res.chunk.length === 0) break;
|
||||
from = res.end;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
interface SyncState {
|
||||
nextBatch: string | null;
|
||||
updatedAt: number;
|
||||
rooms: Record<string, { slug: string; displayName: string | null }>;
|
||||
}
|
||||
|
||||
function loadSyncState(dir: string): SyncState | null {
|
||||
const p = path.join(dir, '_state.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, 'utf-8')) as SyncState;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSyncState(dir: string, state: SyncState): void {
|
||||
fs.writeFileSync(path.join(dir, '_state.json'), JSON.stringify(state, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function eventToManifestLine(e: MatrixTimelineEvent, userId: string): string {
|
||||
return JSON.stringify({
|
||||
event_id: e.event_id,
|
||||
ts: e.origin_server_ts,
|
||||
iso: new Date(e.origin_server_ts).toISOString(),
|
||||
sender: e.sender,
|
||||
isSelf: e.sender === userId,
|
||||
msgtype: e.content.msgtype ?? null,
|
||||
body: e.content.body ?? null,
|
||||
mxc: typeof e.content.url === 'string' ? e.content.url : null,
|
||||
});
|
||||
}
|
||||
|
||||
function isImageEvent(e: MatrixTimelineEvent): boolean {
|
||||
if (e.type !== 'm.room.message') return false;
|
||||
return e.content.msgtype === 'm.image' || typeof e.content.url === 'string';
|
||||
}
|
||||
|
||||
async function downloadEventImages(
|
||||
client: MatrixClient,
|
||||
events: MatrixTimelineEvent[],
|
||||
roomDir: string,
|
||||
logger: ReturnType<typeof createLogger>,
|
||||
): Promise<{ downloaded: number; failed: number }> {
|
||||
const targets = events.filter(isImageEvent);
|
||||
let downloaded = 0;
|
||||
let failed = 0;
|
||||
|
||||
await pMap(targets, IMAGE_CONCURRENCY, async (evt) => {
|
||||
const mxc = evt.content.url as string;
|
||||
try {
|
||||
// Skip download if any file for this event already exists (delta re-runs).
|
||||
const prefix = `${evt.origin_server_ts}_${safeSlug(evt.event_id)}.`;
|
||||
const existing = fs.readdirSync(roomDir).find((f) => f.startsWith(prefix));
|
||||
if (existing) return;
|
||||
|
||||
const media = await client.downloadMedia(mxc);
|
||||
if (!media) {
|
||||
failed++;
|
||||
return;
|
||||
}
|
||||
const ext = extOfContentType(media.contentType);
|
||||
fs.writeFileSync(path.join(roomDir, prefix + ext), media.bytes);
|
||||
downloaded++;
|
||||
} catch (err) {
|
||||
logger.warn(`[media] failed ${mxc}: ${(err as Error).message}`);
|
||||
failed++;
|
||||
}
|
||||
});
|
||||
|
||||
return { downloaded, failed };
|
||||
}
|
||||
|
||||
async function resolveRoomDisplayName(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
logger: ReturnType<typeof createLogger>,
|
||||
): Promise<string | null> {
|
||||
// 1. Explicit name event
|
||||
try {
|
||||
const name = await client.getRoomName(roomId);
|
||||
if (name) return name;
|
||||
} catch (err) {
|
||||
logger.debug(`[name] getRoomName failed for ${roomId}: ${(err as Error).message}`);
|
||||
}
|
||||
// 2. Fall back to other joined members (DM heuristic)
|
||||
try {
|
||||
const members = await client.getJoinedMembers(roomId);
|
||||
const others = Object.keys(members).filter((m) => m !== client.userId);
|
||||
if (others.length > 0) {
|
||||
return others.map((m) => m.match(/^@([^:]+):/)?.[1] ?? m).join(', ');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug(`[name] getJoinedMembers failed for ${roomId}: ${(err as Error).message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function backfillAll(
|
||||
client: MatrixClient,
|
||||
args: Args,
|
||||
logger: ReturnType<typeof createLogger>,
|
||||
outDir: string,
|
||||
): Promise<SyncState> {
|
||||
const t0 = Date.now();
|
||||
|
||||
// One /sync up-front: gives us heroes / m.room.name for every active room
|
||||
// and (crucially) the cursor for the future delta runs. This lets us avoid
|
||||
// per-room name lookups for the common case.
|
||||
const seed = await client.sync({ timeoutMs: 0 });
|
||||
const seedJoined = seed.rooms?.join ?? {};
|
||||
const seedNames = new Map<string, string | null>();
|
||||
for (const [roomId, room] of Object.entries(seedJoined)) {
|
||||
const nameEvent = room.state?.events?.find((e) => e.type === 'm.room.name');
|
||||
if (nameEvent && typeof nameEvent.content.name === 'string') {
|
||||
seedNames.set(roomId, nameEvent.content.name as string);
|
||||
continue;
|
||||
}
|
||||
const heroes = (room.summary?.['m.heroes'] ?? []).filter((h) => h !== client.userId);
|
||||
if (heroes.length > 0) {
|
||||
seedNames.set(roomId, heroes.map((h) => h.match(/^@([^:]+):/)?.[1] ?? h).join(', '));
|
||||
} else {
|
||||
seedNames.set(roomId, null);
|
||||
}
|
||||
}
|
||||
|
||||
const joined = await client.getJoinedRooms();
|
||||
logger.info(
|
||||
`[backfill] /joined_rooms → ${joined.length} rooms (${seedNames.size} pre-named from /sync)`,
|
||||
);
|
||||
|
||||
const state: SyncState = { nextBatch: null, updatedAt: Date.now(), rooms: {} };
|
||||
|
||||
let totalEvents = 0;
|
||||
let totalImages = 0;
|
||||
let totalDownloaded = 0;
|
||||
let totalFailed = 0;
|
||||
let done = 0;
|
||||
|
||||
await pMap(joined, ROOM_CONCURRENCY, async (roomId) => {
|
||||
let displayName: string | null;
|
||||
if (seedNames.has(roomId)) {
|
||||
displayName = seedNames.get(roomId) ?? null;
|
||||
} else {
|
||||
displayName = await resolveRoomDisplayName(client, roomId, logger);
|
||||
}
|
||||
|
||||
const slug = roomDirSlug(roomId, displayName);
|
||||
const roomDir = path.join(outDir, slug);
|
||||
fs.mkdirSync(roomDir, { recursive: true });
|
||||
|
||||
const events = await paginateAllMessages(client, roomId, args.maxPages);
|
||||
const messages = events.filter((e) => e.type === 'm.room.message');
|
||||
|
||||
const lines = messages
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((e) => eventToManifestLine(e, client.userId));
|
||||
fs.writeFileSync(
|
||||
path.join(roomDir, 'messages.jsonl'),
|
||||
lines.join('\n') + (lines.length ? '\n' : ''),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const { downloaded, failed } = await downloadEventImages(client, events, roomDir, logger);
|
||||
const imgCount = events.filter(isImageEvent).length;
|
||||
|
||||
totalEvents += events.length;
|
||||
totalImages += imgCount;
|
||||
totalDownloaded += downloaded;
|
||||
totalFailed += failed;
|
||||
state.rooms[roomId] = { slug, displayName };
|
||||
done++;
|
||||
|
||||
logger.info(
|
||||
`[backfill ${done}/${joined.length}] ${slug}: events=${events.length} msgs=${messages.length} imgs=${imgCount} ok=${downloaded} fail=${failed}`,
|
||||
);
|
||||
});
|
||||
|
||||
// The seed /sync's cursor is the right one to delta from — all events
|
||||
// returned in that response are already on disk via the per-room walk.
|
||||
state.nextBatch = seed.next_batch;
|
||||
state.updatedAt = Date.now();
|
||||
saveSyncState(outDir, state);
|
||||
|
||||
const elapsedSec = ((Date.now() - t0) / 1000).toFixed(1);
|
||||
logger.info(
|
||||
`[backfill] DONE in ${elapsedSec}s — rooms=${joined.length} events=${totalEvents} imgs=${totalImages} ok=${totalDownloaded} fail=${totalFailed}`,
|
||||
);
|
||||
logger.info(`[backfill] cursor stored: ${state.nextBatch?.slice(0, 32)}…`);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function deltaSync(
|
||||
client: MatrixClient,
|
||||
args: Args,
|
||||
logger: ReturnType<typeof createLogger>,
|
||||
outDir: string,
|
||||
state: SyncState,
|
||||
): Promise<SyncState> {
|
||||
logger.info(`[delta] resuming from cursor ${state.nextBatch?.slice(0, 32)}…`);
|
||||
const sync = await client.sync({ since: state.nextBatch ?? undefined, timeoutMs: 0 });
|
||||
const joined = sync.rooms?.join ?? {};
|
||||
const roomIds = Object.keys(joined);
|
||||
|
||||
if (roomIds.length === 0) {
|
||||
logger.info('[delta] no new events since last sync');
|
||||
state.nextBatch = sync.next_batch;
|
||||
state.updatedAt = Date.now();
|
||||
saveSyncState(outDir, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
let totalNew = 0;
|
||||
let totalImages = 0;
|
||||
let totalDownloaded = 0;
|
||||
|
||||
for (const roomId of roomIds) {
|
||||
const room = joined[roomId];
|
||||
const events = room.timeline?.events ?? [];
|
||||
if (events.length === 0) continue;
|
||||
|
||||
// Discover (or rediscover) display name for previously-unseen rooms.
|
||||
let info = state.rooms[roomId];
|
||||
if (!info) {
|
||||
const displayName = await resolveRoomDisplayName(client, roomId, logger);
|
||||
info = { slug: roomDirSlug(roomId, displayName), displayName };
|
||||
state.rooms[roomId] = info;
|
||||
logger.info(`[delta] new room discovered: ${info.slug}`);
|
||||
}
|
||||
|
||||
const roomDir = path.join(outDir, info.slug);
|
||||
fs.mkdirSync(roomDir, { recursive: true });
|
||||
|
||||
const messageEvents = events.filter((e) => e.type === 'm.room.message');
|
||||
if (messageEvents.length > 0) {
|
||||
const lines = messageEvents.map((e) => eventToManifestLine(e, client.userId));
|
||||
fs.appendFileSync(path.join(roomDir, 'messages.jsonl'), lines.join('\n') + '\n', 'utf-8');
|
||||
}
|
||||
|
||||
const { downloaded } = await downloadEventImages(client, events, roomDir, logger);
|
||||
const imgCount = events.filter(isImageEvent).length;
|
||||
|
||||
totalNew += messageEvents.length;
|
||||
totalImages += imgCount;
|
||||
totalDownloaded += downloaded;
|
||||
|
||||
logger.info(
|
||||
`[delta] ${info.slug}: +${messageEvents.length} msgs +${imgCount} imgs (downloaded ${downloaded})`,
|
||||
);
|
||||
}
|
||||
|
||||
state.nextBatch = sync.next_batch;
|
||||
state.updatedAt = Date.now();
|
||||
saveSyncState(outDir, state);
|
||||
|
||||
logger.info(
|
||||
`[delta] DONE — rooms_touched=${roomIds.length} new_msgs=${totalNew} new_imgs=${totalImages} downloaded=${totalDownloaded}`,
|
||||
);
|
||||
logger.info(`[delta] cursor updated: ${state.nextBatch?.slice(0, 32)}…`);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function syncAll(
|
||||
client: MatrixClient,
|
||||
args: Args,
|
||||
logger: ReturnType<typeof createLogger>,
|
||||
) {
|
||||
const outDir = path.resolve(process.cwd(), args.dumpDir);
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
logger.info(`[sync] output dir: ${outDir}`);
|
||||
|
||||
const existing = loadSyncState(outDir);
|
||||
if (!existing?.nextBatch || args.forceBackfill) {
|
||||
if (args.forceBackfill) logger.info('[sync] --force-backfill set: ignoring existing cursor');
|
||||
else logger.info('[sync] no cursor on disk → running full backfill');
|
||||
await backfillAll(client, args, logger, outDir);
|
||||
} else {
|
||||
await deltaSync(client, args, logger, outDir, existing);
|
||||
}
|
||||
}
|
||||
|
||||
async function dumpAll(client: MatrixClient, args: Args, logger: ReturnType<typeof createLogger>) {
|
||||
const outDir = path.resolve(process.cwd(), args.dumpDir);
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
logger.info(`[dump] output dir: ${outDir}`);
|
||||
|
||||
const rooms = await client.listRooms();
|
||||
logger.info(`[dump] ${rooms.length} rooms to walk`);
|
||||
|
||||
const overallSummary: Array<{
|
||||
roomId: string;
|
||||
displayName: string | null;
|
||||
eventCount: number;
|
||||
messageCount: number;
|
||||
imageCount: number;
|
||||
downloaded: number;
|
||||
failed: number;
|
||||
}> = [];
|
||||
|
||||
let totalImages = 0;
|
||||
let totalDownloaded = 0;
|
||||
let totalFailed = 0;
|
||||
|
||||
for (const room of rooms) {
|
||||
const slug = safeSlug(room.displayName ?? room.roomId);
|
||||
const roomDir = path.join(outDir, slug);
|
||||
fs.mkdirSync(roomDir, { recursive: true });
|
||||
|
||||
const events = await paginateAllMessages(client, room.roomId, args.maxPages);
|
||||
const messages = events.filter((e) => e.type === 'm.room.message');
|
||||
const images = messages.filter(
|
||||
(e) => e.content.msgtype === 'm.image' || typeof e.content.url === 'string',
|
||||
);
|
||||
|
||||
// Write the messages.jsonl manifest.
|
||||
const manifestPath = path.join(roomDir, 'messages.jsonl');
|
||||
const lines = messages
|
||||
.slice()
|
||||
.reverse() // chronological for human reading
|
||||
.map((e) =>
|
||||
JSON.stringify({
|
||||
event_id: e.event_id,
|
||||
ts: e.origin_server_ts,
|
||||
iso: new Date(e.origin_server_ts).toISOString(),
|
||||
sender: e.sender,
|
||||
isSelf: e.sender === client.userId,
|
||||
msgtype: e.content.msgtype ?? null,
|
||||
body: e.content.body ?? null,
|
||||
mxc: typeof e.content.url === 'string' ? e.content.url : null,
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(manifestPath, lines.join('\n') + '\n', 'utf-8');
|
||||
|
||||
let downloaded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const evt of images) {
|
||||
const mxc = evt.content.url as string | undefined;
|
||||
if (!mxc) continue;
|
||||
try {
|
||||
const media = await client.downloadMedia(mxc);
|
||||
if (!media) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
const ext = extOfContentType(media.contentType);
|
||||
const fname = `${evt.origin_server_ts}_${safeSlug(evt.event_id)}.${ext}`;
|
||||
fs.writeFileSync(path.join(roomDir, fname), media.bytes);
|
||||
downloaded++;
|
||||
} catch (err) {
|
||||
logger.warn(`[dump] failed ${mxc} in ${room.roomId}: ${(err as Error).message}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
totalImages += images.length;
|
||||
totalDownloaded += downloaded;
|
||||
totalFailed += failed;
|
||||
|
||||
logger.info(
|
||||
`[dump] ${slug}: events=${events.length} msgs=${messages.length} imgs=${images.length} ok=${downloaded} fail=${failed}`,
|
||||
);
|
||||
|
||||
overallSummary.push({
|
||||
roomId: room.roomId,
|
||||
displayName: room.displayName,
|
||||
eventCount: events.length,
|
||||
messageCount: messages.length,
|
||||
imageCount: images.length,
|
||||
downloaded,
|
||||
failed,
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, '_summary.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
capturedAt: new Date().toISOString(),
|
||||
userId: client.userId,
|
||||
homeserver: client.homeserver,
|
||||
rooms: overallSummary,
|
||||
totals: {
|
||||
rooms: rooms.length,
|
||||
images: totalImages,
|
||||
downloaded: totalDownloaded,
|
||||
failed: totalFailed,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`[dump] DONE — rooms=${rooms.length} images=${totalImages} downloaded=${totalDownloaded} failed=${totalFailed}`,
|
||||
);
|
||||
logger.info(`[dump] manifest: ${path.join(outDir, '_summary.json')}`);
|
||||
}
|
||||
|
||||
async function getCredentials(logger: ReturnType<typeof createLogger>, refresh: boolean) {
|
||||
if (!refresh) {
|
||||
const cached = await loadCachedCredentials();
|
||||
if (cached) {
|
||||
logger.info(`[matrix] using cached credentials (captured ${ageStr(cached.capturedAt)} ago)`);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[matrix] opening browser to capture access token...');
|
||||
const context = await chromium.launchPersistentContext(SESSION_DIR, {
|
||||
headless: false,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
try {
|
||||
return await extractMatrixCredentials(context, logger);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
function ageStr(ts: number): string {
|
||||
const sec = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
||||
if (sec < 60) return `${sec}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m`;
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h`;
|
||||
return `${Math.floor(sec / 86400)}d`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const logger = createLogger();
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
let creds = await getCredentials(logger, args.refresh);
|
||||
let client = new MatrixClient(creds, logger);
|
||||
|
||||
const reExtract = async () => {
|
||||
logger.warn('[matrix] token rejected — re-extracting');
|
||||
creds = await getCredentials(logger, true);
|
||||
client = new MatrixClient(creds, logger);
|
||||
};
|
||||
|
||||
// --- sync mode (backfill on first run, delta thereafter) -----------
|
||||
if (args.sync) {
|
||||
try {
|
||||
await syncAll(client, args, logger);
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixAuthError) {
|
||||
await reExtract();
|
||||
await syncAll(client, args, logger);
|
||||
} else throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- dump mode (snapshot of /sync — recent rooms only) -------------
|
||||
if (args.dump) {
|
||||
try {
|
||||
await dumpAll(client, args, logger);
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixAuthError) {
|
||||
await reExtract();
|
||||
await dumpAll(client, args, logger);
|
||||
} else throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- send mode -----------------------------------------------------
|
||||
if (args.sendRoomId && args.sendBody) {
|
||||
try {
|
||||
const eventId = await client.sendTextMessage(args.sendRoomId, args.sendBody);
|
||||
console.log(`Sent: event_id=${eventId}`);
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixAuthError) {
|
||||
await reExtract();
|
||||
const eventId = await client.sendTextMessage(args.sendRoomId, args.sendBody);
|
||||
console.log(`Sent (after re-auth): event_id=${eventId}`);
|
||||
} else throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- single-room history mode --------------------------------------
|
||||
if (args.roomId) {
|
||||
let res;
|
||||
try {
|
||||
res = await client.getRoomMessages(args.roomId, { limit: 30 });
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixAuthError) {
|
||||
await reExtract();
|
||||
res = await client.getRoomMessages(args.roomId, { limit: 30 });
|
||||
} else throw err;
|
||||
}
|
||||
|
||||
console.log(`\nRoom ${args.roomId} — ${res.chunk.length} events (most recent first):\n`);
|
||||
for (const event of res.chunk) {
|
||||
if (event.type !== 'm.room.message') {
|
||||
console.log(` [${event.type}] from ${event.sender}`);
|
||||
continue;
|
||||
}
|
||||
const who = event.sender === client.userId ? 'ME' : event.sender;
|
||||
const when = new Date(event.origin_server_ts).toISOString();
|
||||
const body = (event.content.body as string | undefined) ?? '<no body>';
|
||||
const mediaTag = event.content.url ? ` [media: ${event.content.url}]` : '';
|
||||
console.log(` ${when} ${who}: ${body.slice(0, 120)}${body.length > 120 ? '…' : ''}${mediaTag}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- list rooms (default) ------------------------------------------
|
||||
let rooms;
|
||||
try {
|
||||
rooms = await client.listRooms();
|
||||
} catch (err) {
|
||||
if (err instanceof MatrixAuthError) {
|
||||
await reExtract();
|
||||
rooms = await client.listRooms();
|
||||
} else throw err;
|
||||
}
|
||||
|
||||
console.log(`\nLogged in as: ${client.userId}`);
|
||||
console.log(`Homeserver: ${client.homeserver}`);
|
||||
console.log(`Rooms: ${rooms.length}\n`);
|
||||
|
||||
for (const r of rooms.slice(0, 50)) {
|
||||
const unread = r.unreadCount > 0 ? ` (${r.unreadCount} unread)` : '';
|
||||
const last = r.lastEvent
|
||||
? `${new Date(r.lastEvent.origin_server_ts).toISOString()} ${(r.lastEvent.content.body as string | undefined)?.slice(0, 80) ?? `[${r.lastEvent.type}]`}`
|
||||
: '<no events in window>';
|
||||
console.log(`• ${r.displayName ?? r.roomId}${unread}`);
|
||||
console.log(` roomId: ${r.roomId}`);
|
||||
console.log(` last: ${last}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FATAL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Read-EnvFile([string]$Path) {
|
||||
$values = @{}
|
||||
Get-Content -LiteralPath $Path | ForEach-Object {
|
||||
if ($_ -match '^\s*([^#=]+)=(.*)$') {
|
||||
$values[$matches[1].Trim()] = $matches[2].Trim().Trim('"')
|
||||
}
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
$root = Split-Path -Parent $PSScriptRoot
|
||||
$deploy = Read-EnvFile (Join-Path $root '.env')
|
||||
foreach ($required in @('DOKPLOY_URL', 'DOKPLOY_API_KEY')) {
|
||||
if (-not $deploy[$required]) { throw "Missing $required in .env" }
|
||||
}
|
||||
|
||||
$headers = @{ 'x-api-key' = $deploy.DOKPLOY_API_KEY; Accept = 'application/json' }
|
||||
$base = $deploy.DOKPLOY_URL.TrimEnd('/')
|
||||
$core = Invoke-RestMethod -Uri "$base/api/environment.byProjectId?projectId=5hj5VzXYE0fe3SKOx7y5g" -Headers $headers
|
||||
$reddit = @($core.compose | Where-Object { $_.name -eq 'reddit' })[0]
|
||||
if (-not $reddit.env) { throw 'Core/production Reddit service environment was not found' }
|
||||
|
||||
$remote = @{}
|
||||
$reddit.env -split "`n" | ForEach-Object {
|
||||
if ($_ -match '^([^=]+)=(.*)$') { $remote[$matches[1]] = $matches[2] }
|
||||
}
|
||||
foreach ($required in @('OPENROUTER_API_KEY')) {
|
||||
if (-not $remote[$required]) { throw "Deployed environment is missing $required" }
|
||||
}
|
||||
|
||||
$previous = @{}
|
||||
foreach ($entry in $remote.GetEnumerator()) {
|
||||
$previous[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key, 'Process')
|
||||
[Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process')
|
||||
}
|
||||
$previous['WEB_DASHBOARD_TOKEN'] = [Environment]::GetEnvironmentVariable('WEB_DASHBOARD_TOKEN', 'Process')
|
||||
[Environment]::SetEnvironmentVariable('WEB_DASHBOARD_TOKEN', 'create01', 'Process')
|
||||
|
||||
$logDirectory = Join-Path $root 'data'
|
||||
New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null
|
||||
$stdout = Join-Path $logDirectory 'local-dev.stdout.log'
|
||||
$stderr = Join-Path $logDirectory 'local-dev.stderr.log'
|
||||
$process = Start-Process -FilePath 'npm.cmd' -ArgumentList 'run', 'dev' -WorkingDirectory $root -WindowStyle Hidden -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru
|
||||
|
||||
foreach ($name in $previous.Keys) {
|
||||
[Environment]::SetEnvironmentVariable($name, $previous[$name], 'Process')
|
||||
}
|
||||
|
||||
Write-Output "Started local RedditBot process $($process.Id)."
|
||||
Write-Output "Logs: $stdout"
|
||||
Reference in new issue
Block a user