mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 00:30:44 +00:00
913 lines
26 KiB
TypeScript
913 lines
26 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
import type { MatrixQaObservedEvent } from "./events.js";
|
|
import { requestMatrixJson, type MatrixQaFetchLike } from "./request.js";
|
|
import {
|
|
createMatrixQaRoomObserver,
|
|
primeMatrixQaRoom,
|
|
waitForMatrixQaRoomEvent,
|
|
waitForOptionalMatrixQaRoomEvent,
|
|
type MatrixQaRoomObserver,
|
|
} from "./sync.js";
|
|
import {
|
|
findMatrixQaProvisionedRoom,
|
|
type MatrixQaParticipantRole,
|
|
type MatrixQaProvisionedTopology,
|
|
type MatrixQaTopologyRoomSpec,
|
|
type MatrixQaTopologySpec,
|
|
} from "./topology.js";
|
|
|
|
export type { MatrixQaRoomObserver } from "./sync.js";
|
|
|
|
type MatrixQaAuthStage = "m.login.dummy" | "m.login.registration_token";
|
|
|
|
type MatrixQaRegisterResponse = {
|
|
access_token?: string;
|
|
device_id?: string;
|
|
user_id?: string;
|
|
};
|
|
|
|
type MatrixQaLoginResponse = MatrixQaRegisterResponse;
|
|
|
|
type MatrixQaRoomCreateResponse = {
|
|
room_id?: string;
|
|
};
|
|
|
|
type MatrixQaSendMessageContent = {
|
|
body: string;
|
|
format?: "org.matrix.custom.html";
|
|
formatted_body?: string;
|
|
"m.new_content"?: MatrixQaSendMessageContent;
|
|
"m.mentions"?: {
|
|
user_ids?: string[];
|
|
};
|
|
"m.relates_to"?:
|
|
| {
|
|
rel_type: "m.thread";
|
|
event_id: string;
|
|
is_falling_back: true;
|
|
"m.in_reply_to": {
|
|
event_id: string;
|
|
};
|
|
}
|
|
| {
|
|
rel_type: "m.replace";
|
|
event_id: string;
|
|
};
|
|
msgtype: "m.text";
|
|
};
|
|
|
|
type MatrixQaMediaMessageType = "m.audio" | "m.file" | "m.image" | "m.video";
|
|
|
|
type MatrixQaSendMediaMessageContent = Omit<MatrixQaSendMessageContent, "msgtype"> & {
|
|
filename?: string;
|
|
info?: {
|
|
mimetype?: string;
|
|
size?: number;
|
|
};
|
|
msgtype: MatrixQaMediaMessageType;
|
|
url: string;
|
|
};
|
|
|
|
type MatrixQaSendReactionContent = {
|
|
"m.relates_to": {
|
|
event_id: string;
|
|
key: string;
|
|
rel_type: "m.annotation";
|
|
};
|
|
};
|
|
|
|
type MatrixQaRoomInitialState = Array<{
|
|
content: Record<string, unknown>;
|
|
state_key: string;
|
|
type: string;
|
|
}>;
|
|
|
|
type MatrixQaUiaaResponse = {
|
|
completed?: string[];
|
|
flows?: Array<{ stages?: string[] }>;
|
|
session?: string;
|
|
};
|
|
|
|
type MatrixQaRegisteredAccount = {
|
|
accessToken: string;
|
|
deviceId?: string;
|
|
localpart: string;
|
|
password: string;
|
|
userId: string;
|
|
};
|
|
|
|
export type MatrixQaProvisionResult = {
|
|
driver: MatrixQaRegisteredAccount;
|
|
observer: MatrixQaRegisteredAccount;
|
|
roomId: string;
|
|
sut: MatrixQaRegisteredAccount;
|
|
topology: MatrixQaProvisionedTopology;
|
|
};
|
|
|
|
function buildMatrixThreadRelation(threadRootEventId: string, replyToEventId?: string) {
|
|
return {
|
|
"m.relates_to": {
|
|
rel_type: "m.thread" as const,
|
|
event_id: threadRootEventId,
|
|
is_falling_back: true as const,
|
|
"m.in_reply_to": {
|
|
event_id: replyToEventId?.trim() || threadRootEventId,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildMatrixReplacementRelation(targetEventId: string) {
|
|
const normalizedTargetEventId = targetEventId.trim();
|
|
if (!normalizedTargetEventId) {
|
|
throw new Error("Matrix replacement requires a target event id");
|
|
}
|
|
return {
|
|
"m.relates_to": {
|
|
rel_type: "m.replace" as const,
|
|
event_id: normalizedTargetEventId,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildMatrixReactionRelation(
|
|
messageId: string,
|
|
emoji: string,
|
|
): MatrixQaSendReactionContent {
|
|
const normalizedMessageId = messageId.trim();
|
|
const normalizedEmoji = emoji.trim();
|
|
if (!normalizedMessageId) {
|
|
throw new Error("Matrix reaction requires a messageId");
|
|
}
|
|
if (!normalizedEmoji) {
|
|
throw new Error("Matrix reaction requires an emoji");
|
|
}
|
|
return {
|
|
"m.relates_to": {
|
|
rel_type: "m.annotation",
|
|
event_id: normalizedMessageId,
|
|
key: normalizedEmoji,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildMatrixQaRoomInitialState(encrypted?: boolean): MatrixQaRoomInitialState {
|
|
const initialState: MatrixQaRoomInitialState = [
|
|
{
|
|
type: "m.room.history_visibility",
|
|
state_key: "",
|
|
content: { history_visibility: "joined" },
|
|
},
|
|
];
|
|
if (encrypted === true) {
|
|
initialState.push({
|
|
type: "m.room.encryption",
|
|
state_key: "",
|
|
content: { algorithm: "m.megolm.v1.aes-sha2" },
|
|
});
|
|
}
|
|
return initialState;
|
|
}
|
|
|
|
function escapeMatrixHtml(value: string): string {
|
|
return value.replace(/[&<>"']/g, (char) => {
|
|
switch (char) {
|
|
case "&":
|
|
return "&";
|
|
case "<":
|
|
return "<";
|
|
case ">":
|
|
return ">";
|
|
case '"':
|
|
return """;
|
|
case "'":
|
|
return "'";
|
|
default:
|
|
return char;
|
|
}
|
|
});
|
|
}
|
|
|
|
function buildMatrixMentionLink(userId: string) {
|
|
const href = `https://matrix.to/#/${encodeURIComponent(userId)}`;
|
|
const label = escapeMatrixHtml(userId);
|
|
return `<a href="${href}">${label}</a>`;
|
|
}
|
|
|
|
export function buildMatrixQaMessageContent(params: {
|
|
body: string;
|
|
mentionUserIds?: string[];
|
|
replyToEventId?: string;
|
|
threadRootEventId?: string;
|
|
}): MatrixQaSendMessageContent {
|
|
const body = params.body;
|
|
const uniqueMentionUserIds = [...new Set(params.mentionUserIds?.filter(Boolean) ?? [])];
|
|
const formattedParts: string[] = [];
|
|
let cursor = 0;
|
|
let usedFormattedMention = false;
|
|
|
|
while (cursor < body.length) {
|
|
let matchedUserId: string | null = null;
|
|
for (const userId of uniqueMentionUserIds) {
|
|
if (body.startsWith(userId, cursor)) {
|
|
matchedUserId = userId;
|
|
break;
|
|
}
|
|
}
|
|
if (matchedUserId) {
|
|
formattedParts.push(buildMatrixMentionLink(matchedUserId));
|
|
cursor += matchedUserId.length;
|
|
usedFormattedMention = true;
|
|
continue;
|
|
}
|
|
formattedParts.push(escapeMatrixHtml(body[cursor] ?? ""));
|
|
cursor += 1;
|
|
}
|
|
|
|
return {
|
|
body,
|
|
msgtype: "m.text",
|
|
...(usedFormattedMention
|
|
? {
|
|
format: "org.matrix.custom.html" as const,
|
|
formatted_body: formattedParts.join(""),
|
|
}
|
|
: {}),
|
|
...(uniqueMentionUserIds.length > 0
|
|
? { "m.mentions": { user_ids: uniqueMentionUserIds } }
|
|
: {}),
|
|
...(params.threadRootEventId
|
|
? buildMatrixThreadRelation(params.threadRootEventId, params.replyToEventId)
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function buildMatrixQaReplacementMessageContent(params: {
|
|
body: string;
|
|
mentionUserIds?: string[];
|
|
targetEventId: string;
|
|
}): MatrixQaSendMessageContent {
|
|
const newContent = buildMatrixQaMessageContent({
|
|
body: params.body,
|
|
mentionUserIds: params.mentionUserIds,
|
|
});
|
|
return {
|
|
body: `* ${params.body}`,
|
|
msgtype: "m.text",
|
|
"m.new_content": newContent,
|
|
...buildMatrixReplacementRelation(params.targetEventId),
|
|
};
|
|
}
|
|
|
|
function resolveMatrixQaMediaMsgtype(params: {
|
|
contentType?: string;
|
|
kind?: "audio" | "file" | "image" | "video";
|
|
}): MatrixQaMediaMessageType {
|
|
if (params.kind === "audio" || params.contentType?.startsWith("audio/")) {
|
|
return "m.audio";
|
|
}
|
|
if (params.kind === "video" || params.contentType?.startsWith("video/")) {
|
|
return "m.video";
|
|
}
|
|
if (params.kind === "image" || params.contentType?.startsWith("image/")) {
|
|
return "m.image";
|
|
}
|
|
return "m.file";
|
|
}
|
|
|
|
function buildMatrixQaMediaMessageContent(params: {
|
|
body?: string;
|
|
contentType?: string;
|
|
fileName?: string;
|
|
kind?: "audio" | "file" | "image" | "video";
|
|
mentionUserIds?: string[];
|
|
replyToEventId?: string;
|
|
size: number;
|
|
threadRootEventId?: string;
|
|
url: string;
|
|
}): MatrixQaSendMediaMessageContent {
|
|
const normalizedBody = params.body?.trim() || params.fileName?.trim() || "(file)";
|
|
const content = buildMatrixQaMessageContent({
|
|
body: normalizedBody,
|
|
mentionUserIds: params.mentionUserIds,
|
|
replyToEventId: params.replyToEventId,
|
|
threadRootEventId: params.threadRootEventId,
|
|
});
|
|
return {
|
|
...content,
|
|
filename: params.fileName?.trim() || undefined,
|
|
info: {
|
|
...(params.contentType ? { mimetype: params.contentType } : {}),
|
|
size: params.size,
|
|
},
|
|
msgtype: resolveMatrixQaMediaMsgtype({
|
|
contentType: params.contentType,
|
|
kind: params.kind,
|
|
}),
|
|
url: params.url,
|
|
};
|
|
}
|
|
|
|
async function uploadMatrixQaContent(params: {
|
|
accessToken?: string;
|
|
baseUrl: string;
|
|
buffer: Buffer;
|
|
contentType?: string;
|
|
fetchImpl: MatrixQaFetchLike;
|
|
fileName?: string;
|
|
}) {
|
|
const url = new URL("/_matrix/media/v3/upload", params.baseUrl);
|
|
const fileName = params.fileName?.trim();
|
|
if (fileName) {
|
|
url.searchParams.set("filename", fileName);
|
|
}
|
|
const uploadBody: Uint8Array<ArrayBuffer> =
|
|
params.buffer.buffer instanceof ArrayBuffer
|
|
? new Uint8Array(params.buffer.buffer, params.buffer.byteOffset, params.buffer.byteLength)
|
|
: Uint8Array.from(params.buffer);
|
|
const response = await params.fetchImpl(url, {
|
|
method: "POST",
|
|
headers: {
|
|
accept: "application/json",
|
|
"content-type": params.contentType ?? "application/octet-stream",
|
|
...(params.accessToken ? { authorization: `Bearer ${params.accessToken}` } : {}),
|
|
},
|
|
body: uploadBody,
|
|
signal: AbortSignal.timeout(20_000),
|
|
});
|
|
const body = (await response.json().catch(() => ({}))) as {
|
|
content_uri?: string;
|
|
error?: string;
|
|
};
|
|
if (response.status !== 200) {
|
|
throw new Error(body.error ?? `Matrix media upload failed with status ${response.status}`);
|
|
}
|
|
const contentUri = body.content_uri?.trim();
|
|
if (!contentUri) {
|
|
throw new Error("Matrix media upload did not return content_uri.");
|
|
}
|
|
return contentUri;
|
|
}
|
|
|
|
function resolveNextRegistrationAuth(params: {
|
|
registrationToken: string;
|
|
response: MatrixQaUiaaResponse;
|
|
}) {
|
|
const session = params.response.session?.trim();
|
|
if (!session) {
|
|
throw new Error("Matrix registration UIAA response did not include a session id.");
|
|
}
|
|
|
|
const completed = new Set(
|
|
(params.response.completed ?? []).filter(
|
|
(stage): stage is MatrixQaAuthStage =>
|
|
stage === "m.login.dummy" || stage === "m.login.registration_token",
|
|
),
|
|
);
|
|
const supportedStages = new Set<MatrixQaAuthStage>([
|
|
"m.login.registration_token",
|
|
"m.login.dummy",
|
|
]);
|
|
|
|
for (const flow of params.response.flows ?? []) {
|
|
const flowStages = flow.stages ?? [];
|
|
if (
|
|
flowStages.length === 0 ||
|
|
flowStages.some((stage) => !supportedStages.has(stage as MatrixQaAuthStage))
|
|
) {
|
|
continue;
|
|
}
|
|
const stages = flowStages as MatrixQaAuthStage[];
|
|
const nextStage = stages.find((stage) => !completed.has(stage));
|
|
if (!nextStage) {
|
|
continue;
|
|
}
|
|
if (nextStage === "m.login.registration_token") {
|
|
return {
|
|
session,
|
|
type: nextStage,
|
|
token: params.registrationToken,
|
|
};
|
|
}
|
|
return {
|
|
session,
|
|
type: nextStage,
|
|
};
|
|
}
|
|
|
|
throw new Error(
|
|
`Matrix registration requires unsupported auth stages: ${JSON.stringify(params.response.flows ?? [])}`,
|
|
);
|
|
}
|
|
|
|
function buildRegisteredAccount(params: {
|
|
localpart: string;
|
|
password: string;
|
|
response: MatrixQaRegisterResponse;
|
|
}) {
|
|
const userId = params.response.user_id?.trim();
|
|
const accessToken = params.response.access_token?.trim();
|
|
if (!userId || !accessToken) {
|
|
throw new Error("Matrix registration did not return both user_id and access_token.");
|
|
}
|
|
return {
|
|
accessToken,
|
|
deviceId: params.response.device_id?.trim() || undefined,
|
|
localpart: params.localpart,
|
|
password: params.password,
|
|
userId,
|
|
} satisfies MatrixQaRegisteredAccount;
|
|
}
|
|
|
|
function resolveMatrixQaLoginUser(params: { localpart?: string; userId?: string }) {
|
|
const user = params.userId?.trim() || params.localpart?.trim();
|
|
if (!user) {
|
|
throw new Error("Matrix password login requires a localpart or userId.");
|
|
}
|
|
return user;
|
|
}
|
|
|
|
export function createMatrixQaClient(params: {
|
|
accessToken?: string;
|
|
baseUrl: string;
|
|
fetchImpl?: MatrixQaFetchLike;
|
|
syncObserver?: MatrixQaRoomObserver;
|
|
}) {
|
|
const fetchImpl = params.fetchImpl ?? fetch;
|
|
const syncObserver = params.syncObserver;
|
|
const sendEvent = async (opts: { body: unknown; endpoint: string; errorLabel: string }) => {
|
|
const result = await requestMatrixJson<{ event_id?: string }>({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
body: opts.body,
|
|
endpoint: opts.endpoint,
|
|
fetchImpl,
|
|
method: "PUT",
|
|
});
|
|
const eventId = result.body.event_id?.trim();
|
|
if (!eventId) {
|
|
throw new Error(`Matrix ${opts.errorLabel} did not return event_id.`);
|
|
}
|
|
return eventId;
|
|
};
|
|
|
|
return {
|
|
async createPrivateRoom(opts: {
|
|
encrypted?: boolean;
|
|
inviteUserIds: string[];
|
|
isDirect?: boolean;
|
|
name: string;
|
|
}) {
|
|
const result = await requestMatrixJson<MatrixQaRoomCreateResponse>({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
body: {
|
|
creation_content: { "m.federate": false },
|
|
initial_state: buildMatrixQaRoomInitialState(opts.encrypted),
|
|
invite: opts.inviteUserIds,
|
|
is_direct: opts.isDirect === true,
|
|
name: opts.name,
|
|
preset: "private_chat",
|
|
},
|
|
endpoint: "/_matrix/client/v3/createRoom",
|
|
fetchImpl,
|
|
method: "POST",
|
|
});
|
|
const roomId = result.body.room_id?.trim();
|
|
if (!roomId) {
|
|
throw new Error("Matrix createRoom did not return room_id.");
|
|
}
|
|
return roomId;
|
|
},
|
|
async primeRoom() {
|
|
if (syncObserver) {
|
|
return await syncObserver.prime();
|
|
}
|
|
return await primeMatrixQaRoom({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
fetchImpl,
|
|
});
|
|
},
|
|
async registerWithToken(opts: {
|
|
deviceName: string;
|
|
localpart: string;
|
|
password: string;
|
|
registrationToken: string;
|
|
}) {
|
|
let auth: Record<string, unknown> | undefined;
|
|
const baseBody = {
|
|
inhibit_login: false,
|
|
initial_device_display_name: opts.deviceName,
|
|
password: opts.password,
|
|
username: opts.localpart,
|
|
};
|
|
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
const response = await requestMatrixJson<MatrixQaRegisterResponse | MatrixQaUiaaResponse>({
|
|
baseUrl: params.baseUrl,
|
|
body: {
|
|
...baseBody,
|
|
...(auth ? { auth } : {}),
|
|
},
|
|
endpoint: "/_matrix/client/v3/register",
|
|
fetchImpl,
|
|
method: "POST",
|
|
okStatuses: [200, 401],
|
|
timeoutMs: 30_000,
|
|
});
|
|
if (response.status === 200) {
|
|
return buildRegisteredAccount({
|
|
localpart: opts.localpart,
|
|
password: opts.password,
|
|
response: response.body as MatrixQaRegisterResponse,
|
|
});
|
|
}
|
|
auth = resolveNextRegistrationAuth({
|
|
registrationToken: opts.registrationToken,
|
|
response: response.body as MatrixQaUiaaResponse,
|
|
});
|
|
}
|
|
throw new Error(
|
|
`Matrix registration for ${opts.localpart} did not complete after 4 attempts.`,
|
|
);
|
|
},
|
|
async loginWithPassword(opts: {
|
|
deviceName: string;
|
|
localpart?: string;
|
|
password: string;
|
|
userId?: string;
|
|
}) {
|
|
const result = await requestMatrixJson<MatrixQaLoginResponse>({
|
|
baseUrl: params.baseUrl,
|
|
body: {
|
|
type: "m.login.password",
|
|
identifier: {
|
|
type: "m.id.user",
|
|
user: resolveMatrixQaLoginUser(opts),
|
|
},
|
|
initial_device_display_name: opts.deviceName,
|
|
password: opts.password,
|
|
},
|
|
endpoint: "/_matrix/client/v3/login",
|
|
fetchImpl,
|
|
method: "POST",
|
|
timeoutMs: 30_000,
|
|
});
|
|
return buildRegisteredAccount({
|
|
localpart: opts.localpart ?? opts.userId ?? "",
|
|
password: opts.password,
|
|
response: result.body,
|
|
});
|
|
},
|
|
async sendTextMessage(opts: {
|
|
body: string;
|
|
mentionUserIds?: string[];
|
|
replyToEventId?: string;
|
|
roomId: string;
|
|
threadRootEventId?: string;
|
|
}) {
|
|
const txnId = randomUUID();
|
|
return await sendEvent({
|
|
body: buildMatrixQaMessageContent(opts),
|
|
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
|
errorLabel: "sendMessage",
|
|
});
|
|
},
|
|
async sendReplacementMessage(opts: {
|
|
body: string;
|
|
mentionUserIds?: string[];
|
|
roomId: string;
|
|
targetEventId: string;
|
|
}) {
|
|
const txnId = randomUUID();
|
|
return await sendEvent({
|
|
body: buildMatrixQaReplacementMessageContent(opts),
|
|
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
|
errorLabel: "sendReplacementMessage",
|
|
});
|
|
},
|
|
async sendMediaMessage(opts: {
|
|
body?: string;
|
|
buffer: Buffer;
|
|
contentType?: string;
|
|
fileName?: string;
|
|
kind?: "audio" | "file" | "image" | "video";
|
|
mentionUserIds?: string[];
|
|
replyToEventId?: string;
|
|
roomId: string;
|
|
threadRootEventId?: string;
|
|
}) {
|
|
const contentUri = await uploadMatrixQaContent({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
buffer: opts.buffer,
|
|
contentType: opts.contentType,
|
|
fetchImpl,
|
|
fileName: opts.fileName,
|
|
});
|
|
const txnId = randomUUID();
|
|
return await sendEvent({
|
|
body: buildMatrixQaMediaMessageContent({
|
|
body: opts.body,
|
|
contentType: opts.contentType,
|
|
fileName: opts.fileName,
|
|
kind: opts.kind,
|
|
mentionUserIds: opts.mentionUserIds,
|
|
replyToEventId: opts.replyToEventId,
|
|
size: opts.buffer.byteLength,
|
|
threadRootEventId: opts.threadRootEventId,
|
|
url: contentUri,
|
|
}),
|
|
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
|
errorLabel: "sendMediaMessage",
|
|
});
|
|
},
|
|
async redactEvent(opts: { eventId: string; reason?: string; roomId: string }) {
|
|
const txnId = randomUUID();
|
|
const reason = opts.reason?.trim();
|
|
return await sendEvent({
|
|
body: reason ? { reason } : {},
|
|
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/redact/${encodeURIComponent(opts.eventId)}/${encodeURIComponent(txnId)}`,
|
|
errorLabel: "redactEvent",
|
|
});
|
|
},
|
|
async sendReaction(opts: { emoji: string; messageId: string; roomId: string }) {
|
|
const txnId = randomUUID();
|
|
return await sendEvent({
|
|
body: buildMatrixReactionRelation(opts.messageId, opts.emoji),
|
|
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/send/m.reaction/${encodeURIComponent(txnId)}`,
|
|
errorLabel: "sendReaction",
|
|
});
|
|
},
|
|
async joinRoom(roomId: string) {
|
|
const result = await requestMatrixJson<{ room_id?: string }>({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
body: {},
|
|
endpoint: `/_matrix/client/v3/join/${encodeURIComponent(roomId)}`,
|
|
fetchImpl,
|
|
method: "POST",
|
|
});
|
|
return result.body.room_id?.trim() || roomId;
|
|
},
|
|
async inviteUserToRoom(opts: { roomId: string; userId: string }) {
|
|
await requestMatrixJson<Record<string, never>>({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
body: {
|
|
user_id: opts.userId,
|
|
},
|
|
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/invite`,
|
|
fetchImpl,
|
|
method: "POST",
|
|
});
|
|
},
|
|
async kickUserFromRoom(opts: { reason?: string; roomId: string; userId: string }) {
|
|
await requestMatrixJson<Record<string, never>>({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
body: {
|
|
user_id: opts.userId,
|
|
...(opts.reason?.trim() ? { reason: opts.reason.trim() } : {}),
|
|
},
|
|
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(opts.roomId)}/kick`,
|
|
fetchImpl,
|
|
method: "POST",
|
|
});
|
|
},
|
|
async leaveRoom(roomId: string) {
|
|
await requestMatrixJson<Record<string, never>>({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
body: {},
|
|
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/leave`,
|
|
fetchImpl,
|
|
method: "POST",
|
|
});
|
|
},
|
|
waitForOptionalRoomEvent(opts: {
|
|
observedEvents: MatrixQaObservedEvent[];
|
|
predicate: (event: MatrixQaObservedEvent) => boolean;
|
|
roomId: string;
|
|
since?: string;
|
|
timeoutMs: number;
|
|
}) {
|
|
if (syncObserver) {
|
|
return syncObserver.waitForOptionalRoomEvent({
|
|
predicate: opts.predicate,
|
|
roomId: opts.roomId,
|
|
timeoutMs: opts.timeoutMs,
|
|
});
|
|
}
|
|
return waitForOptionalMatrixQaRoomEvent({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
fetchImpl,
|
|
...opts,
|
|
});
|
|
},
|
|
async waitForRoomEvent(opts: {
|
|
observedEvents: MatrixQaObservedEvent[];
|
|
predicate: (event: MatrixQaObservedEvent) => boolean;
|
|
roomId: string;
|
|
since?: string;
|
|
timeoutMs: number;
|
|
}) {
|
|
if (syncObserver) {
|
|
return await syncObserver.waitForRoomEvent({
|
|
predicate: opts.predicate,
|
|
roomId: opts.roomId,
|
|
timeoutMs: opts.timeoutMs,
|
|
});
|
|
}
|
|
return await waitForMatrixQaRoomEvent({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
fetchImpl,
|
|
...opts,
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function joinRoomWithRetry(params: {
|
|
accessToken: string;
|
|
baseUrl: string;
|
|
fetchImpl?: MatrixQaFetchLike;
|
|
roomId: string;
|
|
}) {
|
|
const client = createMatrixQaClient({
|
|
accessToken: params.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
fetchImpl: params.fetchImpl,
|
|
});
|
|
let lastError: unknown = null;
|
|
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
|
try {
|
|
await client.joinRoom(params.roomId);
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await sleep(300 * attempt);
|
|
}
|
|
}
|
|
throw new Error(`Matrix join retry failed: ${formatErrorMessage(lastError)}`);
|
|
}
|
|
|
|
function resolveProvisionedRoomRequireMention(room: MatrixQaTopologyRoomSpec) {
|
|
return room.kind === "group" ? room.requireMention !== false : false;
|
|
}
|
|
|
|
function resolveTopologyMemberAccounts(
|
|
accounts: Record<MatrixQaParticipantRole, MatrixQaRegisteredAccount>,
|
|
memberRoles: MatrixQaParticipantRole[],
|
|
) {
|
|
const uniqueRoles = [...new Set(memberRoles)];
|
|
if (uniqueRoles.length === 0) {
|
|
throw new Error("Matrix QA room provisioning requires at least one member");
|
|
}
|
|
return uniqueRoles.map((role) => ({
|
|
role,
|
|
account: accounts[role],
|
|
}));
|
|
}
|
|
|
|
async function provisionMatrixQaTopology(params: {
|
|
accounts: Record<MatrixQaParticipantRole, MatrixQaRegisteredAccount>;
|
|
baseUrl: string;
|
|
fetchImpl?: MatrixQaFetchLike;
|
|
spec: MatrixQaTopologySpec;
|
|
}): Promise<MatrixQaProvisionedTopology> {
|
|
const rooms = [];
|
|
|
|
for (const room of params.spec.rooms) {
|
|
const members = resolveTopologyMemberAccounts(params.accounts, room.members);
|
|
const creator = members[0];
|
|
const invitees = members.slice(1);
|
|
const creatorClient = createMatrixQaClient({
|
|
accessToken: creator.account.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
fetchImpl: params.fetchImpl,
|
|
});
|
|
const roomId = await creatorClient.createPrivateRoom({
|
|
encrypted: room.encrypted === true,
|
|
inviteUserIds: invitees.map((entry) => entry.account.userId),
|
|
isDirect: room.kind === "dm",
|
|
name: room.name,
|
|
});
|
|
await Promise.all(
|
|
invitees.map((invitee) =>
|
|
joinRoomWithRetry({
|
|
accessToken: invitee.account.accessToken,
|
|
baseUrl: params.baseUrl,
|
|
fetchImpl: params.fetchImpl,
|
|
roomId,
|
|
}),
|
|
),
|
|
);
|
|
rooms.push({
|
|
encrypted: room.encrypted === true,
|
|
key: room.key,
|
|
kind: room.kind,
|
|
memberRoles: members.map((entry) => entry.role),
|
|
memberUserIds: members.map((entry) => entry.account.userId),
|
|
name: room.name,
|
|
requireMention: resolveProvisionedRoomRequireMention(room),
|
|
roomId,
|
|
});
|
|
}
|
|
|
|
const defaultRoom = findMatrixQaProvisionedRoom(
|
|
{
|
|
defaultRoomId: "",
|
|
defaultRoomKey: params.spec.defaultRoomKey,
|
|
rooms,
|
|
},
|
|
params.spec.defaultRoomKey,
|
|
);
|
|
|
|
return {
|
|
defaultRoomId: defaultRoom.roomId,
|
|
defaultRoomKey: params.spec.defaultRoomKey,
|
|
rooms,
|
|
};
|
|
}
|
|
|
|
export async function provisionMatrixQaRoom(params: {
|
|
baseUrl: string;
|
|
fetchImpl?: MatrixQaFetchLike;
|
|
topology?: MatrixQaTopologySpec;
|
|
roomName: string;
|
|
driverLocalpart: string;
|
|
observerLocalpart: string;
|
|
registrationToken: string;
|
|
sutLocalpart: string;
|
|
}) {
|
|
const anonClient = createMatrixQaClient({
|
|
baseUrl: params.baseUrl,
|
|
fetchImpl: params.fetchImpl,
|
|
});
|
|
const [driver, sut, observer] = await Promise.all([
|
|
anonClient.registerWithToken({
|
|
deviceName: "OpenClaw Matrix QA Driver",
|
|
localpart: params.driverLocalpart,
|
|
password: `driver-${randomUUID()}`,
|
|
registrationToken: params.registrationToken,
|
|
}),
|
|
anonClient.registerWithToken({
|
|
deviceName: "OpenClaw Matrix QA SUT",
|
|
localpart: params.sutLocalpart,
|
|
password: `sut-${randomUUID()}`,
|
|
registrationToken: params.registrationToken,
|
|
}),
|
|
anonClient.registerWithToken({
|
|
deviceName: "OpenClaw Matrix QA Observer",
|
|
localpart: params.observerLocalpart,
|
|
password: `observer-${randomUUID()}`,
|
|
registrationToken: params.registrationToken,
|
|
}),
|
|
]);
|
|
const topology = await provisionMatrixQaTopology({
|
|
accounts: {
|
|
driver,
|
|
observer,
|
|
sut,
|
|
},
|
|
baseUrl: params.baseUrl,
|
|
fetchImpl: params.fetchImpl,
|
|
spec:
|
|
params.topology ??
|
|
({
|
|
defaultRoomKey: "main",
|
|
rooms: [
|
|
{
|
|
key: "main",
|
|
kind: "group",
|
|
members: ["driver", "observer", "sut"],
|
|
name: params.roomName,
|
|
requireMention: true,
|
|
},
|
|
],
|
|
} satisfies MatrixQaTopologySpec),
|
|
});
|
|
return {
|
|
driver,
|
|
observer,
|
|
roomId: topology.defaultRoomId,
|
|
sut,
|
|
topology,
|
|
} satisfies MatrixQaProvisionResult;
|
|
}
|
|
|
|
export const __testing = {
|
|
buildMatrixQaMessageContent,
|
|
buildMatrixQaReplacementMessageContent,
|
|
buildMatrixReactionRelation,
|
|
buildMatrixReplacementRelation,
|
|
buildMatrixThreadRelation,
|
|
createMatrixQaRoomObserver,
|
|
resolveNextRegistrationAuth,
|
|
};
|