mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:30:43 +00:00
QA: add Matrix transport substrate
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
provisionMatrixQaRoom,
|
||||
type MatrixQaObservedEvent,
|
||||
} from "./client.js";
|
||||
import { buildDefaultMatrixQaTopologySpec } from "./topology.js";
|
||||
|
||||
function resolveRequestUrl(input: RequestInfo | URL) {
|
||||
if (typeof input === "string") {
|
||||
@@ -308,6 +309,57 @@ describe("matrix driver client", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("issues Matrix room membership control requests for QA topology changes", async () => {
|
||||
const requests: Array<{ body: Record<string, unknown>; url: string }> = [];
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
requests.push({
|
||||
body: parseJsonRequestBody(init),
|
||||
url: resolveRequestUrl(input),
|
||||
});
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const client = createMatrixQaClient({
|
||||
accessToken: "token",
|
||||
baseUrl: "http://127.0.0.1:28008/",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await client.inviteUserToRoom({
|
||||
roomId: "!room:matrix-qa.test",
|
||||
userId: "@observer:matrix-qa.test",
|
||||
});
|
||||
await client.kickUserFromRoom({
|
||||
reason: "topology reset",
|
||||
roomId: "!room:matrix-qa.test",
|
||||
userId: "@observer:matrix-qa.test",
|
||||
});
|
||||
await client.leaveRoom("!room:matrix-qa.test");
|
||||
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/invite",
|
||||
body: {
|
||||
user_id: "@observer:matrix-qa.test",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/kick",
|
||||
body: {
|
||||
reason: "topology reset",
|
||||
user_id: "@observer:matrix-qa.test",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "http://127.0.0.1:28008/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/leave",
|
||||
body: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("sends Matrix reactions through the protocol send endpoint", async () => {
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
expect(resolveRequestUrl(input)).toContain(
|
||||
@@ -401,16 +453,117 @@ describe("matrix driver client", () => {
|
||||
roomName: "OpenClaw Matrix QA",
|
||||
sutLocalpart: "qa-sut",
|
||||
fetchImpl,
|
||||
topology: buildDefaultMatrixQaTopologySpec({
|
||||
defaultRoomName: "OpenClaw Matrix QA",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.roomId).toBe("!room:matrix-qa.test");
|
||||
expect(result.topology).toMatchObject({
|
||||
defaultRoomId: "!room:matrix-qa.test",
|
||||
defaultRoomKey: "main",
|
||||
rooms: [
|
||||
{
|
||||
key: "main",
|
||||
kind: "group",
|
||||
memberRoles: ["driver", "observer", "sut"],
|
||||
memberUserIds: [
|
||||
"@qa-driver:matrix-qa.test",
|
||||
"@qa-observer:matrix-qa.test",
|
||||
"@qa-sut:matrix-qa.test",
|
||||
],
|
||||
requireMention: true,
|
||||
roomId: "!room:matrix-qa.test",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.observer.userId).toBe("@qa-observer:matrix-qa.test");
|
||||
expect(createRoomBodies).toEqual([
|
||||
expect.objectContaining({
|
||||
invite: ["@qa-sut:matrix-qa.test", "@qa-observer:matrix-qa.test"],
|
||||
invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"],
|
||||
is_direct: false,
|
||||
preset: "private_chat",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("provisions direct-message topology rooms with Matrix direct-room flags", async () => {
|
||||
const createRoomBodies: Array<Record<string, unknown>> = [];
|
||||
const roomIds = ["!group:matrix-qa.test", "!dm:matrix-qa.test"];
|
||||
let registerCount = 0;
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const url = resolveRequestUrl(input);
|
||||
const body = parseJsonRequestBody(init);
|
||||
if (url.endsWith("/_matrix/client/v3/register")) {
|
||||
registerCount += 1;
|
||||
const role = ["driver", "sut", "observer"][registerCount - 1];
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: `token-${role}`,
|
||||
user_id: `@qa-${role}:matrix-qa.test`,
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/_matrix/client/v3/createRoom")) {
|
||||
createRoomBodies.push(body);
|
||||
return new Response(JSON.stringify({ room_id: roomIds.shift() }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (url.includes("/_matrix/client/v3/join/")) {
|
||||
return new Response(JSON.stringify({ room_id: "!joined:matrix-qa.test" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
};
|
||||
|
||||
const result = await provisionMatrixQaRoom({
|
||||
baseUrl: "http://127.0.0.1:28008/",
|
||||
driverLocalpart: "qa-driver",
|
||||
observerLocalpart: "qa-observer",
|
||||
registrationToken: "reg-token",
|
||||
roomName: "unused",
|
||||
sutLocalpart: "qa-sut",
|
||||
fetchImpl,
|
||||
topology: {
|
||||
defaultRoomKey: "group",
|
||||
rooms: [
|
||||
{
|
||||
key: "group",
|
||||
kind: "group",
|
||||
members: ["driver", "observer", "sut"],
|
||||
name: "Matrix Group",
|
||||
requireMention: true,
|
||||
},
|
||||
{
|
||||
key: "sut-dm",
|
||||
kind: "dm",
|
||||
members: ["driver", "sut"],
|
||||
name: "Matrix Driver/SUT DM",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.topology.rooms).toMatchObject([
|
||||
{ key: "group", kind: "group", roomId: "!group:matrix-qa.test", requireMention: true },
|
||||
{ key: "sut-dm", kind: "dm", roomId: "!dm:matrix-qa.test", requireMention: false },
|
||||
]);
|
||||
expect(createRoomBodies).toEqual([
|
||||
expect.objectContaining({
|
||||
invite: ["@qa-observer:matrix-qa.test", "@qa-sut:matrix-qa.test"],
|
||||
is_direct: false,
|
||||
name: "Matrix Group",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
invite: ["@qa-sut:matrix-qa.test"],
|
||||
is_direct: true,
|
||||
name: "Matrix Driver/SUT DM",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
findMatrixQaProvisionedRoom,
|
||||
type MatrixQaParticipantRole,
|
||||
type MatrixQaProvisionedTopology,
|
||||
type MatrixQaTopologyRoomSpec,
|
||||
type MatrixQaTopologySpec,
|
||||
} from "./topology.js";
|
||||
|
||||
type FetchLike = typeof fetch;
|
||||
|
||||
@@ -115,6 +122,7 @@ export type MatrixQaProvisionResult = {
|
||||
observer: MatrixQaRegisteredAccount;
|
||||
roomId: string;
|
||||
sut: MatrixQaRegisteredAccount;
|
||||
topology: MatrixQaProvisionedTopology;
|
||||
};
|
||||
|
||||
export type MatrixQaRoomEventWaitResult =
|
||||
@@ -488,7 +496,7 @@ export function createMatrixQaClient(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
async createPrivateRoom(opts: { inviteUserIds: string[]; name: string }) {
|
||||
async createPrivateRoom(opts: { inviteUserIds: string[]; isDirect?: boolean; name: string }) {
|
||||
const result = await requestMatrixJson<MatrixQaRoomCreateResponse>({
|
||||
accessToken: params.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
@@ -502,7 +510,7 @@ export function createMatrixQaClient(params: {
|
||||
},
|
||||
],
|
||||
invite: opts.inviteUserIds,
|
||||
is_direct: false,
|
||||
is_direct: opts.isDirect === true,
|
||||
name: opts.name,
|
||||
preset: "private_chat",
|
||||
},
|
||||
@@ -618,6 +626,41 @@ export function createMatrixQaClient(params: {
|
||||
});
|
||||
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,
|
||||
async waitForRoomEvent(opts: {
|
||||
observedEvents: MatrixQaObservedEvent[];
|
||||
@@ -659,9 +702,85 @@ async function joinRoomWithRetry(params: {
|
||||
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?: FetchLike;
|
||||
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({
|
||||
inviteUserIds: invitees.map((entry) => entry.account.userId),
|
||||
isDirect: room.kind === "dm",
|
||||
name: room.name,
|
||||
});
|
||||
for (const invitee of invitees) {
|
||||
await joinRoomWithRetry({
|
||||
accessToken: invitee.account.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
fetchImpl: params.fetchImpl,
|
||||
roomId,
|
||||
});
|
||||
}
|
||||
rooms.push({
|
||||
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?: FetchLike;
|
||||
topology?: MatrixQaTopologySpec;
|
||||
roomName: string;
|
||||
driverLocalpart: string;
|
||||
observerLocalpart: string;
|
||||
@@ -690,32 +809,35 @@ export async function provisionMatrixQaRoom(params: {
|
||||
password: `observer-${randomUUID()}`,
|
||||
registrationToken: params.registrationToken,
|
||||
});
|
||||
const driverClient = createMatrixQaClient({
|
||||
accessToken: driver.accessToken,
|
||||
const topology = await provisionMatrixQaTopology({
|
||||
accounts: {
|
||||
driver,
|
||||
observer,
|
||||
sut,
|
||||
},
|
||||
baseUrl: params.baseUrl,
|
||||
fetchImpl: params.fetchImpl,
|
||||
});
|
||||
const roomId = await driverClient.createPrivateRoom({
|
||||
inviteUserIds: [sut.userId, observer.userId],
|
||||
name: params.roomName,
|
||||
});
|
||||
await joinRoomWithRetry({
|
||||
accessToken: sut.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
fetchImpl: params.fetchImpl,
|
||||
roomId,
|
||||
});
|
||||
await joinRoomWithRetry({
|
||||
accessToken: observer.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
fetchImpl: params.fetchImpl,
|
||||
roomId,
|
||||
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,
|
||||
roomId: topology.defaultRoomId,
|
||||
sut,
|
||||
topology,
|
||||
} satisfies MatrixQaProvisionResult;
|
||||
}
|
||||
|
||||
|
||||
125
extensions/qa-matrix/src/substrate/config.test.ts
Normal file
125
extensions/qa-matrix/src/substrate/config.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMatrixQaConfig } from "./config.js";
|
||||
|
||||
describe("matrix qa config", () => {
|
||||
const topology = {
|
||||
defaultRoomId: "!main:matrix-qa.test",
|
||||
defaultRoomKey: "main",
|
||||
rooms: [
|
||||
{
|
||||
key: "main",
|
||||
kind: "group" as const,
|
||||
memberRoles: ["driver", "observer", "sut"],
|
||||
memberUserIds: [
|
||||
"@driver:matrix-qa.test",
|
||||
"@observer:matrix-qa.test",
|
||||
"@sut:matrix-qa.test",
|
||||
],
|
||||
name: "Main",
|
||||
requireMention: true,
|
||||
roomId: "!main:matrix-qa.test",
|
||||
},
|
||||
{
|
||||
key: "secondary",
|
||||
kind: "group" as const,
|
||||
memberRoles: ["driver", "observer", "sut"],
|
||||
memberUserIds: [
|
||||
"@driver:matrix-qa.test",
|
||||
"@observer:matrix-qa.test",
|
||||
"@sut:matrix-qa.test",
|
||||
],
|
||||
name: "Secondary",
|
||||
requireMention: true,
|
||||
roomId: "!secondary:matrix-qa.test",
|
||||
},
|
||||
{
|
||||
key: "driver-dm",
|
||||
kind: "dm" as const,
|
||||
memberRoles: ["driver", "sut"],
|
||||
memberUserIds: ["@driver:matrix-qa.test", "@sut:matrix-qa.test"],
|
||||
name: "DM",
|
||||
requireMention: false,
|
||||
roomId: "!dm:matrix-qa.test",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("builds default Matrix QA config from provisioned topology", () => {
|
||||
const next = buildMatrixQaConfig({} as OpenClawConfig, {
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
sutAccessToken: "sut-token",
|
||||
sutAccountId: "sut",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
});
|
||||
|
||||
expect(next.channels?.matrix?.accounts?.sut).toMatchObject({
|
||||
dm: {
|
||||
allowFrom: ["@driver:matrix-qa.test"],
|
||||
enabled: true,
|
||||
policy: "allowlist",
|
||||
},
|
||||
groupAllowFrom: ["@driver:matrix-qa.test"],
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"!main:matrix-qa.test": { enabled: true, requireMention: true },
|
||||
"!secondary:matrix-qa.test": { enabled: true, requireMention: true },
|
||||
},
|
||||
replyToMode: "off",
|
||||
threadReplies: "inbound",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies room-keyed Matrix QA config overrides", () => {
|
||||
const next = buildMatrixQaConfig({} as OpenClawConfig, {
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
overrides: {
|
||||
groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"],
|
||||
groupsByKey: {
|
||||
secondary: {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
replyToMode: "all",
|
||||
threadReplies: "always",
|
||||
},
|
||||
sutAccessToken: "sut-token",
|
||||
sutAccountId: "sut",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
});
|
||||
|
||||
expect(next.channels?.matrix?.accounts?.sut).toMatchObject({
|
||||
groupAllowFrom: ["@driver:matrix-qa.test", "@observer:matrix-qa.test"],
|
||||
groups: {
|
||||
"!main:matrix-qa.test": { enabled: true, requireMention: true },
|
||||
"!secondary:matrix-qa.test": { enabled: true, requireMention: false },
|
||||
},
|
||||
replyToMode: "all",
|
||||
threadReplies: "always",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown room-key overrides", () => {
|
||||
expect(() =>
|
||||
buildMatrixQaConfig({} as OpenClawConfig, {
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
overrides: {
|
||||
groupsByKey: {
|
||||
ghost: {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
sutAccessToken: "sut-token",
|
||||
sutAccountId: "sut",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
topology,
|
||||
}),
|
||||
).toThrow('Matrix QA group override references unknown room key "ghost"');
|
||||
});
|
||||
});
|
||||
169
extensions/qa-matrix/src/substrate/config.ts
Normal file
169
extensions/qa-matrix/src/substrate/config.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { MatrixQaProvisionedTopology } from "./topology.js";
|
||||
|
||||
type MatrixQaReplyToMode = "off" | "first" | "all" | "batched";
|
||||
type MatrixQaThreadRepliesMode = "off" | "inbound" | "always";
|
||||
type MatrixQaDmPolicy = "allowlist" | "disabled" | "open" | "pairing";
|
||||
type MatrixQaGroupPolicy = "allowlist" | "disabled" | "open";
|
||||
|
||||
export type MatrixQaGroupConfigOverrides = {
|
||||
enabled?: boolean;
|
||||
requireMention?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixQaDmConfigOverrides = {
|
||||
allowFrom?: string[];
|
||||
enabled?: boolean;
|
||||
policy?: MatrixQaDmPolicy;
|
||||
sessionScope?: "per-room" | "per-user";
|
||||
threadReplies?: MatrixQaThreadRepliesMode;
|
||||
};
|
||||
|
||||
export type MatrixQaConfigOverrides = {
|
||||
autoJoin?: "allowlist" | "always" | "off";
|
||||
blockStreaming?: boolean;
|
||||
dm?: MatrixQaDmConfigOverrides;
|
||||
encryption?: boolean;
|
||||
groupAllowFrom?: string[];
|
||||
groupPolicy?: MatrixQaGroupPolicy;
|
||||
groupsByKey?: Record<string, MatrixQaGroupConfigOverrides>;
|
||||
replyToMode?: MatrixQaReplyToMode;
|
||||
streaming?: "off" | "partial" | "quiet" | boolean;
|
||||
threadReplies?: MatrixQaThreadRepliesMode;
|
||||
};
|
||||
|
||||
function resolveMatrixQaGroupEntries(params: {
|
||||
overrides?: MatrixQaConfigOverrides;
|
||||
topology: MatrixQaProvisionedTopology;
|
||||
}) {
|
||||
const groupRooms = params.topology.rooms.filter((room) => room.kind === "group");
|
||||
const groupsByKey = params.overrides?.groupsByKey ?? {};
|
||||
const knownGroupKeys = new Set(groupRooms.map((room) => room.key));
|
||||
|
||||
for (const key of Object.keys(groupsByKey)) {
|
||||
if (!knownGroupKeys.has(key)) {
|
||||
throw new Error(`Matrix QA group override references unknown room key "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
groupRooms.map((room) => {
|
||||
const override = groupsByKey[room.key];
|
||||
return [
|
||||
room.roomId,
|
||||
{
|
||||
enabled: override?.enabled ?? true,
|
||||
requireMention: override?.requireMention ?? room.requireMention,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatrixQaDmAllowFrom(params: {
|
||||
driverUserId: string;
|
||||
overrides?: MatrixQaConfigOverrides;
|
||||
sutUserId: string;
|
||||
topology: MatrixQaProvisionedTopology;
|
||||
}) {
|
||||
if (params.overrides?.dm?.allowFrom) {
|
||||
return [...params.overrides.dm.allowFrom];
|
||||
}
|
||||
const dmAllowFrom = [
|
||||
...new Set(
|
||||
params.topology.rooms
|
||||
.filter((room) => room.kind === "dm")
|
||||
.flatMap((room) => room.memberUserIds.filter((userId) => userId !== params.sutUserId)),
|
||||
),
|
||||
];
|
||||
return dmAllowFrom.length > 0 ? dmAllowFrom : [params.driverUserId];
|
||||
}
|
||||
|
||||
function resolveMatrixQaDmConfig(params: {
|
||||
driverUserId: string;
|
||||
overrides?: MatrixQaConfigOverrides;
|
||||
sutUserId: string;
|
||||
topology: MatrixQaProvisionedTopology;
|
||||
}) {
|
||||
const hasDmRooms = params.topology.rooms.some((room) => room.kind === "dm");
|
||||
const dmOverrides = params.overrides?.dm;
|
||||
|
||||
if (!hasDmRooms && dmOverrides?.enabled !== true) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
return {
|
||||
allowFrom: resolveMatrixQaDmAllowFrom(params),
|
||||
enabled: dmOverrides?.enabled ?? true,
|
||||
policy: dmOverrides?.policy ?? "allowlist",
|
||||
...(dmOverrides?.sessionScope ? { sessionScope: dmOverrides.sessionScope } : {}),
|
||||
...(dmOverrides?.threadReplies ? { threadReplies: dmOverrides.threadReplies } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMatrixQaConfig(
|
||||
baseCfg: OpenClawConfig,
|
||||
params: {
|
||||
driverUserId: string;
|
||||
homeserver: string;
|
||||
overrides?: MatrixQaConfigOverrides;
|
||||
sutAccessToken: string;
|
||||
sutAccountId: string;
|
||||
sutDeviceId?: string;
|
||||
sutUserId: string;
|
||||
topology: MatrixQaProvisionedTopology;
|
||||
},
|
||||
): OpenClawConfig {
|
||||
const pluginAllow = [...new Set([...(baseCfg.plugins?.allow ?? []), "matrix"])];
|
||||
const groups = resolveMatrixQaGroupEntries({
|
||||
overrides: params.overrides,
|
||||
topology: params.topology,
|
||||
});
|
||||
|
||||
return {
|
||||
...baseCfg,
|
||||
plugins: {
|
||||
...baseCfg.plugins,
|
||||
allow: pluginAllow,
|
||||
entries: {
|
||||
...baseCfg.plugins?.entries,
|
||||
matrix: { enabled: true },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
matrix: {
|
||||
...baseCfg.channels?.matrix,
|
||||
enabled: true,
|
||||
defaultAccount: params.sutAccountId,
|
||||
accounts: {
|
||||
...baseCfg.channels?.matrix?.accounts,
|
||||
[params.sutAccountId]: {
|
||||
accessToken: params.sutAccessToken,
|
||||
...(params.sutDeviceId ? { deviceId: params.sutDeviceId } : {}),
|
||||
dm: resolveMatrixQaDmConfig(params),
|
||||
enabled: true,
|
||||
encryption: params.overrides?.encryption ?? false,
|
||||
groupAllowFrom: params.overrides?.groupAllowFrom ?? [params.driverUserId],
|
||||
groupPolicy: params.overrides?.groupPolicy ?? "allowlist",
|
||||
...(Object.keys(groups).length > 0 ? { groups } : {}),
|
||||
homeserver: params.homeserver,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
replyToMode: params.overrides?.replyToMode ?? "off",
|
||||
threadReplies: params.overrides?.threadReplies ?? "inbound",
|
||||
userId: params.sutUserId,
|
||||
...(params.overrides?.autoJoin ? { autoJoin: params.overrides.autoJoin } : {}),
|
||||
...(params.overrides?.blockStreaming !== undefined
|
||||
? { blockStreaming: params.overrides.blockStreaming }
|
||||
: {}),
|
||||
...(params.overrides?.streaming !== undefined
|
||||
? { streaming: params.overrides.streaming }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -84,6 +84,10 @@ describe("matrix harness runtime", () => {
|
||||
expect(result.stopCommand).toBe(
|
||||
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml down --remove-orphans`,
|
||||
);
|
||||
await result.restartService();
|
||||
expect(calls).toContain(
|
||||
`docker compose -f ${outputDir}/docker-compose.matrix-qa.yml restart matrix-qa-homeserver @/repo/openclaw`,
|
||||
);
|
||||
} finally {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export type MatrixQaHarnessFiles = {
|
||||
|
||||
export type MatrixQaHarness = MatrixQaHarnessFiles & {
|
||||
baseUrl: string;
|
||||
restartService(): Promise<void>;
|
||||
stopCommand: string;
|
||||
stop(): Promise<void>;
|
||||
};
|
||||
@@ -248,9 +249,34 @@ export async function startMatrixQaHarness(
|
||||
sleepImpl,
|
||||
});
|
||||
|
||||
const waitForReady = async () => {
|
||||
await sleepImpl(1_000);
|
||||
await waitForDockerServiceHealth(
|
||||
MATRIX_QA_SERVICE,
|
||||
files.composeFile,
|
||||
repoRoot,
|
||||
runCommand,
|
||||
sleepImpl,
|
||||
);
|
||||
await waitForHealth(buildVersionsUrl(baseUrl), {
|
||||
label: "Matrix homeserver",
|
||||
composeFile: files.composeFile,
|
||||
fetchImpl,
|
||||
sleepImpl,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...files,
|
||||
baseUrl,
|
||||
async restartService() {
|
||||
await runCommand(
|
||||
"docker",
|
||||
["compose", "-f", files.composeFile, "restart", MATRIX_QA_SERVICE],
|
||||
repoRoot,
|
||||
);
|
||||
await waitForReady();
|
||||
},
|
||||
stopCommand: `docker compose -f ${files.composeFile} down --remove-orphans`,
|
||||
async stop() {
|
||||
await runCommand(
|
||||
|
||||
102
extensions/qa-matrix/src/substrate/topology.ts
Normal file
102
extensions/qa-matrix/src/substrate/topology.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export type MatrixQaParticipantRole = "driver" | "observer" | "sut";
|
||||
|
||||
export type MatrixQaRoomKind = "dm" | "group";
|
||||
|
||||
export type MatrixQaTopologyRoomSpec = {
|
||||
key: string;
|
||||
kind: MatrixQaRoomKind;
|
||||
members: MatrixQaParticipantRole[];
|
||||
name: string;
|
||||
requireMention?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixQaTopologySpec = {
|
||||
defaultRoomKey: string;
|
||||
rooms: MatrixQaTopologyRoomSpec[];
|
||||
};
|
||||
|
||||
export type MatrixQaProvisionedRoom = {
|
||||
key: string;
|
||||
kind: MatrixQaRoomKind;
|
||||
memberRoles: MatrixQaParticipantRole[];
|
||||
memberUserIds: string[];
|
||||
name: string;
|
||||
requireMention: boolean;
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
export type MatrixQaProvisionedTopology = {
|
||||
defaultRoomId: string;
|
||||
defaultRoomKey: string;
|
||||
rooms: MatrixQaProvisionedRoom[];
|
||||
};
|
||||
|
||||
function matrixQaRoomSpecsEqual(left: MatrixQaTopologyRoomSpec, right: MatrixQaTopologyRoomSpec) {
|
||||
return (
|
||||
left.key === right.key &&
|
||||
left.kind === right.kind &&
|
||||
left.name === right.name &&
|
||||
left.requireMention === right.requireMention &&
|
||||
left.members.length === right.members.length &&
|
||||
left.members.every((member, index) => member === right.members[index])
|
||||
);
|
||||
}
|
||||
|
||||
export function buildDefaultMatrixQaTopologySpec(params: {
|
||||
defaultRoomName: string;
|
||||
}): MatrixQaTopologySpec {
|
||||
return {
|
||||
defaultRoomKey: "main",
|
||||
rooms: [
|
||||
{
|
||||
key: "main",
|
||||
kind: "group",
|
||||
members: ["driver", "observer", "sut"],
|
||||
name: params.defaultRoomName,
|
||||
requireMention: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function findMatrixQaProvisionedRoom(
|
||||
topology: MatrixQaProvisionedTopology,
|
||||
key: string,
|
||||
): MatrixQaProvisionedRoom {
|
||||
const room = topology.rooms.find((entry) => entry.key === key);
|
||||
if (!room) {
|
||||
throw new Error(`Matrix QA topology is missing room "${key}"`);
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
export function mergeMatrixQaTopologySpecs(specs: MatrixQaTopologySpec[]): MatrixQaTopologySpec {
|
||||
const first = specs[0];
|
||||
if (!first) {
|
||||
throw new Error("Matrix QA topology merge requires at least one spec");
|
||||
}
|
||||
|
||||
const roomByKey = new Map<string, MatrixQaTopologyRoomSpec>();
|
||||
for (const spec of specs) {
|
||||
if (spec.defaultRoomKey !== first.defaultRoomKey) {
|
||||
throw new Error(
|
||||
`Matrix QA topology default room mismatch: ${spec.defaultRoomKey} !== ${first.defaultRoomKey}`,
|
||||
);
|
||||
}
|
||||
for (const room of spec.rooms) {
|
||||
const existing = roomByKey.get(room.key);
|
||||
if (!existing) {
|
||||
roomByKey.set(room.key, room);
|
||||
continue;
|
||||
}
|
||||
if (!matrixQaRoomSpecsEqual(existing, room)) {
|
||||
throw new Error(`Matrix QA topology room "${room.key}" has conflicting definitions`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultRoomKey: first.defaultRoomKey,
|
||||
rooms: [...roomByKey.values()],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user