import type { Logger } from "pino"; import { isMatrixEvent, parseMxc, type MatrixEvent, type RoomEncryption } from "./events.js"; import { PermanentArchiveError, parseContentLength } from "./inspector.js"; export class MatrixHttpError extends Error { constructor( readonly status: number, readonly errcode: string | null, message: string, ) { super(message); } } export interface JoinedRoomSync { timeline: { events: MatrixEvent[]; limited: boolean; prevBatch: string | null }; stateEvents: MatrixEvent[]; } export interface SyncResult { nextBatch: string; joined: Map; invited: string[]; } export interface MediaDownload { body: ReadableStream; contentLength: number | null; } /** The narrow Matrix surface the processor needs; faked in tests. */ export interface MatrixPort { readonly userId: string; sync(since: string | null, signal: AbortSignal): Promise; /** Older events between `from` (a prev_batch) back to `to` (the previous sync token), oldest first. */ backfill(roomId: string, from: string, to: string, signal: AbortSignal): Promise; roomEncryption(roomId: string, signal: AbortSignal): Promise; joinRoom(roomId: string, signal: AbortSignal): Promise; downloadMedia(mxcUri: string, signal: AbortSignal): Promise; sendNotice(roomId: string, txnId: string, content: Record, signal: AbortSignal): Promise; /** Phase-B export gate: current `m.room.power_levels` for a mapped room. */ roomPowerLevels(roomId: string, signal: AbortSignal): Promise>; /** Every room the account has joined (encryption lint input). */ joinedRooms(signal: AbortSignal): Promise; } const SYNC_TIMEOUT_MS = 30_000; const MAX_REDIRECTS = 3; const BACKFILL_PAGES = 20; const TIMELINE_TYPES = ["m.room.message", "m.room.redaction", "m.room.encrypted", "m.room.encryption"]; /** Client-side filter: mapped rooms only, minimal timeline types, no presence/ephemeral noise. */ export function syncFilter(roomIds: readonly string[]): Record { return { presence: { not_types: ["*"] }, account_data: { not_types: ["*"] }, room: { rooms: [...roomIds], account_data: { not_types: ["*"] }, ephemeral: { not_types: ["*"] }, state: { types: ["m.room.encryption"], lazy_load_members: true }, timeline: { limit: 50, types: TIMELINE_TYPES }, }, }; } /** Authenticated media requires spec v1.11+ (or the MSC3916 stable flag on older advertisers). */ export function supportsAuthenticatedMedia(versions: unknown): boolean { if (typeof versions !== "object" || versions === null) return false; const record = versions as { versions?: unknown; unstable_features?: unknown }; const list = Array.isArray(record.versions) ? record.versions.filter((v): v is string => typeof v === "string") : []; const modern = list.some((v) => { const match = /^v1\.(\d+)$/.exec(v); return match !== null && Number(match[1]) >= 11; }); const features = record.unstable_features; const flagged = typeof features === "object" && features !== null && (features as Record)["org.matrix.msc3916.stable"] === true; return modern || flagged; } /** * Redirect policy for media downloads. Bearer auth is only ever sent to the * homeserver origin; other hosts must be explicitly allowlisted (e.g. the R2 * endpoint) and are fetched without credentials. Downgrades to http are refused. */ export function resolveRedirect( current: URL, location: string, homeserver: URL, allowedHosts: readonly string[], ): { url: URL; sendAuth: boolean } { const url = new URL(location, current); if (url.origin === homeserver.origin) return { url, sendAuth: true }; if (url.protocol !== "https:") throw new PermanentArchiveError(`refusing non-https media redirect to ${url.host}`); if (!allowedHosts.includes(url.hostname.toLowerCase())) { throw new PermanentArchiveError(`media redirect to unapproved host ${url.hostname}`); } return { url, sendAuth: false }; } function events(value: unknown): MatrixEvent[] { return Array.isArray(value) ? value.filter(isMatrixEvent) : []; } function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record) : {}; } export function parseSync(body: unknown): SyncResult { const root = record(body); if (typeof root.next_batch !== "string") throw new Error("sync response missing next_batch"); const rooms = record(root.rooms); const joined = new Map(); for (const [roomId, raw] of Object.entries(record(rooms.join))) { const room = record(raw); const timeline = record(room.timeline); joined.set(roomId, { timeline: { events: events(timeline.events), limited: timeline.limited === true, prevBatch: typeof timeline.prev_batch === "string" ? timeline.prev_batch : null, }, stateEvents: events(record(room.state).events), }); } return { nextBatch: root.next_batch, joined, invited: Object.keys(record(rooms.invite)) }; } export interface MatrixClientOptions { homeserver: URL; user: string; password: string; deviceId: string; mediaRedirectHosts: string[]; roomIds: string[]; logger: Logger; } /** Plain client-server sync-loop client (not an appservice). No crypto: encrypted content is never touched. */ export class MatrixClient implements MatrixPort { private accessToken: string | null = null; private resolvedUserId = ""; private readonly filter: string; constructor(private readonly opts: MatrixClientOptions) { this.filter = JSON.stringify(syncFilter(opts.roomIds)); } get userId(): string { return this.resolvedUserId; } async login(signal: AbortSignal): Promise { const versions = await this.request("GET", "/_matrix/client/versions", { signal, auth: false }); if (!supportsAuthenticatedMedia(versions)) { throw new Error("homeserver does not support authenticated media (v1.11); refusing to start"); } const body = record(await this.request("POST", "/_matrix/client/v3/login", { signal, auth: false, json: { type: "m.login.password", identifier: { type: "m.id.user", user: this.opts.user }, password: this.opts.password, device_id: this.opts.deviceId, initial_device_display_name: "Hub archive bot", }, })); if (typeof body.access_token !== "string" || typeof body.user_id !== "string") { throw new Error("login response missing access_token/user_id"); } this.accessToken = body.access_token; this.resolvedUserId = body.user_id; } async sync(since: string | null, signal: AbortSignal): Promise { const query = new URLSearchParams({ filter: this.filter, timeout: String(since === null ? 0 : SYNC_TIMEOUT_MS) }); if (since !== null) query.set("since", since); const timeout = AbortSignal.any([signal, AbortSignal.timeout(SYNC_TIMEOUT_MS + 30_000)]); return parseSync(await this.request("GET", `/_matrix/client/v3/sync?${query.toString()}`, { signal: timeout })); } async backfill(roomId: string, from: string, to: string, signal: AbortSignal): Promise { const collected: MatrixEvent[] = []; let cursor: string | null = from; for (let page = 0; page < BACKFILL_PAGES && cursor !== null; page += 1) { const query = new URLSearchParams({ from: cursor, to, dir: "b", limit: "100", filter: JSON.stringify({ types: TIMELINE_TYPES }), }); const body = record(await this.request( "GET", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/messages?${query.toString()}`, { signal }, )); const chunk = events(body.chunk); collected.push(...chunk); cursor = chunk.length > 0 && typeof body.end === "string" ? body.end : null; } if (cursor !== null) this.opts.logger.warn({ roomId }, "matrix: backfill page budget exhausted; older gap events skipped"); return collected.reverse(); } async roomEncryption(roomId: string, signal: AbortSignal): Promise { try { const body = record(await this.request( "GET", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`, { signal }, )); // Any present encryption state (even an unknown algorithm) means encrypted. return Object.keys(body).length > 0 ? "encrypted" : "unknown"; } catch (err) { if (err instanceof MatrixHttpError && err.status === 404 && err.errcode === "M_NOT_FOUND") return "unencrypted"; if (err instanceof MatrixHttpError && (err.status === 403 || err.status === 404)) return "unknown"; throw err; } } async joinRoom(roomId: string, signal: AbortSignal): Promise { await this.request("POST", `/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, { signal, json: {} }); } async sendNotice(roomId: string, txnId: string, content: Record, signal: AbortSignal): Promise { await this.request( "PUT", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`, { signal, json: content }, ); } async roomPowerLevels(roomId: string, signal: AbortSignal): Promise> { const body = record(await this.request( "GET", `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.power_levels/`, { signal }, )); return body; } async joinedRooms(signal: AbortSignal): Promise { const body = record(await this.request("GET", "/_matrix/client/v3/joined_rooms", { signal })); const joined = body.joined_rooms; return Array.isArray(joined) ? joined.filter((r): r is string => typeof r === "string") : []; } /** Authenticated media endpoint only; the URL is derived from a validated mxc URI, never user-supplied HTTP. */ async downloadMedia(mxcUri: string, signal: AbortSignal): Promise { const { serverName, mediaId } = parseMxc(mxcUri); let url = new URL( `/_matrix/client/v1/media/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}?allow_redirect=true`, this.opts.homeserver, ); let sendAuth = true; for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { const headers: Record = {}; if (sendAuth) headers.authorization = `Bearer ${this.token()}`; const res = await fetch(url, { headers, redirect: "manual", signal }); if (res.status >= 300 && res.status < 400) { const location = res.headers.get("location"); await res.body?.cancel(); if (location === null) throw new Error("media redirect without Location"); ({ url, sendAuth } = resolveRedirect(url, location, this.opts.homeserver, this.opts.mediaRedirectHosts)); continue; } if (!res.ok || res.body === null) { const error = await this.toError(res); // Missing/forbidden/too-large media will not appear on retry. if (res.status === 404 || res.status === 403 || res.status === 413) throw new PermanentArchiveError(error.message); throw error; } return { body: res.body, contentLength: parseContentLength(res.headers.get("content-length")) }; } throw new PermanentArchiveError("too many media redirects"); } private token(): string { if (this.accessToken === null) throw new Error("matrix client not logged in"); return this.accessToken; } private async request( method: string, path: string, opts: { signal: AbortSignal; auth?: boolean; json?: unknown }, ): Promise { const headers: Record = { accept: "application/json" }; if (opts.auth !== false) headers.authorization = `Bearer ${this.token()}`; if (opts.json !== undefined) headers["content-type"] = "application/json"; const res = await fetch(new URL(path, this.opts.homeserver), { method, headers, body: opts.json === undefined ? undefined : JSON.stringify(opts.json), redirect: "error", signal: opts.signal, }); if (!res.ok) throw await this.toError(res); return res.json() as Promise; } private async toError(res: Response): Promise { let errcode: string | null = null; let message = `matrix HTTP ${res.status}`; try { const body = record(await res.json()); if (typeof body.errcode === "string") errcode = body.errcode; if (typeof body.error === "string") message = `${message}: ${body.error}`; } catch { // Non-JSON error body; status is enough. } return new MatrixHttpError(res.status, errcode, message); } }