Co-authored-by: jason.woltje <[email protected]> Co-committed-by: jason.woltje <[email protected]>
205 lines
7.0 KiB
TypeScript
205 lines
7.0 KiB
TypeScript
/**
|
|
* Minimal Matrix Client-Server API client for the P1 presence slice.
|
|
*
|
|
* Deliberately tiny: only the calls presence needs (whoami, set native
|
|
* presence, send a timeline event, read recent timeline). Auth is a single
|
|
* bearer token; an optional `actAsUserId` enables Application-Service
|
|
* masquerade (`?user_id=`) so the P1 provisioner can drive several virtual
|
|
* agents with one as_token in dev (RFC-001 §2.2 step 4/Appendix A). Agents
|
|
* holding their own access_token simply omit `actAsUserId`.
|
|
*
|
|
* `fetch` is injectable for unit tests.
|
|
*/
|
|
|
|
import crypto from 'node:crypto';
|
|
|
|
import type {
|
|
HeartbeatObservation,
|
|
MatrixPresence,
|
|
PresenceHeartbeatContent,
|
|
PresenceStatus,
|
|
} from './types.js';
|
|
|
|
export interface MatrixClientConfig {
|
|
/** Client-Server API base, e.g. https://matrix.localhost:8448 */
|
|
homeserverUrl: string;
|
|
/** Bearer token (a per-agent access_token, or an as_token for masquerade). */
|
|
accessToken: string;
|
|
/** If set, all calls masquerade as this MXID via ?user_id= (AS mode). */
|
|
actAsUserId?: string;
|
|
}
|
|
|
|
export class MatrixError extends Error {
|
|
constructor(
|
|
readonly status: number,
|
|
readonly errcode: string | undefined,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = 'MatrixError';
|
|
}
|
|
}
|
|
|
|
type FetchLike = typeof fetch;
|
|
|
|
/** Map our authoritative liveness state to the native Matrix presence EDU. */
|
|
export function toMatrixPresence(status: PresenceStatus): MatrixPresence {
|
|
switch (status) {
|
|
case 'online':
|
|
return 'online';
|
|
case 'away':
|
|
return 'unavailable';
|
|
case 'offline':
|
|
return 'offline';
|
|
}
|
|
}
|
|
|
|
export class MinimalMatrixClient {
|
|
private readonly fetchImpl: FetchLike;
|
|
|
|
constructor(
|
|
private readonly cfg: MatrixClientConfig,
|
|
fetchImpl?: FetchLike,
|
|
) {
|
|
this.fetchImpl = fetchImpl ?? fetch;
|
|
}
|
|
|
|
private async request(
|
|
method: string,
|
|
path: string,
|
|
options: { query?: Record<string, string>; body?: unknown } = {},
|
|
): Promise<Record<string, unknown>> {
|
|
const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path);
|
|
if (this.cfg.actAsUserId) {
|
|
url.searchParams.set('user_id', this.cfg.actAsUserId);
|
|
}
|
|
for (const [k, v] of Object.entries(options.query ?? {})) {
|
|
url.searchParams.set(k, v);
|
|
}
|
|
const res = await this.fetchImpl(url, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${this.cfg.accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
});
|
|
const text = await res.text();
|
|
const data = (text ? JSON.parse(text) : {}) as Record<string, unknown>;
|
|
if (!res.ok) {
|
|
throw new MatrixError(
|
|
res.status,
|
|
typeof data.errcode === 'string' ? data.errcode : undefined,
|
|
`${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`,
|
|
);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
/** GET /account/whoami — resolves the acting MXID. */
|
|
async whoami(): Promise<string> {
|
|
const data = await this.request('GET', '/_matrix/client/v3/account/whoami');
|
|
if (typeof data.user_id !== 'string') {
|
|
throw new MatrixError(500, undefined, 'whoami returned no user_id');
|
|
}
|
|
return data.user_id;
|
|
}
|
|
|
|
/**
|
|
* Set the native Matrix presence EDU (so Element shows the right dot for
|
|
* humans). NOT the authoritative liveness signal — the heartbeat is.
|
|
*/
|
|
async setPresence(userId: string, status: PresenceStatus, statusMsg?: string): Promise<void> {
|
|
const user = encodeURIComponent(userId);
|
|
await this.request('PUT', `/_matrix/client/v3/presence/${user}/status`, {
|
|
body: {
|
|
presence: toMatrixPresence(status),
|
|
...(statusMsg ? { status_msg: statusMsg } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Send an arbitrary timeline event; returns its event_id. */
|
|
async sendEvent(
|
|
roomId: string,
|
|
eventType: string,
|
|
content: Record<string, unknown>,
|
|
): Promise<string> {
|
|
const room = encodeURIComponent(roomId);
|
|
const txn = `mosaic-comms-${crypto.randomUUID()}`;
|
|
const data = await this.request(
|
|
'PUT',
|
|
`/_matrix/client/v3/rooms/${room}/send/${encodeURIComponent(eventType)}/${txn}`,
|
|
{ body: content },
|
|
);
|
|
if (typeof data.event_id !== 'string') {
|
|
throw new MatrixError(500, undefined, 'send returned no event_id');
|
|
}
|
|
return data.event_id;
|
|
}
|
|
|
|
/** Post a `mosaic.presence` heartbeat (m.room.message carrier) to the room. */
|
|
async sendHeartbeat(roomId: string, content: PresenceHeartbeatContent): Promise<string> {
|
|
return this.sendEvent(roomId, 'm.room.message', content as unknown as Record<string, unknown>);
|
|
}
|
|
|
|
/** Join a room (by id or alias). Idempotent on the server. */
|
|
async joinRoom(roomIdOrAlias: string): Promise<string> {
|
|
const data = await this.request(
|
|
'POST',
|
|
`/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}`,
|
|
{ body: {} },
|
|
);
|
|
if (typeof data.room_id !== 'string') {
|
|
throw new MatrixError(500, undefined, 'join returned no room_id');
|
|
}
|
|
return data.room_id;
|
|
}
|
|
|
|
/**
|
|
* Read recent `mosaic.presence` heartbeats from a room and reduce them to the
|
|
* latest observation per agent. Walks the timeline backwards (most-recent
|
|
* first) and keeps, per slug, the beat with the highest seq.
|
|
*
|
|
* `lastSeenTs` uses the server's `origin_server_ts` (honest "when we last
|
|
* heard from it"), falling back to the agent-stamped envelope `ts`.
|
|
*/
|
|
async readHeartbeats(roomId: string, limit = 200): Promise<HeartbeatObservation[]> {
|
|
const room = encodeURIComponent(roomId);
|
|
const data = await this.request('GET', `/_matrix/client/v3/rooms/${room}/messages`, {
|
|
query: { dir: 'b', limit: String(limit) },
|
|
});
|
|
const chunk = Array.isArray(data.chunk) ? (data.chunk as Array<Record<string, unknown>>) : [];
|
|
const bySlug = new Map<string, HeartbeatObservation>();
|
|
|
|
for (const ev of chunk) {
|
|
const content = ev.content as Record<string, unknown> | undefined;
|
|
if (!content || content.msgtype !== 'mosaic.presence') continue;
|
|
const agent = content.agent as Record<string, unknown> | undefined;
|
|
const slug = agent && typeof agent.slug === 'string' ? agent.slug : undefined;
|
|
const mxid =
|
|
agent && typeof agent.mxid === 'string'
|
|
? agent.mxid
|
|
: typeof ev.sender === 'string'
|
|
? ev.sender
|
|
: undefined;
|
|
if (!slug || !mxid) continue;
|
|
|
|
const seq = typeof content.seq === 'number' ? content.seq : 0;
|
|
const serverTs = typeof ev.origin_server_ts === 'number' ? ev.origin_server_ts : undefined;
|
|
const envelopeTs = typeof content.ts === 'number' ? content.ts : undefined;
|
|
const lastSeenTs = serverTs ?? envelopeTs ?? 0;
|
|
const assertedStatus =
|
|
content.status === 'online' || content.status === 'away' || content.status === 'offline'
|
|
? (content.status as PresenceStatus)
|
|
: 'offline';
|
|
|
|
const prev = bySlug.get(slug);
|
|
if (!prev || seq > prev.lastSeq) {
|
|
bySlug.set(slug, { slug, mxid, lastSeenTs, lastSeq: seq, assertedStatus });
|
|
}
|
|
}
|
|
return [...bySlug.values()];
|
|
}
|
|
}
|