refactor(matrix): use native sdk event contracts

This commit is contained in:
Peter Steinberger
2026-07-14 17:22:22 +01:00
parent 6f0326af36
commit cbe012ec70
5 changed files with 143 additions and 198 deletions

View File

@@ -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> | 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<string, unknown> {
return this.getContent();
}
getWireContent(): Record<string, unknown> {
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;
}
}

View File

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

View File

@@ -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<TRawEvent extends DecryptBridgeRawEvent> {
return;
}
if (isDecryptionFailure(params.decryptedEvent)) {
if (params.decryptedEvent.isDecryptionFailure()) {
this.emitFailedDecryptionOnce(
retryKey,
decryptedRoomId,
@@ -407,7 +390,7 @@ export class MatrixDecryptBridge<TRawEvent extends DecryptBridgeRawEvent> {
if (this.stopped) {
return;
}
if (isDecryptionFailure(state.event)) {
if (state.event.isDecryptionFailure()) {
if (!shouldRetryDecryptionFailure(state.event)) {
this.clearDecryptRetry(retryKey);
return;

View File

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

View File

@@ -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<string, unknown>;
};
const content =
opts.contentMode === "original"
? (eventWithOriginalContent.getOriginalContent?.() ?? event.getContent?.() ?? {})
: (event.getContent?.() ?? eventWithOriginalContent.getOriginalContent?.() ?? {});
const normalizedContent = preserveMatrixRelation(event, content || {});
const originalContent = event.getOriginalContent<Record<string, unknown>>();
const content = (
opts.contentMode === "original" ? originalContent : event.getContent<Record<string, unknown>>()
) as Record<string, unknown>;
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<string, unknown>,
): Record<string, unknown> {
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<string, unknown> | undefined }
).getOriginalContent?.();
const originalRelation = originalContent?.["m.relates_to"];
if (originalRelation) {
return originalRelation;
}
const wireContent = (
event as { getWireContent?: () => Record<string, unknown> | 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<string, unknown>)["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;
}