Files
edr-platform/apps/edr-freight-api/src/modules/chat/matrix.client.ts
2026-08-17 12:53:08 +00:00

321 lines
11 KiB
TypeScript

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<typeof chatConfig>,
) {}
/**
* 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, '-');
}
/** `@<localpart>:<server_name>` — 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<T>(
method: string,
path: string,
body?: unknown,
token: string = this.config.adminToken,
): Promise<T> {
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<T>(
method: string,
path: string,
body: unknown,
): Promise<T> {
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<T>(
method: string,
path: string,
token?: string,
): Promise<T | null> {
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<string> {
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<string[]> {
const res = await this.request<{ joined: Record<string, unknown> }>(
'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<void> {
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<string> {
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<void> {
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<void> {
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: "<user> 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<void> {
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<void> {
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<void> {
return this.request(
'POST',
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
{ erase: false },
);
}
sendMessage(roomId: string, body: string, formattedBody?: string): Promise<void> {
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 },
);
}
}