mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
feat: WIP element Chat intergration
This commit is contained in:
263
apps/edr-freight-api/src/modules/chat/matrix.client.ts
Normal file
263
apps/edr-freight-api/src/modules/chat/matrix.client.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
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.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MatrixClient {
|
||||
constructor(
|
||||
@Inject(chatConfig.KEY)
|
||||
private readonly config: ConfigType<typeof chatConfig>,
|
||||
) {}
|
||||
|
||||
/** `@<localpart>:<server_name>` — the one place this format is assembled. */
|
||||
mxid(localpart: string): string {
|
||||
return `@${localpart}:${this.config.serverName}`;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Exchange a fresh access token for a one-shot login_token (5 min TTL). */
|
||||
getLoginToken(accessToken: string): Promise<{ login_token: string }> {
|
||||
return this.request(
|
||||
'POST',
|
||||
'/_matrix/client/v1/login/get_token',
|
||||
{},
|
||||
accessToken,
|
||||
);
|
||||
}
|
||||
|
||||
/** 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(
|
||||
alias: string,
|
||||
name: string,
|
||||
opts: { isSpace?: boolean; parentSpaceId?: string } = {},
|
||||
): Promise<string> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user