mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-26 10:21:11 +00:00
refactor(matrix): use native sdk event contracts
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user