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 }, }); } /** Create a room as the AS sender: agents get PL 50 by namespace via the * sender (PL 100); humans invited at default PL. Optionally link into a * space (m.space.child + m.space.parent). Returns the room id. */ async createRoom(options: { name: string; alias?: string; topic?: string; invite?: string[]; spaceId?: string; }): Promise<{ roomId: string; spaceLinked: boolean; spaceError?: string }> { const body: Record = { name: options.name, preset: 'private_chat', invite: options.invite ?? [], power_level_content_override: { users: { [this.senderUserId]: 100 }, // state_default 50 stays; the AS sender can grant agents as needed. }, }; if (options.alias) body.room_alias_name = options.alias; if (options.topic) body.topic = options.topic; const res = await this.request('POST', '/_matrix/client/v3/createRoom', { userId: this.senderUserId, body, }); const roomId = res.room_id; if (typeof roomId !== 'string') throw new Error('createRoom returned no room_id'); if (!options.spaceId) { return { roomId, spaceLinked: false }; } // Space-link failures must NOT throw: the room already exists, and an // exception would hide the room_id (orphaned room, no recovery path). const encodedSpaceId = encodeURIComponent(options.spaceId); const encodedRoomId = encodeURIComponent(roomId); try { await this.request( 'PUT', `/_matrix/client/v3/rooms/${encodedSpaceId}/state/m.space.child/${encodedRoomId}`, { userId: this.senderUserId, body: { via: [this.cfg.domain], suggested: true } }, ); await this.request( 'PUT', `/_matrix/client/v3/rooms/${encodedRoomId}/state/m.space.parent/${encodedSpaceId}`, { userId: this.senderUserId, body: { via: [this.cfg.domain], canonical: true } }, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); return { roomId, spaceLinked: false, spaceError: message }; } return { roomId, spaceLinked: true }; } /** 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 }, }); } /** Read an account_data object on the AS sender user. Returns null when the * key has never been written (M_NOT_FOUND), so callers can treat that as an * empty store; any other error propagates. */ async getSenderAccountData(type: string): Promise | null> { const user = encodeURIComponent(this.senderUserId); const key = encodeURIComponent(type); try { return await this.request('GET', `/_matrix/client/v3/user/${user}/account_data/${key}`, { userId: this.senderUserId, }); } catch (err) { if (err instanceof MatrixApiError && err.errcode === 'M_NOT_FOUND') return null; throw err; } } /** Write an account_data object on the AS sender user. */ async setSenderAccountData(type: string, content: Record): Promise { const user = encodeURIComponent(this.senderUserId); const key = encodeURIComponent(type); await this.request('PUT', `/_matrix/client/v3/user/${user}/account_data/${key}`, { userId: this.senderUserId, body: content, }); } }