feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
Co-authored-by: jason.woltje <[email protected]> Co-committed-by: jason.woltje <[email protected]>
This commit was merged in pull request #888.
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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()];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user