From cbe012ec70bd42d62aa2d3115e2aa888a85a867f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 17:22:22 +0100 Subject: [PATCH] refactor(matrix): use native sdk event contracts --- extensions/matrix/src/matrix/sdk.test.ts | 25 ++- extensions/matrix/src/matrix/sdk.ts | 33 +--- .../matrix/src/matrix/sdk/decrypt-bridge.ts | 25 +-- .../src/matrix/sdk/event-helpers.test.ts | 182 ++++++++++-------- .../matrix/src/matrix/sdk/event-helpers.ts | 76 ++------ 5 files changed, 143 insertions(+), 198 deletions(-) diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 776f715fa15c..f18ef94fdf1c 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { CryptoEvent } from "matrix-js-sdk/lib/crypto-api/CryptoEvent.js"; +import type { DecryptionFailureCode as DecryptionFailureCodeValue } from "matrix-js-sdk/lib/crypto-api/index.js"; import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installMatrixTestRuntime } from "../test-runtime.js"; @@ -108,7 +109,7 @@ class FakeMatrixEvent extends EventEmitter { age?: number; redacted_because?: unknown; }; - private decryptionFailureReasonValue?: string; + private decryptionFailureReasonValue: DecryptionFailureCodeValue | null; private decryptionFailure: boolean; private decryptAttemptHandler?: (options?: { isRetry?: boolean }) => Promise | void; readonly attemptDecryption = vi.fn( @@ -130,7 +131,7 @@ class FakeMatrixEvent extends EventEmitter { redacted_because?: unknown; }; decryptionFailure?: boolean; - decryptionFailureReason?: string; + decryptionFailureReason?: DecryptionFailureCodeValue; }) { super(); this.roomId = params.roomId; @@ -142,11 +143,13 @@ class FakeMatrixEvent extends EventEmitter { this.content = params.content; this.stateKey = params.stateKey; this.unsigned = params.unsigned; - this.decryptionFailureReasonValue = params.decryptionFailureReason; + this.decryptionFailureReasonValue = params.decryptionFailure + ? (params.decryptionFailureReason ?? DecryptionFailureCode.UNKNOWN_ERROR) + : null; this.decryptionFailure = params.decryptionFailure === true; } - get decryptionFailureReason(): string | undefined { + get decryptionFailureReason(): DecryptionFailureCodeValue | null { return this.decryptionFailureReasonValue; } @@ -174,6 +177,14 @@ class FakeMatrixEvent extends EventEmitter { return this.clearEvent?.content ?? this.content; } + getOriginalContent(): Record { + return this.getContent(); + } + + getWireContent(): Record { + return this.content; + } + getUnsigned(): { age?: number; redacted_because?: unknown } { return this.unsigned ?? {}; } @@ -182,6 +193,10 @@ class FakeMatrixEvent extends EventEmitter { return this.stateKey; } + getWireStateKey(): string | undefined { + return this.stateKey; + } + isDecryptionFailure(): boolean { return this.decryptionFailure; } @@ -212,7 +227,7 @@ class FakeMatrixEvent extends EventEmitter { this.content = params.content; this.clearEvent = { type: params.type, content: params.content }; this.decryptionFailure = false; - this.decryptionFailureReasonValue = undefined; + this.decryptionFailureReasonValue = null; } } diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index ed23a1f6d08d..a56c6c6dffa5 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -11,6 +11,7 @@ import { type MatrixEvent, } from "matrix-js-sdk/lib/matrix.js"; import type { Direction } from "matrix-js-sdk/lib/models/event-timeline.js"; +import type { Room } from "matrix-js-sdk/lib/models/room.js"; import { VerificationMethod } from "matrix-js-sdk/lib/types.js"; import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; @@ -880,16 +881,7 @@ export class MatrixClient { } hasSyncedJoinedRoomMember(roomId: string, userId: string): boolean { - const room = ( - this.client as { - getRoom?: (roomId: string) => { - currentState?: { - getMember?: (userId: string) => { membership?: string | null } | null; - }; - } | null; - } - ).getRoom?.(roomId); - return room?.currentState?.getMember?.(userId)?.membership === "join"; + return this.client.getRoom(roomId)?.getMember(userId)?.membership === "join"; } async getRoomStateEvent( @@ -2132,17 +2124,12 @@ export class MatrixClient { }); } - private emitMembershipForRoom(room: unknown): void { - const roomObj = room as { - roomId?: string; - getMyMembership?: () => string | null | undefined; - selfMembership?: string | null | undefined; - }; - const roomId = roomObj.roomId?.trim(); + private emitMembershipForRoom(room: Room): void { + const roomId = room.roomId.trim(); if (!roomId) { return; } - const membership = roomObj.getMyMembership?.() ?? roomObj.selfMembership ?? undefined; + const membership = room.getMyMembership(); const selfUserId = this.client.getUserId() ?? this.selfUserId ?? ""; if (!selfUserId) { return; @@ -2166,15 +2153,7 @@ export class MatrixClient { } private emitOutstandingInviteEvents(): void { - const listRooms = (this.client as { getRooms?: () => unknown[] }).getRooms; - if (typeof listRooms !== "function") { - return; - } - const rooms = listRooms.call(this.client); - if (!Array.isArray(rooms)) { - return; - } - for (const room of rooms) { + for (const room of this.client.getRooms()) { this.emitMembershipForRoom(room); } } diff --git a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts index e4e36fc3dbb5..9a99bf58be82 100644 --- a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts +++ b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts @@ -57,28 +57,11 @@ function resolveDecryptRetryKey(roomId: string, eventId: string): string | null return `${roomId}|${eventId}`; } -function isDecryptionFailure(event: MatrixEvent): boolean { - return ( - typeof (event as { isDecryptionFailure?: () => boolean }).isDecryptionFailure === "function" && - (event as { isDecryptionFailure: () => boolean }).isDecryptionFailure() - ); -} - -function getDecryptionFailureReason(event: MatrixEvent): DecryptionFailureCode | null { - const reason = (event as { decryptionFailureReason?: unknown }).decryptionFailureReason; - return typeof reason === "string" && reason in DecryptionFailureCode - ? (reason as DecryptionFailureCode) - : null; -} - function shouldRetryDecryptionFailure(event: MatrixEvent): boolean { - if (!isDecryptionFailure(event)) { + if (!event.isDecryptionFailure()) { return false; } - const reason = getDecryptionFailureReason(event); - if (!reason) { - return true; - } + const reason = event.decryptionFailureReason; return ( reason === DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID || reason === DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX || @@ -256,7 +239,7 @@ export class MatrixDecryptBridge { return; } - if (isDecryptionFailure(params.decryptedEvent)) { + if (params.decryptedEvent.isDecryptionFailure()) { this.emitFailedDecryptionOnce( retryKey, decryptedRoomId, @@ -407,7 +390,7 @@ export class MatrixDecryptBridge { if (this.stopped) { return; } - if (isDecryptionFailure(state.event)) { + if (state.event.isDecryptionFailure()) { if (!shouldRetryDecryptionFailure(state.event)) { this.clearDecryptRetry(retryKey); return; diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.test.ts b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts index c187cc893c55..97259928b789 100644 --- a/extensions/matrix/src/matrix/sdk/event-helpers.test.ts +++ b/extensions/matrix/src/matrix/sdk/event-helpers.test.ts @@ -1,26 +1,35 @@ // Matrix tests cover event helpers plugin behavior. -import type { MatrixEvent } from "matrix-js-sdk/lib/matrix.js"; -import { describe, expect, it } from "vitest"; +import { MatrixEvent } from "matrix-js-sdk/lib/matrix.js"; +import { describe, expect, it, vi } from "vitest"; import { buildHttpError, matrixEventToRaw, parseMxc } from "./event-helpers.js"; -const makeEditedMessageEvent = (): MatrixEvent => - ({ - getId: () => "$root", - getSender: () => "@alice:example.org", - getType: () => "m.room.message", - getTs: () => 1000, - getOriginalContent: () => ({ body: "original", msgtype: "m.text" }), - getContent: () => ({ - body: "@bot edited", - "m.mentions": { user_ids: ["@bot:example.org"] }, - msgtype: "m.text", - }), - getUnsigned: () => ({ +const makeEditedMessageEvent = (): MatrixEvent => { + const event = new MatrixEvent({ + event_id: "$root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 1000, + content: { body: "original", msgtype: "m.text" }, + unsigned: { "m.relations": { "m.replace": { event_id: "$edit" }, }, + }, + }); + event.makeReplaced( + new MatrixEvent({ + type: "m.room.message", + content: { + "m.new_content": { + body: "@bot edited", + "m.mentions": { user_ids: ["@bot:example.org"] }, + msgtype: "m.text", + }, + }, }), - }) as unknown as MatrixEvent; + ); + return event; +}; describe("event-helpers", () => { it("parses mxc URIs", () => { @@ -49,41 +58,26 @@ describe("event-helpers", () => { expect(buildHttpError(502, `${invalidPrefix}🎉tail`).message).toBe(invalidPrefix); }); - it("serializes Matrix events and resolves state key from available sources", () => { - const viaGetter = { - getId: () => "$1", - getSender: () => "@alice:example.org", - getType: () => "m.room.member", - getTs: () => 1000, - getContent: () => ({ membership: "join" }), - getUnsigned: () => ({ age: 1 }), - getStateKey: () => "@alice:example.org", - } as unknown as MatrixEvent; - expect(matrixEventToRaw(viaGetter).state_key).toBe("@alice:example.org"); + it("serializes native Matrix state events", () => { + const event = new MatrixEvent({ + event_id: "$1", + sender: "@alice:example.org", + type: "m.room.member", + origin_server_ts: 1000, + content: { membership: "join" }, + unsigned: { age: 1 }, + state_key: "@alice:example.org", + }); - const viaWire = { - getId: () => "$2", - getSender: () => "@bob:example.org", - getType: () => "m.room.member", - getTs: () => 2000, - getContent: () => ({ membership: "join" }), - getUnsigned: () => ({}), - getStateKey: () => undefined, - getWireContent: () => ({ state_key: "@bob:example.org" }), - } as unknown as MatrixEvent; - expect(matrixEventToRaw(viaWire).state_key).toBe("@bob:example.org"); - - const viaRaw = { - getId: () => "$3", - getSender: () => "@carol:example.org", - getType: () => "m.room.member", - getTs: () => 3000, - getContent: () => ({ membership: "join" }), - getUnsigned: () => ({}), - getStateKey: () => undefined, - event: { state_key: "@carol:example.org" }, - } as unknown as MatrixEvent; - expect(matrixEventToRaw(viaRaw).state_key).toBe("@carol:example.org"); + expect(matrixEventToRaw(event)).toEqual({ + event_id: "$1", + sender: "@alice:example.org", + type: "m.room.member", + origin_server_ts: 1000, + content: { membership: "join" }, + unsigned: { age: 1 }, + state_key: "@alice:example.org", + }); }); it("serializes current content by default for read APIs", () => { @@ -106,26 +100,32 @@ describe("event-helpers", () => { }); it("preserves original thread relation when serializing edited current content", () => { - const event = { - getId: () => "$root", - getSender: () => "@alice:example.org", - getType: () => "m.room.message", - getTs: () => 1000, - getOriginalContent: () => ({ + const event = new MatrixEvent({ + event_id: "$root", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 1000, + content: { body: "original", msgtype: "m.text", "m.relates_to": { rel_type: "m.thread", event_id: "$thread", }, + }, + }); + event.makeReplaced( + new MatrixEvent({ + type: "m.room.message", + content: { + "m.new_content": { + body: "@bot edited", + "m.mentions": { user_ids: ["@bot:example.org"] }, + msgtype: "m.text", + }, + }, }), - getContent: () => ({ - body: "@bot edited", - "m.mentions": { user_ids: ["@bot:example.org"] }, - msgtype: "m.text", - }), - getUnsigned: () => ({}), - } as unknown as MatrixEvent; + ); expect(matrixEventToRaw(event).content["m.relates_to"]).toEqual({ rel_type: "m.thread", @@ -133,31 +133,53 @@ describe("event-helpers", () => { }); }); - it("preserves wire thread relation for decrypted encrypted events", () => { - const event = { - getId: () => "$encrypted", - getSender: () => "@alice:example.org", - getType: () => "m.room.message", - getTs: () => 1000, - getContent: () => ({ - body: "decrypted edit", + it("preserves reply-only wire relations for encrypted events with clear content", () => { + const event = new MatrixEvent({ + event_id: "$encrypted", + sender: "@alice:example.org", + type: "m.room.message", + origin_server_ts: 1000, + content: { + body: "decrypted reply", msgtype: "m.text", - }), - getUnsigned: () => ({}), - getWireContent: () => ({ + }, + }); + event.makeEncrypted( + "m.room.encrypted", + { + algorithm: "m.megolm.v1.aes-sha2", "m.relates_to": { - rel_type: "m.thread", - event_id: "$thread", + "m.in_reply_to": { event_id: "$parent" }, }, - }), - } as unknown as MatrixEvent; + }, + "curve-key", + "ed-key", + ); expect(matrixEventToRaw(event).content["m.relates_to"]).toEqual({ - rel_type: "m.thread", - event_id: "$thread", + "m.in_reply_to": { event_id: "$parent" }, }); }); + it("preserves packed wire state keys when clear state is unavailable", () => { + const event = new MatrixEvent({ + event_id: "$encrypted-state", + sender: "@alice:example.org", + type: "m.room.member", + state_key: "@alice:example.org", + content: { membership: "join" }, + }); + event.makeEncrypted( + "m.room.encrypted", + { algorithm: "m.megolm.v1.aes-sha2" }, + "curve-key", + "ed-key", + ); + vi.spyOn(event, "getStateKey").mockReturnValue(undefined); + + expect(matrixEventToRaw(event).state_key).toBe("m.room.member:@alice:example.org"); + }); + it("can serialize original content for inbound trigger filtering", () => { expect(matrixEventToRaw(makeEditedMessageEvent(), { contentMode: "original" })).toEqual({ event_id: "$root", diff --git a/extensions/matrix/src/matrix/sdk/event-helpers.ts b/extensions/matrix/src/matrix/sdk/event-helpers.ts index 206588668683..3b37b2da2124 100644 --- a/extensions/matrix/src/matrix/sdk/event-helpers.ts +++ b/extensions/matrix/src/matrix/sdk/event-helpers.ts @@ -9,66 +9,30 @@ export function matrixEventToRaw( event: MatrixEvent, opts: { contentMode?: MatrixEventContentMode } = {}, ): MatrixRawEvent { - const unsigned = (event.getUnsigned?.() ?? {}) as { - age?: number; - redacted_because?: unknown; - }; - const eventWithOriginalContent = event as { - getOriginalContent?: () => Record; - }; - const content = - opts.contentMode === "original" - ? (eventWithOriginalContent.getOriginalContent?.() ?? event.getContent?.() ?? {}) - : (event.getContent?.() ?? eventWithOriginalContent.getOriginalContent?.() ?? {}); - const normalizedContent = preserveMatrixRelation(event, content || {}); + const originalContent = event.getOriginalContent>(); + const content = ( + opts.contentMode === "original" ? originalContent : event.getContent>() + ) as Record; + const relation = originalContent["m.relates_to"] || event.getWireContent()["m.relates_to"]; + const normalizedContent = + relation && !Object.hasOwn(content, "m.relates_to") + ? { ...content, "m.relates_to": relation } + : content; const raw: MatrixRawEvent = { event_id: event.getId() ?? "", sender: event.getSender() ?? "", type: event.getType() ?? "", origin_server_ts: event.getTs() ?? 0, content: normalizedContent, - unsigned, + unsigned: event.getUnsigned(), }; - const stateKey = resolveMatrixStateKey(event); + const stateKey = event.getStateKey() ?? event.getWireStateKey(); if (typeof stateKey === "string") { raw.state_key = stateKey; } return raw; } -function preserveMatrixRelation( - event: MatrixEvent, - content: Record, -): Record { - if (Object.hasOwn(content, "m.relates_to")) { - return content; - } - const relation = resolveMatrixRelation(event); - return relation ? { ...content, "m.relates_to": relation } : content; -} - -function resolveMatrixRelation(event: MatrixEvent): unknown { - const originalContent = ( - event as { getOriginalContent?: () => Record | undefined } - ).getOriginalContent?.(); - const originalRelation = originalContent?.["m.relates_to"]; - if (originalRelation) { - return originalRelation; - } - const wireContent = ( - event as { getWireContent?: () => Record | undefined } - ).getWireContent?.(); - const wireRelation = wireContent?.["m.relates_to"]; - if (wireRelation) { - return wireRelation; - } - const rawContent = (event as { event?: { content?: unknown } }).event?.content; - if (rawContent && typeof rawContent === "object") { - return (rawContent as Record)["m.relates_to"]; - } - return undefined; -} - export function parseMxc(url: string): { server: string; mediaId: string } | null { const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim()); if (!match) { @@ -104,21 +68,3 @@ export function buildHttpError( } return Object.assign(new Error(message), { statusCode }); } - -function resolveMatrixStateKey(event: MatrixEvent): string | undefined { - const direct = event.getStateKey?.(); - if (typeof direct === "string") { - return direct; - } - const wireContent = ( - event as { getWireContent?: () => { state_key?: unknown } } - ).getWireContent?.(); - if (wireContent && typeof wireContent.state_key === "string") { - return wireContent.state_key; - } - const rawEvent = (event as { event?: { state_key?: unknown } }).event; - if (rawEvent && typeof rawEvent.state_key === "string") { - return rawEvent.state_key; - } - return undefined; -}