mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:50:46 +00:00
QA: split lab runtime and extend Matrix coverage (#67430)
Merged via squash.
Prepared head SHA: 790418b93b
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
8ecb6bbb12
commit
4db162db7f
@@ -21,6 +21,8 @@ export type MatrixQaScenarioId =
|
||||
| "matrix-room-thread-reply-override"
|
||||
| "matrix-room-quiet-streaming-preview"
|
||||
| "matrix-room-block-streaming"
|
||||
| "matrix-room-image-understanding-attachment"
|
||||
| "matrix-room-generated-image-delivery"
|
||||
| "matrix-dm-reply-shape"
|
||||
| "matrix-dm-shared-session-notice"
|
||||
| "matrix-dm-thread-reply-override"
|
||||
@@ -47,6 +49,7 @@ export const MATRIX_QA_BLOCK_ROOM_KEY = "block";
|
||||
export const MATRIX_QA_DRIVER_DM_ROOM_KEY = "driver-dm";
|
||||
export const MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY = "driver-dm-shared";
|
||||
export const MATRIX_QA_HOMESERVER_ROOM_KEY = "homeserver";
|
||||
export const MATRIX_QA_MEDIA_ROOM_KEY = "media";
|
||||
export const MATRIX_QA_MEMBERSHIP_ROOM_KEY = "membership";
|
||||
export const MATRIX_QA_RESTART_ROOM_KEY = "restart";
|
||||
export const MATRIX_QA_SECONDARY_ROOM_KEY = "secondary";
|
||||
@@ -123,6 +126,12 @@ const MATRIX_QA_MEMBERSHIP_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
|
||||
requireMention: true,
|
||||
});
|
||||
|
||||
const MATRIX_QA_MEDIA_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
|
||||
key: MATRIX_QA_MEDIA_ROOM_KEY,
|
||||
name: "Matrix QA Media Room",
|
||||
requireMention: true,
|
||||
});
|
||||
|
||||
const MATRIX_QA_RESTART_ROOM_TOPOLOGY = buildMatrixQaSingleGroupTopology({
|
||||
key: MATRIX_QA_RESTART_ROOM_KEY,
|
||||
name: "Matrix QA Restart Room",
|
||||
@@ -202,6 +211,18 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
streaming: "quiet",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "matrix-room-image-understanding-attachment",
|
||||
timeoutMs: 60_000,
|
||||
title: "Matrix image attachments reach the model vision path",
|
||||
topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY,
|
||||
},
|
||||
{
|
||||
id: "matrix-room-generated-image-delivery",
|
||||
timeoutMs: 60_000,
|
||||
title: "Matrix generated images deliver as real image attachments",
|
||||
topology: MATRIX_QA_MEDIA_ROOM_TOPOLOGY,
|
||||
},
|
||||
{
|
||||
id: "matrix-dm-reply-shape",
|
||||
timeoutMs: 45_000,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { MatrixQaObservedEvent } from "../../substrate/events.js";
|
||||
import {
|
||||
MATRIX_QA_BLOCK_ROOM_KEY,
|
||||
MATRIX_QA_HOMESERVER_ROOM_KEY,
|
||||
MATRIX_QA_MEDIA_ROOM_KEY,
|
||||
MATRIX_QA_MEMBERSHIP_ROOM_KEY,
|
||||
MATRIX_QA_RESTART_ROOM_KEY,
|
||||
resolveMatrixQaScenarioRoomId,
|
||||
@@ -32,6 +34,62 @@ import {
|
||||
import type { MatrixQaCanaryArtifact, MatrixQaScenarioExecution } from "./scenario-types.js";
|
||||
|
||||
type MatrixQaThreadScenarioResult = Awaited<ReturnType<typeof runThreadScenario>>;
|
||||
const MATRIX_QA_IMAGE_ATTACHMENT_FILENAME = "red-top-blue-bottom.png";
|
||||
const MATRIX_QA_IMAGE_COLOR_GROUPS = [["red"], ["blue"]] as const;
|
||||
|
||||
function createMatrixQaSplitColorImagePng() {
|
||||
const width = 16;
|
||||
const height = 16;
|
||||
const rgba = Buffer.alloc(width * height * 4);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const isTopHalf = y < height / 2;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (isTopHalf) {
|
||||
fillPixel(rgba, x, y, width, 255, 0, 0);
|
||||
continue;
|
||||
}
|
||||
fillPixel(rgba, x, y, width, 0, 0, 255);
|
||||
}
|
||||
}
|
||||
return encodePngRgba(rgba, width, height);
|
||||
}
|
||||
|
||||
function buildMatrixQaImageUnderstandingPrompt(sutUserId: string) {
|
||||
return `${sutUserId} Image understanding check: describe the top and bottom colors in the attached image in one short sentence.`;
|
||||
}
|
||||
|
||||
function buildMatrixQaImageGenerationPrompt(sutUserId: string) {
|
||||
return `${sutUserId} Image generation check: generate a QA lighthouse image and summarize it in one short sentence.`;
|
||||
}
|
||||
|
||||
function hasMatrixQaExpectedColorReply(body: string | undefined) {
|
||||
const normalizedBody = body?.toLowerCase() ?? "";
|
||||
return MATRIX_QA_IMAGE_COLOR_GROUPS.every((group) =>
|
||||
group.some((color) => normalizedBody.includes(color)),
|
||||
);
|
||||
}
|
||||
|
||||
function requireMatrixQaImageAttachment(event: MatrixQaObservedEvent, scenarioLabel: string) {
|
||||
if (event.msgtype !== "m.image" || event.attachment?.kind !== "image") {
|
||||
throw new Error(
|
||||
`${scenarioLabel} expected an m.image attachment but saw ${event.msgtype ?? "<none>"}`,
|
||||
);
|
||||
}
|
||||
return event.attachment;
|
||||
}
|
||||
|
||||
function buildMatrixQaAttachmentDetailLines(params: {
|
||||
attachmentEvent: MatrixQaObservedEvent;
|
||||
label: string;
|
||||
}) {
|
||||
return [
|
||||
`${params.label} event: ${params.attachmentEvent.eventId}`,
|
||||
`${params.label} msgtype: ${params.attachmentEvent.msgtype ?? "<none>"}`,
|
||||
`${params.label} attachment kind: ${params.attachmentEvent.attachment?.kind ?? "<none>"}`,
|
||||
`${params.label} attachment filename: ${params.attachmentEvent.attachment?.filename ?? "<none>"}`,
|
||||
`${params.label} body preview: ${params.attachmentEvent.body?.slice(0, 200) ?? "<none>"}`,
|
||||
];
|
||||
}
|
||||
|
||||
function assertMatrixQaInReplyTarget(params: {
|
||||
actualEventId?: string;
|
||||
@@ -545,6 +603,110 @@ export async function runBlockStreamingScenario(context: MatrixQaScenarioContext
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
|
||||
export async function runImageUnderstandingAttachmentScenario(context: MatrixQaScenarioContext) {
|
||||
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
|
||||
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
|
||||
const triggerBody = buildMatrixQaImageUnderstandingPrompt(context.sutUserId);
|
||||
const driverEventId = await client.sendMediaMessage({
|
||||
body: triggerBody,
|
||||
buffer: createMatrixQaSplitColorImagePng(),
|
||||
contentType: "image/png",
|
||||
fileName: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
|
||||
kind: "image",
|
||||
mentionUserIds: [context.sutUserId],
|
||||
roomId,
|
||||
});
|
||||
const matched = await client.waitForRoomEvent({
|
||||
observedEvents: context.observedEvents,
|
||||
predicate: (event) =>
|
||||
event.roomId === roomId &&
|
||||
event.sender === context.sutUserId &&
|
||||
event.type === "m.room.message" &&
|
||||
event.relatesTo === undefined &&
|
||||
isMatrixQaMessageLikeKind(event.kind) &&
|
||||
hasMatrixQaExpectedColorReply(event.body),
|
||||
roomId,
|
||||
since: startSince,
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
advanceMatrixQaActorCursor({
|
||||
actorId: "driver",
|
||||
syncState: context.syncState,
|
||||
nextSince: matched.since,
|
||||
startSince,
|
||||
});
|
||||
const reply = buildMatrixReplyArtifact(matched.event);
|
||||
return {
|
||||
artifacts: {
|
||||
attachmentFilename: MATRIX_QA_IMAGE_ATTACHMENT_FILENAME,
|
||||
driverEventId,
|
||||
reply,
|
||||
roomId,
|
||||
triggerBody,
|
||||
},
|
||||
details: [
|
||||
`room id: ${roomId}`,
|
||||
`driver attachment event: ${driverEventId}`,
|
||||
`sent attachment filename: ${MATRIX_QA_IMAGE_ATTACHMENT_FILENAME}`,
|
||||
...buildMatrixReplyDetails("reply", reply),
|
||||
].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
|
||||
export async function runGeneratedImageDeliveryScenario(context: MatrixQaScenarioContext) {
|
||||
const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_MEDIA_ROOM_KEY);
|
||||
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
|
||||
const triggerBody = buildMatrixQaImageGenerationPrompt(context.sutUserId);
|
||||
const driverEventId = await client.sendTextMessage({
|
||||
body: triggerBody,
|
||||
mentionUserIds: [context.sutUserId],
|
||||
roomId,
|
||||
});
|
||||
const matched = await client.waitForRoomEvent({
|
||||
observedEvents: context.observedEvents,
|
||||
predicate: (event) =>
|
||||
event.roomId === roomId &&
|
||||
event.sender === context.sutUserId &&
|
||||
event.type === "m.room.message" &&
|
||||
event.relatesTo === undefined &&
|
||||
event.msgtype === "m.image" &&
|
||||
event.attachment?.kind === "image",
|
||||
roomId,
|
||||
since: startSince,
|
||||
timeoutMs: context.timeoutMs,
|
||||
});
|
||||
advanceMatrixQaActorCursor({
|
||||
actorId: "driver",
|
||||
syncState: context.syncState,
|
||||
nextSince: matched.since,
|
||||
startSince,
|
||||
});
|
||||
const attachment = requireMatrixQaImageAttachment(
|
||||
matched.event,
|
||||
"Matrix generated image delivery scenario",
|
||||
);
|
||||
return {
|
||||
artifacts: {
|
||||
attachmentBodyPreview: matched.event.body?.slice(0, 200),
|
||||
attachmentEventId: matched.event.eventId,
|
||||
attachmentFilename: attachment.filename,
|
||||
attachmentKind: attachment.kind,
|
||||
attachmentMsgtype: matched.event.msgtype,
|
||||
driverEventId,
|
||||
roomId,
|
||||
triggerBody,
|
||||
},
|
||||
details: [
|
||||
`room id: ${roomId}`,
|
||||
`driver event: ${driverEventId}`,
|
||||
...buildMatrixQaAttachmentDetailLines({
|
||||
attachmentEvent: matched.event,
|
||||
label: "generated image",
|
||||
}),
|
||||
].join("\n"),
|
||||
} satisfies MatrixQaScenarioExecution;
|
||||
}
|
||||
|
||||
export async function runRoomAutoJoinInviteScenario(context: MatrixQaScenarioContext) {
|
||||
const { client, startSince } = await primeMatrixQaDriverScenarioClient(context);
|
||||
const dynamicRoomId = await client.createPrivateRoom({
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
} from "./scenario-runtime-dm.js";
|
||||
import {
|
||||
runBlockStreamingScenario,
|
||||
runGeneratedImageDeliveryScenario,
|
||||
runHomeserverRestartResumeScenario,
|
||||
runImageUnderstandingAttachmentScenario,
|
||||
runMatrixQaCanary,
|
||||
runMembershipLossScenario,
|
||||
runObserverAllowlistOverrideScenario,
|
||||
@@ -119,6 +121,10 @@ export async function runMatrixQaScenario(
|
||||
return await runQuietStreamingPreviewScenario(context);
|
||||
case "matrix-room-block-streaming":
|
||||
return await runBlockStreamingScenario(context);
|
||||
case "matrix-room-image-understanding-attachment":
|
||||
return await runImageUnderstandingAttachmentScenario(context);
|
||||
case "matrix-room-generated-image-delivery":
|
||||
return await runGeneratedImageDeliveryScenario(context);
|
||||
case "matrix-dm-reply-shape":
|
||||
return await runDriverTopologyScopedScenario({
|
||||
context,
|
||||
|
||||
@@ -16,6 +16,11 @@ export type MatrixQaCanaryArtifact = {
|
||||
};
|
||||
|
||||
export type MatrixQaScenarioArtifacts = {
|
||||
attachmentBodyPreview?: string;
|
||||
attachmentEventId?: string;
|
||||
attachmentFilename?: string;
|
||||
attachmentKind?: string;
|
||||
attachmentMsgtype?: string;
|
||||
actorUserId?: string;
|
||||
driverEventId?: string;
|
||||
expectedNoReplyWindowMs?: number;
|
||||
|
||||
@@ -32,6 +32,8 @@ describe("matrix live qa scenarios", () => {
|
||||
"matrix-room-thread-reply-override",
|
||||
"matrix-room-quiet-streaming-preview",
|
||||
"matrix-room-block-streaming",
|
||||
"matrix-room-image-understanding-attachment",
|
||||
"matrix-room-generated-image-delivery",
|
||||
"matrix-dm-reply-shape",
|
||||
"matrix-dm-shared-session-notice",
|
||||
"matrix-dm-thread-reply-override",
|
||||
@@ -748,6 +750,171 @@ describe("matrix live qa scenarios", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a real Matrix image attachment for image-understanding prompts", async () => {
|
||||
const primeRoom = vi.fn().mockResolvedValue("driver-sync-start");
|
||||
const sendMediaMessage = vi.fn().mockResolvedValue("$image-understanding-trigger");
|
||||
const waitForRoomEvent = vi.fn().mockResolvedValue({
|
||||
event: {
|
||||
kind: "message",
|
||||
roomId: "!media:matrix-qa.test",
|
||||
eventId: "$sut-image-reply",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
body: "Protocol note: the attached image is split horizontally, with red on top and blue on the bottom.",
|
||||
},
|
||||
since: "driver-sync-next",
|
||||
});
|
||||
|
||||
createMatrixQaClient.mockReturnValue({
|
||||
primeRoom,
|
||||
sendMediaMessage,
|
||||
waitForRoomEvent,
|
||||
});
|
||||
|
||||
const scenario = MATRIX_QA_SCENARIOS.find(
|
||||
(entry) => entry.id === "matrix-room-image-understanding-attachment",
|
||||
);
|
||||
expect(scenario).toBeDefined();
|
||||
|
||||
await expect(
|
||||
runMatrixQaScenario(scenario!, {
|
||||
baseUrl: "http://127.0.0.1:28008/",
|
||||
canary: undefined,
|
||||
driverAccessToken: "driver-token",
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
observedEvents: [],
|
||||
observerAccessToken: "observer-token",
|
||||
observerUserId: "@observer:matrix-qa.test",
|
||||
roomId: "!main:matrix-qa.test",
|
||||
restartGateway: undefined,
|
||||
syncState: {},
|
||||
sutAccessToken: "sut-token",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
timeoutMs: 8_000,
|
||||
topology: {
|
||||
defaultRoomId: "!main:matrix-qa.test",
|
||||
defaultRoomKey: "main",
|
||||
rooms: [
|
||||
{
|
||||
key: scenarioTesting.MATRIX_QA_MEDIA_ROOM_KEY,
|
||||
kind: "group",
|
||||
memberRoles: ["driver", "observer", "sut"],
|
||||
memberUserIds: [
|
||||
"@driver:matrix-qa.test",
|
||||
"@observer:matrix-qa.test",
|
||||
"@sut:matrix-qa.test",
|
||||
],
|
||||
name: "Media",
|
||||
requireMention: true,
|
||||
roomId: "!media:matrix-qa.test",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
artifacts: {
|
||||
attachmentFilename: "red-top-blue-bottom.png",
|
||||
driverEventId: "$image-understanding-trigger",
|
||||
reply: {
|
||||
eventId: "$sut-image-reply",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendMediaMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contentType: "image/png",
|
||||
fileName: "red-top-blue-bottom.png",
|
||||
kind: "image",
|
||||
mentionUserIds: ["@sut:matrix-qa.test"],
|
||||
roomId: "!media:matrix-qa.test",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for a real Matrix image attachment after image generation", async () => {
|
||||
const primeRoom = vi.fn().mockResolvedValue("driver-sync-start");
|
||||
const sendTextMessage = vi.fn().mockResolvedValue("$image-generate-trigger");
|
||||
const waitForRoomEvent = vi.fn().mockResolvedValue({
|
||||
event: {
|
||||
kind: "message",
|
||||
roomId: "!media:matrix-qa.test",
|
||||
eventId: "$sut-image",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
body: "Protocol note: generated the QA lighthouse image successfully.",
|
||||
msgtype: "m.image",
|
||||
attachment: {
|
||||
kind: "image",
|
||||
filename: "qa-lighthouse.png",
|
||||
},
|
||||
},
|
||||
since: "driver-sync-next",
|
||||
});
|
||||
|
||||
createMatrixQaClient.mockReturnValue({
|
||||
primeRoom,
|
||||
sendTextMessage,
|
||||
waitForRoomEvent,
|
||||
});
|
||||
|
||||
const scenario = MATRIX_QA_SCENARIOS.find(
|
||||
(entry) => entry.id === "matrix-room-generated-image-delivery",
|
||||
);
|
||||
expect(scenario).toBeDefined();
|
||||
|
||||
await expect(
|
||||
runMatrixQaScenario(scenario!, {
|
||||
baseUrl: "http://127.0.0.1:28008/",
|
||||
canary: undefined,
|
||||
driverAccessToken: "driver-token",
|
||||
driverUserId: "@driver:matrix-qa.test",
|
||||
observedEvents: [],
|
||||
observerAccessToken: "observer-token",
|
||||
observerUserId: "@observer:matrix-qa.test",
|
||||
roomId: "!main:matrix-qa.test",
|
||||
restartGateway: undefined,
|
||||
syncState: {},
|
||||
sutAccessToken: "sut-token",
|
||||
sutUserId: "@sut:matrix-qa.test",
|
||||
timeoutMs: 8_000,
|
||||
topology: {
|
||||
defaultRoomId: "!main:matrix-qa.test",
|
||||
defaultRoomKey: "main",
|
||||
rooms: [
|
||||
{
|
||||
key: scenarioTesting.MATRIX_QA_MEDIA_ROOM_KEY,
|
||||
kind: "group",
|
||||
memberRoles: ["driver", "observer", "sut"],
|
||||
memberUserIds: [
|
||||
"@driver:matrix-qa.test",
|
||||
"@observer:matrix-qa.test",
|
||||
"@sut:matrix-qa.test",
|
||||
],
|
||||
name: "Media",
|
||||
requireMention: true,
|
||||
roomId: "!media:matrix-qa.test",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
artifacts: {
|
||||
attachmentEventId: "$sut-image",
|
||||
attachmentFilename: "qa-lighthouse.png",
|
||||
attachmentKind: "image",
|
||||
attachmentMsgtype: "m.image",
|
||||
driverEventId: "$image-generate-trigger",
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendTextMessage).toHaveBeenCalledWith({
|
||||
body: expect.stringContaining("Image generation check: generate a QA lighthouse image"),
|
||||
mentionUserIds: ["@sut:matrix-qa.test"],
|
||||
roomId: "!media:matrix-qa.test",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses DM thread override scenarios against the provisioned DM room", async () => {
|
||||
const primeRoom = vi.fn().mockResolvedValue("driver-sync-start");
|
||||
const sendTextMessage = vi.fn().mockResolvedValue("$dm-thread-trigger");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
MATRIX_QA_DRIVER_DM_ROOM_KEY,
|
||||
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
|
||||
MATRIX_QA_MEDIA_ROOM_KEY,
|
||||
MATRIX_QA_MEMBERSHIP_ROOM_KEY,
|
||||
MATRIX_QA_SCENARIOS,
|
||||
MATRIX_QA_SECONDARY_ROOM_KEY,
|
||||
@@ -54,6 +55,7 @@ export type { MatrixQaScenarioContext, MatrixQaSyncState };
|
||||
export const __testing = {
|
||||
MATRIX_QA_DRIVER_DM_ROOM_KEY,
|
||||
MATRIX_QA_DRIVER_DM_SHARED_ROOM_KEY,
|
||||
MATRIX_QA_MEDIA_ROOM_KEY,
|
||||
MATRIX_QA_MEMBERSHIP_ROOM_KEY,
|
||||
MATRIX_QA_SECONDARY_ROOM_KEY,
|
||||
MATRIX_QA_STANDARD_SCENARIO_IDS,
|
||||
|
||||
@@ -15,8 +15,13 @@ describe("matrix observed event artifacts", () => {
|
||||
type: "m.room.message",
|
||||
body: "secret",
|
||||
formattedBody: "<p>secret</p>",
|
||||
msgtype: "m.text",
|
||||
msgtype: "m.image",
|
||||
originServerTs: 1_700_000_000_000,
|
||||
attachment: {
|
||||
kind: "image",
|
||||
caption: "secret",
|
||||
filename: "qa-lighthouse.png",
|
||||
},
|
||||
relatesTo: {
|
||||
relType: "m.thread",
|
||||
eventId: "$root",
|
||||
@@ -33,8 +38,12 @@ describe("matrix observed event artifacts", () => {
|
||||
eventId: "$event",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
msgtype: "m.text",
|
||||
msgtype: "m.image",
|
||||
originServerTs: 1_700_000_000_000,
|
||||
attachment: {
|
||||
kind: "image",
|
||||
filename: "qa-lighthouse.png",
|
||||
},
|
||||
relatesTo: {
|
||||
relType: "m.thread",
|
||||
eventId: "$root",
|
||||
|
||||
@@ -20,6 +20,12 @@ export function buildMatrixQaObservedEventsArtifact(params: {
|
||||
relatesTo: event.relatesTo,
|
||||
mentions: event.mentions,
|
||||
reaction: event.reaction,
|
||||
attachment: event.attachment
|
||||
? {
|
||||
kind: event.attachment.kind,
|
||||
...(event.attachment.filename ? { filename: event.attachment.filename } : {}),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,6 +189,79 @@ describe("matrix driver client", () => {
|
||||
).resolves.toBe("$reaction-1");
|
||||
});
|
||||
|
||||
it("uploads Matrix media before sending the room event", async () => {
|
||||
const requests: Array<{
|
||||
body: RequestInit["body"];
|
||||
headers: HeadersInit | undefined;
|
||||
url: string;
|
||||
}> = [];
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
requests.push({
|
||||
body: init?.body,
|
||||
headers: init?.headers,
|
||||
url: resolveRequestUrl(input),
|
||||
});
|
||||
if (requests.length === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({ content_uri: "mxc://matrix-qa.test/red-top-blue-bottom" }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ event_id: "$media-1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const client = createMatrixQaClient({
|
||||
accessToken: "token",
|
||||
baseUrl: "http://127.0.0.1:28008/",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.sendMediaMessage({
|
||||
body: "@sut:matrix-qa.test Image understanding check",
|
||||
buffer: Buffer.from("png-bytes"),
|
||||
contentType: "image/png",
|
||||
fileName: "red-top-blue-bottom.png",
|
||||
kind: "image",
|
||||
mentionUserIds: ["@sut:matrix-qa.test"],
|
||||
roomId: "!room:matrix-qa.test",
|
||||
}),
|
||||
).resolves.toBe("$media-1");
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0]?.url).toBe(
|
||||
"http://127.0.0.1:28008/_matrix/media/v3/upload?filename=red-top-blue-bottom.png",
|
||||
);
|
||||
expect(requests[0]?.body).toBeInstanceOf(Uint8Array);
|
||||
expect(Array.from(requests[0]?.body as Uint8Array)).toEqual(
|
||||
Array.from(Buffer.from("png-bytes")),
|
||||
);
|
||||
expect(requests[1]?.url).toContain(
|
||||
"/_matrix/client/v3/rooms/!room%3Amatrix-qa.test/send/m.room.message/",
|
||||
);
|
||||
expect(
|
||||
typeof requests[1]?.body === "string" ? JSON.parse(requests[1].body) : requests[1]?.body,
|
||||
).toMatchObject({
|
||||
body: "@sut:matrix-qa.test Image understanding check",
|
||||
msgtype: "m.image",
|
||||
filename: "red-top-blue-bottom.png",
|
||||
url: "mxc://matrix-qa.test/red-top-blue-bottom",
|
||||
info: {
|
||||
mimetype: "image/png",
|
||||
size: "png-bytes".length,
|
||||
},
|
||||
"m.mentions": {
|
||||
user_ids: ["@sut:matrix-qa.test"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("provisions a three-member room so Matrix QA runs in a group context", async () => {
|
||||
const createRoomBodies: Array<Record<string, unknown>> = [];
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
|
||||
@@ -52,6 +52,18 @@ type MatrixQaSendMessageContent = {
|
||||
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;
|
||||
@@ -189,6 +201,96 @@ function buildMatrixQaMessageContent(params: {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function resolveNextRegistrationAuth(params: {
|
||||
registrationToken: string;
|
||||
response: MatrixQaUiaaResponse;
|
||||
@@ -371,6 +473,50 @@ export function createMatrixQaClient(params: {
|
||||
}
|
||||
return eventId;
|
||||
},
|
||||
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();
|
||||
const result = await requestMatrixJson<{ event_id?: string }>({
|
||||
accessToken: params.accessToken,
|
||||
baseUrl: params.baseUrl,
|
||||
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)}`,
|
||||
fetchImpl,
|
||||
method: "PUT",
|
||||
});
|
||||
const eventId = result.body.event_id?.trim();
|
||||
if (!eventId) {
|
||||
throw new Error("Matrix sendMediaMessage did not return event_id.");
|
||||
}
|
||||
return eventId;
|
||||
},
|
||||
async sendReaction(opts: { emoji: string; messageId: string; roomId: string }) {
|
||||
const txnId = randomUUID();
|
||||
const result = await requestMatrixJson<{ event_id?: string }>({
|
||||
|
||||
@@ -133,6 +133,53 @@ describe("matrix observed event normalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes Matrix image messages with attachment metadata", () => {
|
||||
expect(
|
||||
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
|
||||
event_id: "$image",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "Protocol note: generated the QA lighthouse image successfully.",
|
||||
filename: "qa-lighthouse.png",
|
||||
msgtype: "m.image",
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: "message",
|
||||
eventId: "$image",
|
||||
msgtype: "m.image",
|
||||
attachment: {
|
||||
kind: "image",
|
||||
caption: "Protocol note: generated the QA lighthouse image successfully.",
|
||||
filename: "qa-lighthouse.png",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats filename-like Matrix media bodies as attachment filenames", () => {
|
||||
expect(
|
||||
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
|
||||
event_id: "$image",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
content: {
|
||||
body: "qa-lighthouse.png",
|
||||
msgtype: "m.image",
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
attachment: {
|
||||
kind: "image",
|
||||
filename: "qa-lighthouse.png",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes membership events with explicit membership kind", () => {
|
||||
expect(
|
||||
normalizeMatrixQaObservedEvent("!room:matrix-qa.test", {
|
||||
|
||||
@@ -15,6 +15,12 @@ export type MatrixQaObservedEventKind =
|
||||
| "reaction"
|
||||
| "room-event";
|
||||
|
||||
export type MatrixQaObservedEventAttachment = {
|
||||
caption?: string;
|
||||
filename?: string;
|
||||
kind: "audio" | "file" | "image" | "sticker" | "video";
|
||||
};
|
||||
|
||||
export type MatrixQaObservedEvent = {
|
||||
kind: MatrixQaObservedEventKind;
|
||||
roomId: string;
|
||||
@@ -41,6 +47,7 @@ export type MatrixQaObservedEvent = {
|
||||
eventId?: string;
|
||||
key?: string;
|
||||
};
|
||||
attachment?: MatrixQaObservedEventAttachment;
|
||||
};
|
||||
|
||||
function normalizeMentionUserIds(value: unknown) {
|
||||
@@ -80,6 +87,49 @@ function resolveMatrixQaObservedEventKind(params: { msgtype?: string; type: stri
|
||||
return "room-event" as const;
|
||||
}
|
||||
|
||||
function resolveMatrixQaAttachmentKind(msgtype: string | undefined) {
|
||||
switch (msgtype) {
|
||||
case "m.audio":
|
||||
return "audio" as const;
|
||||
case "m.file":
|
||||
return "file" as const;
|
||||
case "m.image":
|
||||
return "image" as const;
|
||||
case "m.sticker":
|
||||
return "sticker" as const;
|
||||
case "m.video":
|
||||
return "video" as const;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isLikelyMatrixQaFilenameBody(value: string) {
|
||||
return !value.includes("\n") && /\.[a-z0-9][a-z0-9._-]{0,24}$/i.test(value);
|
||||
}
|
||||
|
||||
function resolveMatrixQaAttachmentSummary(params: {
|
||||
body?: string;
|
||||
filename?: string;
|
||||
msgtype?: string;
|
||||
}): MatrixQaObservedEventAttachment | undefined {
|
||||
const kind = resolveMatrixQaAttachmentKind(params.msgtype);
|
||||
if (!kind) {
|
||||
return undefined;
|
||||
}
|
||||
const body = params.body?.trim() ?? "";
|
||||
const explicitFilename = params.filename?.trim() ?? "";
|
||||
const inferredFilename =
|
||||
!explicitFilename && body && isLikelyMatrixQaFilenameBody(body) ? body : "";
|
||||
const filename = explicitFilename || inferredFilename;
|
||||
const caption = body && body !== filename ? body : "";
|
||||
return {
|
||||
kind,
|
||||
...(caption ? { caption } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMatrixQaObservedEvent(
|
||||
roomId: string,
|
||||
event: MatrixQaRoomEvent,
|
||||
@@ -104,6 +154,12 @@ export function normalizeMatrixQaObservedEvent(
|
||||
const messageContent = resolveMatrixQaMessageContent(content, relatesTo);
|
||||
const normalizedMsgtype =
|
||||
typeof messageContent.msgtype === "string" ? messageContent.msgtype : msgtype;
|
||||
const normalizedFilename =
|
||||
typeof messageContent.filename === "string"
|
||||
? messageContent.filename
|
||||
: typeof content.filename === "string"
|
||||
? content.filename
|
||||
: undefined;
|
||||
const mentionsRaw = messageContent["m.mentions"] ?? content["m.mentions"];
|
||||
const mentions =
|
||||
typeof mentionsRaw === "object" && mentionsRaw !== null
|
||||
@@ -116,6 +172,11 @@ export function normalizeMatrixQaObservedEvent(
|
||||
type === "m.reaction" && typeof relatesTo?.event_id === "string"
|
||||
? relatesTo.event_id
|
||||
: undefined;
|
||||
const attachment = resolveMatrixQaAttachmentSummary({
|
||||
body: typeof messageContent.body === "string" ? messageContent.body : undefined,
|
||||
filename: normalizedFilename,
|
||||
msgtype: normalizedMsgtype,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: resolveMatrixQaObservedEventKind({ msgtype: normalizedMsgtype, type }),
|
||||
@@ -160,5 +221,6 @@ export function normalizeMatrixQaObservedEvent(
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(attachment ? { attachment } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user