Files
adolf/extensions/qa-matrix/src/substrate/client.ts
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

929 lines
27 KiB
TypeScript

// Qa Matrix plugin module implements client behavior.
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { uniqueStrings, uniqueValues } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { MatrixQaObservedEvent } from "./events.js";
import { MATRIX_QA_JSON_MAX_BYTES, 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 "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
case "'":
return "&#39;";
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 = uniqueStrings(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),
});
// Bound the media-upload response body before parsing, mirroring
// `requestMatrixJson`. The overflow error is read *outside* the parse
// try/catch so it fails closed (propagates) instead of being swallowed into
// `{}`; malformed-but-in-bounds JSON still falls back to `{}` as before.
const uploadBytes = await readResponseWithLimit(response, MATRIX_QA_JSON_MAX_BYTES, {
onOverflow: ({ maxBytes }) => new Error(`Matrix homeserver response exceeds ${maxBytes} bytes`),
});
let body: { content_uri?: string; error?: string };
try {
body = JSON.parse(new TextDecoder().decode(uploadBytes)) as {
content_uri?: string;
error?: string;
};
} catch {
body = {};
}
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 = uniqueValues(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,
};
export { testing as __testing };