import { Inject, Injectable } from '@nestjs/common'; import type { ConfigType } from '@nestjs/config'; import chatConfig from '../../config/chat.config'; /** * Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API * calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a * browser/Element concern; the server side only ever provisions rooms/users * and posts bot messages, so a fetch wrapper is the whole job. * * All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That * same account also posts the notification-bridge messages (see * ChatBridgeService) — one bot/admin account covers both jobs, no separate * bot user needed. */ /** * Localpart of a staff member's MXID: their name, plus the first 6 hex of * their freight user id. * * The tail is not decoration. Names collide — 19 of the 114 users in the dev * IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are * two different people) — and an MXID is permanent, so a bare slug would hand * two employees the same Matrix account and each other's rooms. The id is * already random, so 6 hex of it separates them without a lookup or a mapping * table, and keeps the derivation pure: ChatSsoService (which mints the JWT * `sub`) and ChatProvisioningService (which force-joins rooms) must agree on * this string exactly or they provision two accounts per person. */ export function chatLocalpart(userId: string, displayName: string): string { const slug = displayName // NFKD splits an accent off its letter; the non-alnum sweep below then // folds the leftover mark into the same `-` run as the neighbouring space. .normalize('NFKD') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 40); // Amharic-only names slug to nothing — the tail still makes it unique. return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`; } @Injectable() export class MatrixClient { constructor( @Inject(chatConfig.KEY) private readonly config: ConfigType, ) {} /** * Alias localparts go in a URL path segment, so a `/` in one is fatal: * Synapse decodes the path before routing, and `%2F` splits the request into * a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404 * as "no such room" and ensureRoom then tries to create the same broken alias * on every run. Position keys are `edr_freight_app/opn` shaped, so this hits * every dept room but the handful whose key happens to be a bare word. */ private static aliasSafe(alias: string): string { return alias.replace(/[^A-Za-z0-9._=-]/g, '-'); } /** `@:` — the one place this format is assembled. */ mxid(localpart: string): string { return `@${localpart}:${this.config.serverName}`; } /** The MXID of a freight user — see {@link chatLocalpart}. */ mxidFor(userId: string, displayName: string): string { return this.mxid(chatLocalpart(userId, displayName)); } get serverName(): string { return this.config.serverName; } private async request( method: string, path: string, body?: unknown, token: string = this.config.adminToken, ): Promise { const res = await fetch(`${this.config.baseUrl}${path}`, { method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, ); } if (res.status === 204) return undefined as T; return (await res.json()) as T; } /** No auth — only /login accepts a bare JWT with nothing else on the request. */ private async publicRequest( method: string, path: string, body: unknown, ): Promise { const res = await fetch(`${this.config.baseUrl}${path}`, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, ); } return (await res.json()) as T; } /** 404 → null. Every other non-2xx still throws via {@link request}. */ private async requestOrNull( method: string, path: string, token?: string, ): Promise { const res = await fetch(`${this.config.baseUrl}${path}`, { method, headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` }, }); if (res.status === 404) return null; if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error( `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, ); } return (await res.json()) as T; } /** Sign an already-authenticated freight session into a Matrix session. */ loginWithJwt( jwt: string, ): Promise<{ access_token: string; user_id: string; device_id: string }> { return this.publicRequest('POST', '/_matrix/client/v3/login', { type: 'org.matrix.login.jwt', token: jwt, initial_device_display_name: 'EDR Backoffice', }); } /** The account behind MATRIX_ADMIN_TOKEN — used to exclude the bot itself from membership reconciliation. */ async whoami(): Promise { const res = await this.request<{ user_id: string }>( 'GET', '/_matrix/client/v3/account/whoami', ); return res.user_id; } /** Currently-joined user ids for a room (not full member-event state). */ async joinedMembers(roomId: string): Promise { const res = await this.request<{ joined: Record }>( 'GET', `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`, ); return Object.keys(res.joined); } // No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token // is rate limited to 1 request per user per MINUTE, hardcoded in Synapse // (rest/client/login_token_request.py: "Ratelimit aggressively … could be // abused by a malicious client to create many sessions") and not settable // from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED. // ChatSsoService hands Element the session from loginWithJwt directly // instead, which needs no second call. /** null when the alias doesn't resolve to a room yet. */ resolveAlias(alias: string): Promise<{ room_id: string } | null> { return this.requestOrNull( 'GET', `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, ); } createRoom(input: { alias: string; name: string; topic?: string; isSpace?: boolean; parentSpaceId?: string; }): Promise<{ room_id: string }> { return this.request('POST', '/_matrix/client/v3/createRoom', { room_alias_name: input.alias, name: input.name, topic: input.topic, preset: 'private_chat', creation_content: input.isSpace ? { type: 'm.space' } : undefined, initial_state: input.parentSpaceId ? [ { type: 'm.space.parent', state_key: input.parentSpaceId, content: { via: [this.config.serverName], canonical: true }, }, ] : undefined, }); } addToSpace(spaceId: string, childRoomId: string): Promise { return this.request( 'PUT', `/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(childRoomId)}`, { via: [this.config.serverName] }, ); } /** * Get-or-create by alias — the room identity scheme this whole module * relies on instead of a local id-mapping table. Idempotent: safe to call * on every reconcile run and every bridged notification alike. */ async ensureRoom( rawAlias: string, name: string, opts: { isSpace?: boolean; parentSpaceId?: string } = {}, ): Promise { const alias = MatrixClient.aliasSafe(rawAlias); const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`); if (existing) return existing.room_id; const { room_id } = await this.createRoom({ alias, name, isSpace: opts.isSpace, parentSpaceId: opts.parentSpaceId, }); if (opts.parentSpaceId) { await this.addToSpace(opts.parentSpaceId, room_id); } return room_id; } /** * Create the account if absent (no password — this deployment is JWT-SSO * only), or no-op if it already exists. Needed before force-joining a * position holder who has never clicked "Chat": accounts are otherwise * only created lazily on first JWT login, and the admin join API 404s * ("User not found") on an account that doesn't exist yet. */ async ensureUser(userId: string, displayName?: string): Promise { const existing = await this.requestOrNull<{ name: string }>( 'GET', `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, ); if (existing) return; await this.request( 'PUT', `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, displayName ? { displayname: displayName } : {}, ); } /** Server-admin force-join — no invite to accept, works even mid-outage for the invitee. */ forceJoin(roomIdOrAlias: string, userId: string): Promise { return this.request( 'POST', `/_synapse/admin/v1/join/${encodeURIComponent(roomIdOrAlias)}`, { user_id: userId }, ); } /** * Force-join, treating "already a member" as success. Synapse answers a * repeat join with 403 `M_FORBIDDEN: " is already in the room."`, which * is a failure only if you assumed you knew the membership first. Callers * that just want someone in a room (sign-in, reconcile racing itself) want * this; the raw 403 tells them nothing they can act on. */ async ensureJoined(roomIdOrAlias: string, userId: string): Promise { try { await this.forceJoin(roomIdOrAlias, userId); } catch (err) { if (!/already in the room/i.test((err as Error).message)) throw err; } } kick(roomId: string, userId: string, reason: string): Promise { return this.request( 'POST', `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, { user_id: userId, reason }, ); } /** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */ deactivateUser(userId: string): Promise { return this.request( 'POST', `/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, { erase: false }, ); } sendMessage(roomId: string, body: string, formattedBody?: string): Promise { const txnId = `edr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return this.request( 'PUT', `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, formattedBody ? { msgtype: 'm.text', body, format: 'org.matrix.custom.html', formatted_body: formattedBody, } : { msgtype: 'm.text', body }, ); } }