import crypto from 'node:crypto'; import type { AppserviceConfig } from './types.js'; export interface SendMessageOptions { roomId: string; /** Agent slug, e.g. "pi0-web1" -> @agent-pi0-web1:domain */ agent: string; body: string; /** Request event id to thread off (m.thread, spec v1.4). */ threadRoot?: string; msgtype?: string; /** Extra content keys merged into the message content (e.g. org.uscllc.agent). */ extraContent?: Record; } export class MatrixApiError extends Error { constructor( readonly status: number, readonly errcode: string | undefined, message: string, ) { super(message); this.name = 'MatrixApiError'; } } type FetchLike = typeof fetch; /** * Acts on the homeserver as appservice-namespaced virtual users * (Application Service API: as_token auth + user_id impersonation). */ export class AppserviceIntent { private readonly registered = new Set(); private readonly joined = new Set(); private readonly fetchImpl: FetchLike; constructor( private readonly cfg: AppserviceConfig, fetchImpl?: FetchLike, ) { this.fetchImpl = fetchImpl ?? fetch; } get userPrefix(): string { return this.cfg.userPrefix ?? 'agent-'; } get senderUserId(): string { return `@${this.cfg.senderLocalpart ?? 'mosaic-as'}:${this.cfg.domain}`; } agentLocalpart(agent: string): string { const slug = agent.toLowerCase(); if (!/^[a-z0-9][a-z0-9_.-]*$/.test(slug)) { throw new Error(`invalid agent slug: ${agent}`); } return `${this.userPrefix}${slug}`; } agentUserId(agent: string): string { return `@${this.agentLocalpart(agent)}:${this.cfg.domain}`; } private async request( method: string, path: string, options: { userId?: string; body?: unknown } = {}, ): Promise> { const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path); if (options.userId) { url.searchParams.set('user_id', options.userId); } const res = await this.fetchImpl(url, { method, headers: { Authorization: `Bearer ${this.cfg.asToken}`, '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 MatrixApiError( res.status, typeof data.errcode === 'string' ? data.errcode : undefined, `${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`, ); } return data; } /** Register the virtual user if it does not exist yet. Idempotent. */ async ensureRegistered(agent: string): Promise { const localpart = this.agentLocalpart(agent); const userId = this.agentUserId(agent); if (this.registered.has(userId)) return userId; try { await this.request('POST', '/_matrix/client/v3/register', { body: { type: 'm.login.application_service', username: localpart }, }); } catch (err) { if (!(err instanceof MatrixApiError && err.errcode === 'M_USER_IN_USE')) { throw err; } } this.registered.add(userId); return userId; } /** Join the agent to a room; on invite-only rooms the AS sender invites first. */ async ensureJoined(roomId: string, agent: string): Promise { const userId = await this.ensureRegistered(agent); const key = `${userId} ${roomId}`; if (this.joined.has(key)) return; const room = encodeURIComponent(roomId); try { await this.request('POST', `/_matrix/client/v3/rooms/${room}/join`, { userId, body: {} }); } catch (err) { if (!(err instanceof MatrixApiError && err.errcode === 'M_FORBIDDEN')) throw err; await this.request('POST', `/_matrix/client/v3/rooms/${room}/invite`, { userId: this.senderUserId, body: { user_id: userId }, }); await this.request('POST', `/_matrix/client/v3/rooms/${room}/join`, { userId, body: {} }); } this.joined.add(key); } /** Send a message AS the agent's virtual user. */ async sendAsAgent(options: SendMessageOptions): Promise { const userId = this.agentUserId(options.agent); await this.ensureJoined(options.roomId, options.agent); const content: Record = { msgtype: options.msgtype ?? 'm.text', body: options.body, ...options.extraContent, }; if (options.threadRoot) { content['m.relates_to'] = { rel_type: 'm.thread', event_id: options.threadRoot, is_falling_back: true, 'm.in_reply_to': { event_id: options.threadRoot }, }; } const txn = `mosaic-as-${crypto.randomUUID()}`; const room = encodeURIComponent(options.roomId); const res = await this.request( 'PUT', `/_matrix/client/v3/rooms/${room}/send/m.room.message/${txn}`, { userId, body: content }, ); return typeof res.event_id === 'string' ? res.event_id : undefined; } /** Set the agent's typing indicator in a room. */ async setTyping( roomId: string, agent: string, typing: boolean, timeoutMs = 30000, ): Promise { const userId = await this.ensureRegistered(agent); const room = encodeURIComponent(roomId); const user = encodeURIComponent(userId); await this.request('PUT', `/_matrix/client/v3/rooms/${room}/typing/${user}`, { userId, body: typing ? { typing: true, timeout: timeoutMs } : { typing: false }, }); } /** Set display name for an agent's virtual user. */ async setDisplayName(agent: string, displayName: string): Promise { const userId = await this.ensureRegistered(agent); const user = encodeURIComponent(userId); await this.request('PUT', `/_matrix/client/v3/profile/${user}/displayname`, { userId, body: { displayname: displayName }, }); } }