185 lines
5.9 KiB
TypeScript
185 lines
5.9 KiB
TypeScript
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<string, unknown>;
|
|
}
|
|
|
|
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<string>();
|
|
private readonly joined = new Set<string>();
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown>;
|
|
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<string> {
|
|
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<void> {
|
|
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<string | undefined> {
|
|
const userId = this.agentUserId(options.agent);
|
|
await this.ensureJoined(options.roomId, options.agent);
|
|
const content: Record<string, unknown> = {
|
|
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<void> {
|
|
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<void> {
|
|
const userId = await this.ensureRegistered(agent);
|
|
const user = encodeURIComponent(userId);
|
|
await this.request('PUT', `/_matrix/client/v3/profile/${user}/displayname`, {
|
|
userId,
|
|
body: { displayname: displayName },
|
|
});
|
|
}
|
|
}
|