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:
Gustavo Madeira Santana
2026-04-16 03:08:39 -04:00
committed by GitHub
parent 8ecb6bbb12
commit 4db162db7f
42 changed files with 4230 additions and 2191 deletions

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,

View File

@@ -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;

View File

@@ -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");

View File

@@ -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,

View File

@@ -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",

View File

@@ -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,
},
);
}

View File

@@ -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) => {

View File

@@ -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 }>({

View File

@@ -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", {

View File

@@ -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 } : {}),
};
}