/** * 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; body?: unknown } = {}, ): Promise> { 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; 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 { 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 { 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, ): Promise { 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 { return this.sendEvent(roomId, 'm.room.message', content as unknown as Record); } /** Join a room (by id or alias). Idempotent on the server. */ async joinRoom(roomIdOrAlias: string): Promise { 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 { 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>) : []; const bySlug = new Map(); for (const ev of chunk) { const content = ev.content as Record | undefined; if (!content || content.msgtype !== 'mosaic.presence') continue; const agent = content.agent as Record | 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()]; } }