Compare commits

...

2 Commits

Author SHA1 Message Date
Gustavo Madeira Santana
66c0f4bcc7 Matrix: fix event bridge dedupe and invite detection 2026-02-08 01:28:08 -05:00
Gustavo Madeira Santana
35f60d65d5 Matrix: migrate to matrix-js-sdk and harden client transport 2026-02-08 01:09:39 -05:00
42 changed files with 1772 additions and 1093 deletions

View File

@@ -12,7 +12,7 @@ on any homeserver, so you need a Matrix account for the bot. Once it is logged i
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled.
Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
Status: supported via plugin (`matrix-js-sdk`). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support).
## Plugin required

View File

@@ -5,8 +5,8 @@
"type": "module",
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"markdown-it": "14.1.0",
"matrix-js-sdk": "^40.1.0",
"music-metadata": "^11.11.2",
"zod": "^4.3.6"
},

View File

@@ -101,7 +101,7 @@ export async function readMatrixMessages(
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
// @vector-im/matrix-bot-sdk uses doRequest for room messages
// Room history is queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,

View File

@@ -21,7 +21,7 @@ export async function listMatrixReactions(
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
// @vector-im/matrix-bot-sdk uses doRequest for relations
// Relations are queried via the low-level endpoint for compatibility.
const res = (await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,

View File

@@ -9,10 +9,8 @@ export async function getMatrixMemberInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
// @vector-im/matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
// Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
// Membership and power levels are not included in profile calls; fetch state separately if needed.
return {
userId,
profile: {
@@ -35,7 +33,6 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
// @vector-im/matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
@@ -43,21 +40,21 @@ export async function getMatrixRoomInfo(roomId: string, opts: MatrixActionClient
try {
const nameState = await client.getRoomStateEvent(resolvedRoom, "m.room.name", "");
name = nameState?.name ?? null;
name = typeof nameState?.name === "string" ? nameState.name : null;
} catch {
// ignore
}
try {
const topicState = await client.getRoomStateEvent(resolvedRoom, EventType.RoomTopic, "");
topic = topicState?.topic ?? null;
topic = typeof topicState?.topic === "string" ? topicState.topic : null;
} catch {
// ignore
}
try {
const aliasState = await client.getRoomStateEvent(resolvedRoom, "m.room.canonical_alias", "");
canonicalAlias = aliasState?.alias ?? null;
canonicalAlias = typeof aliasState?.alias === "string" ? aliasState.alias : null;
} catch {
// ignore
}

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import {
EventType,
type MatrixMessageSummary,

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
export const MsgType = {
Text: "m.text",

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "./sdk.js";
let activeClient: MatrixClient | null = null;

View File

@@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../types.js";
import { resolveMatrixConfig } from "./client.js";
import { resolveMatrixAuth, resolveMatrixConfig } from "./client.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.fn();
vi.mock("./credentials.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
saveMatrixCredentials: (...args: unknown[]) => saveMatrixCredentialsMock(...args),
credentialsMatchConfig: vi.fn(() => false),
touchMatrixCredentials: vi.fn(),
}));
describe("resolveMatrixConfig", () => {
it("prefers config over env", () => {
@@ -54,3 +64,59 @@ describe("resolveMatrixConfig", () => {
expect(resolved.encryption).toBe(false);
});
});
describe("resolveMatrixAuth", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
saveMatrixCredentialsMock.mockReset();
});
it("uses the hardened client request path for password login and persists deviceId", async () => {
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
encryption: true,
},
},
} as CoreConfig;
const auth = await resolveMatrixAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
});
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
expect.objectContaining({
type: "m.login.password",
}),
);
expect(auth).toMatchObject({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
encryption: true,
});
expect(saveMatrixCredentialsMock).toHaveBeenCalledWith(
expect.objectContaining({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
}),
);
});
});

View File

@@ -1,7 +1,7 @@
import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
function clean(value?: string): string {
@@ -65,6 +65,10 @@ export async function resolveMatrixAuth(params?: {
// If we have an access token, we can fetch userId via whoami if not provided
if (resolved.accessToken) {
let userId = resolved.userId;
const knownDeviceId =
cachedCredentials && cachedCredentials.accessToken === resolved.accessToken
? cachedCredentials.deviceId
: undefined;
if (!userId) {
// Fetch userId from access token via whoami
ensureMatrixSdkLoggingConfigured();
@@ -76,6 +80,7 @@ export async function resolveMatrixAuth(params?: {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceId: knownDeviceId,
});
} else if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
@@ -84,6 +89,7 @@ export async function resolveMatrixAuth(params?: {
homeserver: resolved.homeserver,
userId,
accessToken: resolved.accessToken,
deviceId: knownDeviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
@@ -96,6 +102,7 @@ export async function resolveMatrixAuth(params?: {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
deviceId: cachedCredentials.deviceId,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,
@@ -112,24 +119,15 @@ export async function resolveMatrixAuth(params?: {
);
}
// Login with password using HTTP API
const loginResponse = await fetch(`${resolved.homeserver}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
}),
});
if (!loginResponse.ok) {
const errorText = await loginResponse.text();
throw new Error(`Matrix login failed: ${errorText}`);
}
const login = (await loginResponse.json()) as {
// Login with password using the same hardened request path as other Matrix HTTP calls.
ensureMatrixSdkLoggingConfigured();
const loginClient = new MatrixClient(resolved.homeserver, "");
const login = (await loginClient.doRequest("POST", "/_matrix/client/v3/login", undefined, {
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "OpenClaw Gateway",
})) as {
access_token?: string;
user_id?: string;
device_id?: string;
@@ -144,6 +142,7 @@ export async function resolveMatrixAuth(params?: {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
deviceId: login.device_id,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
encryption: resolved.encryption,

View File

@@ -1,11 +1,5 @@
import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
import {
LogService,
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
} from "@vector-im/matrix-bot-sdk";
import fs from "node:fs";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
@@ -13,111 +7,43 @@ import {
writeStorageMeta,
} from "./storage.js";
function sanitizeUserIdList(input: unknown, label: string): string[] {
if (input == null) {
return [];
}
if (!Array.isArray(input)) {
LogService.warn(
"MatrixClientLite",
`Expected ${label} list to be an array, got ${typeof input}`,
);
return [];
}
const filtered = input.filter(
(entry): entry is string => typeof entry === "string" && entry.trim().length > 0,
);
if (filtered.length !== input.length) {
LogService.warn(
"MatrixClientLite",
`Dropping ${input.length - filtered.length} invalid ${label} entries from sync payload`,
);
}
return filtered;
}
export async function createMatrixClient(params: {
homeserver: string;
userId: string;
userId?: string;
accessToken: string;
deviceId?: string;
encryption?: boolean;
localTimeoutMs?: number;
initialSyncLimit?: number;
accountId?: string | null;
}): Promise<MatrixClient> {
ensureMatrixSdkLoggingConfigured();
const env = process.env;
const userId = params.userId?.trim() || "unknown";
const matrixClientUserId = params.userId?.trim() || undefined;
// Create storage provider
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.homeserver,
userId: params.userId,
userId,
accessToken: params.accessToken,
accountId: params.accountId,
env,
});
maybeMigrateLegacyStorage({ storagePaths, env });
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePaths.storagePath);
// Create crypto storage if encryption is enabled
let cryptoStorage: ICryptoStorageProvider | undefined;
if (params.encryption) {
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
try {
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
cryptoStorage = new RustSdkCryptoStorageProvider(storagePaths.cryptoPath, StoreType.Sqlite);
} catch (err) {
LogService.warn(
"MatrixClientLite",
"Failed to initialize crypto storage, E2EE disabled:",
err,
);
}
}
writeStorageMeta({
storagePaths,
homeserver: params.homeserver,
userId: params.userId,
userId,
accountId: params.accountId,
});
const client = new MatrixClient(params.homeserver, params.accessToken, storage, cryptoStorage);
if (client.crypto) {
const originalUpdateSyncData = client.crypto.updateSyncData.bind(client.crypto);
client.crypto.updateSyncData = async (
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
changedDeviceLists,
leftDeviceLists,
) => {
const safeChanged = sanitizeUserIdList(changedDeviceLists, "changed device list");
const safeLeft = sanitizeUserIdList(leftDeviceLists, "left device list");
try {
return await originalUpdateSyncData(
toDeviceMessages,
otkCounts,
unusedFallbackKeyAlgs,
safeChanged,
safeLeft,
);
} catch (err) {
const message = typeof err === "string" ? err : err instanceof Error ? err.message : "";
if (message.includes("Expect value to be String")) {
LogService.warn(
"MatrixClientLite",
"Ignoring malformed device list entries during crypto sync",
message,
);
return;
}
throw err;
}
};
}
return client;
return new MatrixClient(params.homeserver, params.accessToken, undefined, undefined, {
userId: matrixClientUserId,
deviceId: params.deviceId,
encryption: params.encryption,
localTimeoutMs: params.localTimeoutMs,
initialSyncLimit: params.initialSyncLimit,
});
}

View File

@@ -1,4 +1,4 @@
import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
import { ConsoleLogger, LogService } from "../sdk.js";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();

View File

@@ -1,7 +1,7 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { LogService } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import type { CoreConfig } from "../types.js";
import type { MatrixAuth } from "./types.js";
import { LogService } from "../sdk.js";
import { resolveMatrixAuth } from "./config.js";
import { createMatrixClient } from "./create-client.js";
import { DEFAULT_ACCOUNT_KEY } from "./storage.js";
@@ -36,8 +36,10 @@ async function createSharedMatrixClient(params: {
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
deviceId: params.auth.deviceId,
encryption: params.auth.encryption,
localTimeoutMs: params.timeoutMs,
initialSyncLimit: params.auth.initialSyncLimit,
accountId: params.accountId,
});
return {
@@ -158,7 +160,7 @@ export async function waitForMatrixSync(_params: {
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
// @vector-im/matrix-bot-sdk handles sync internally in start()
// matrix-js-sdk handles sync lifecycle in start() for this integration.
// This is kept for API compatibility but is essentially a no-op now
}

View File

@@ -2,6 +2,7 @@ export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
deviceId?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
@@ -19,6 +20,7 @@ export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
deviceName?: string;
initialSyncLimit?: number;
encryption?: boolean;

View File

@@ -5,18 +5,28 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { getMatrixRuntime } from "../runtime.js";
const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
const REQUIRED_MATRIX_PACKAGES = ["matrix-js-sdk", "@matrix-org/matrix-sdk-crypto-nodejs"];
export function isMatrixSdkAvailable(): boolean {
function resolveMissingMatrixPackages(): string[] {
try {
const req = createRequire(import.meta.url);
req.resolve(MATRIX_SDK_PACKAGE);
return true;
return REQUIRED_MATRIX_PACKAGES.filter((pkg) => {
try {
req.resolve(pkg);
return false;
} catch {
return true;
}
});
} catch {
return false;
return [...REQUIRED_MATRIX_PACKAGES];
}
}
export function isMatrixSdkAvailable(): boolean {
return resolveMissingMatrixPackages().length === 0;
}
function resolvePluginRoot(): string {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(currentDir, "..", "..");
@@ -31,9 +41,13 @@ export async function ensureMatrixSdkInstalled(params: {
}
const confirm = params.confirm;
if (confirm) {
const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
const ok = await confirm(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs. Install now?",
);
if (!ok) {
throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first).");
throw new Error(
"Matrix requires matrix-js-sdk and @matrix-org/matrix-sdk-crypto-nodejs (install dependencies first).",
);
}
}
@@ -53,8 +67,11 @@ export async function ensureMatrixSdkInstalled(params: {
);
}
if (!isMatrixSdkAvailable()) {
const missing = resolveMissingMatrixPackages();
throw new Error(
"Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.",
missing.length > 0
? `Matrix dependency install completed but required packages are still missing: ${missing.join(", ")}`
: "Matrix dependency install completed but Matrix dependencies are still missing.",
);
}
}

View File

@@ -0,0 +1,127 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "../../types.js";
import { setMatrixRuntime } from "../../runtime.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
type InviteHandler = (roomId: string, inviteEvent: unknown) => Promise<void>;
function createClientStub() {
let inviteHandler: InviteHandler | null = null;
const client = {
on: vi.fn((eventName: string, listener: unknown) => {
if (eventName === "room.invite") {
inviteHandler = listener as InviteHandler;
}
return client;
}),
joinRoom: vi.fn(async () => {}),
getRoomStateEvent: vi.fn(async () => ({})),
} as unknown as import("../sdk.js").MatrixClient;
return {
client,
getInviteHandler: () => inviteHandler,
joinRoom: (client as unknown as { joinRoom: ReturnType<typeof vi.fn> }).joinRoom,
getRoomStateEvent: (client as unknown as { getRoomStateEvent: ReturnType<typeof vi.fn> })
.getRoomStateEvent,
};
}
describe("registerMatrixAutoJoin", () => {
beforeEach(() => {
setMatrixRuntime({
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime);
});
it("joins all invites when autoJoin=always", async () => {
const { client, getInviteHandler, joinRoom } = createClientStub();
const cfg: CoreConfig = {
channels: {
matrix: {
autoJoin: "always",
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
});
it("ignores invites outside allowlist when autoJoin=allowlist", async () => {
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
getRoomStateEvent.mockResolvedValue({
alias: "#other:example.org",
alt_aliases: ["#else:example.org"],
});
const cfg: CoreConfig = {
channels: {
matrix: {
autoJoin: "allowlist",
autoJoinAllowlist: ["#allowed:example.org"],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).not.toHaveBeenCalled();
});
it("joins invite when alias matches allowlist", async () => {
const { client, getInviteHandler, joinRoom, getRoomStateEvent } = createClientStub();
getRoomStateEvent.mockResolvedValue({
alias: "#allowed:example.org",
alt_aliases: ["#backup:example.org"],
});
const cfg: CoreConfig = {
channels: {
matrix: {
autoJoin: "allowlist",
autoJoinAllowlist: [" #allowed:example.org "],
},
},
};
registerMatrixAutoJoin({
client,
cfg,
runtime: {
log: vi.fn(),
error: vi.fn(),
} as unknown as import("openclaw/plugin-sdk").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
expect(inviteHandler).toBeTruthy();
await inviteHandler!("!room:example.org", {});
expect(joinRoom).toHaveBeenCalledWith("!room:example.org");
});
});

View File

@@ -1,7 +1,6 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
import { getMatrixRuntime } from "../../runtime.js";
export function registerMatrixAutoJoin(params: {
@@ -18,47 +17,52 @@ export function registerMatrixAutoJoin(params: {
runtime.log?.(message);
};
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
const autoJoinAllowlist = new Set(
(cfg.channels?.matrix?.autoJoinAllowlist ?? [])
.map((entry) => String(entry).trim())
.filter(Boolean),
);
if (autoJoin === "off") {
return;
}
if (autoJoin === "always") {
// Use the built-in autojoin mixin for "always" mode
AutojoinRoomsMixin.setupOnClient(client);
logVerbose("matrix: auto-join enabled for all invites");
return;
} else {
logVerbose("matrix: auto-join enabled for allowlist invites");
}
// For "allowlist" mode, handle invites manually
// Handle invites directly so both "always" and "allowlist" modes share the same path.
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin !== "allowlist") {
return;
}
if (autoJoin === "allowlist") {
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState && typeof aliasState.alias === "string" ? aliasState.alias : undefined;
altAliases =
aliasState && Array.isArray(aliasState.alt_aliases)
? aliasState.alt_aliases
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
.filter(Boolean)
: [];
} catch {
// Ignore errors
}
// Get room alias if available
let alias: string | undefined;
let altAliases: string[] = [];
try {
const aliasState = await client
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
.catch(() => null);
alias = aliasState?.alias;
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
} catch {
// Ignore errors
}
const allowed =
autoJoinAllowlist.has("*") ||
autoJoinAllowlist.has(roomId) ||
(alias ? autoJoinAllowlist.has(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.has(value));
const allowed =
autoJoinAllowlist.includes("*") ||
autoJoinAllowlist.includes(roomId) ||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
altAliases.some((value) => autoJoinAllowlist.includes(value));
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
}
try {

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
type DirectMessageCheck = {
roomId: string;

View File

@@ -1,6 +1,6 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk";
import type { MatrixAuth } from "../client.js";
import type { MatrixClient } from "../sdk.js";
import type { MatrixRawEvent } from "./types.js";
import { EventType } from "./types.js";

View File

@@ -1,4 +1,3 @@
import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
createReplyPrefixOptions,
createTypingCallbacks,
@@ -9,6 +8,7 @@ import {
type RuntimeEnv,
} from "openclaw/plugin-sdk";
import type { CoreConfig, ReplyToMode } from "../../types.js";
import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
import {
formatPollAsText,
@@ -116,7 +116,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
try {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
// Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
// Encrypted payloads are emitted separately after decryption.
return;
}
@@ -446,7 +446,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
threadReplies,
messageId,
threadRootId,
isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
isThreadRoot: false, // Raw event payload does not carry explicit thread-root metadata.
});
const route = core.channel.routing.resolveAgentRoute({

View File

@@ -300,7 +300,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
logVerboseMessage("matrix: client started");
// @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
// Shared client is already started via resolveSharedMatrixClient.
logger.info(`matrix: logged in as ${auth.userId}`);
// If E2EE is enabled, trigger device verification

View File

@@ -1,9 +1,9 @@
import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
import {
formatLocationText,
toLocationContext,
type NormalizedLocation,
} from "openclaw/plugin-sdk";
import type { LocationMessageEventContent } from "../sdk.js";
import { EventType } from "./types.js";
export type MatrixLocationPayload = {

View File

@@ -28,7 +28,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",
@@ -69,7 +69,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
} as unknown as import("../sdk.js").MatrixClient;
const file = {
url: "mxc://example/file",

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import { getMatrixRuntime } from "../../runtime.js";
// Type for encrypted file info
@@ -21,7 +21,7 @@ async function fetchMatrixMediaBuffer(params: {
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
// @vector-im/matrix-bot-sdk provides mxcToHttp helper
// The client wrapper exposes mxcToHttp for Matrix media URIs.
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) {
return null;
@@ -41,7 +41,7 @@ async function fetchMatrixMediaBuffer(params: {
/**
* Download and decrypt encrypted media from a Matrix room.
* Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
* Uses the Matrix crypto adapter's decryptMedia helper.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;

View File

@@ -1,5 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "openclaw/plugin-sdk";
import type { MatrixClient } from "../sdk.js";
import { getMatrixRuntime } from "../../runtime.js";
import { sendMessageMatrix } from "../send.js";

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
export type MatrixRoomInfo = {
name?: string;

View File

@@ -1,4 +1,4 @@
// Type for raw Matrix event from @vector-im/matrix-bot-sdk
// Type for raw Matrix event payload consumed by thread helpers.
type MatrixRawEvent = {
event_id: string;
sender: string;

View File

@@ -1,4 +1,4 @@
import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
import type { EncryptedFile, MessageEventContent } from "../sdk.js";
export const EventType = {
RoomMessage: "m.room.message",

View File

@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createMatrixClientMock = vi.fn();
const isBunRuntimeMock = vi.fn(() => false);
vi.mock("./client.js", () => ({
createMatrixClient: (...args: unknown[]) => createMatrixClientMock(...args),
isBunRuntime: () => isBunRuntimeMock(),
}));
import { probeMatrix } from "./probe.js";
describe("probeMatrix", () => {
beforeEach(() => {
vi.clearAllMocks();
isBunRuntimeMock.mockReturnValue(false);
createMatrixClientMock.mockResolvedValue({
getUserId: vi.fn(async () => "@bot:example.org"),
});
});
it("passes undefined userId when not provided", async () => {
const result = await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
timeoutMs: 1234,
});
expect(result.ok).toBe(true);
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: undefined,
accessToken: "tok",
localTimeoutMs: 1234,
});
});
it("trims provided userId before client creation", async () => {
await probeMatrix({
homeserver: "https://matrix.example.org",
accessToken: "tok",
userId: " @bot:example.org ",
timeoutMs: 500,
});
expect(createMatrixClientMock).toHaveBeenCalledWith({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok",
localTimeoutMs: 500,
});
});
});

View File

@@ -43,13 +43,14 @@ export async function probeMatrix(params: {
};
}
try {
const inputUserId = params.userId?.trim() || undefined;
const client = await createMatrixClient({
homeserver: params.homeserver,
userId: params.userId ?? "",
userId: inputUserId,
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
// @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
// The client wrapper resolves user ID via whoami when needed.
const userId = await client.getUserId();
result.ok = true;
result.userId = userId ?? null;

View File

@@ -0,0 +1,342 @@
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
class FakeMatrixEvent extends EventEmitter {
private readonly roomId: string;
private readonly eventId: string;
private readonly sender: string;
private readonly type: string;
private readonly ts: number;
private readonly content: Record<string, unknown>;
private readonly stateKey?: string;
private readonly unsigned?: {
age?: number;
redacted_because?: unknown;
};
private readonly decryptionFailure: boolean;
constructor(params: {
roomId: string;
eventId: string;
sender: string;
type: string;
ts: number;
content: Record<string, unknown>;
stateKey?: string;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
decryptionFailure?: boolean;
}) {
super();
this.roomId = params.roomId;
this.eventId = params.eventId;
this.sender = params.sender;
this.type = params.type;
this.ts = params.ts;
this.content = params.content;
this.stateKey = params.stateKey;
this.unsigned = params.unsigned;
this.decryptionFailure = params.decryptionFailure === true;
}
getRoomId(): string {
return this.roomId;
}
getId(): string {
return this.eventId;
}
getSender(): string {
return this.sender;
}
getType(): string {
return this.type;
}
getTs(): number {
return this.ts;
}
getContent(): Record<string, unknown> {
return this.content;
}
getUnsigned(): { age?: number; redacted_because?: unknown } {
return this.unsigned ?? {};
}
getStateKey(): string | undefined {
return this.stateKey;
}
isDecryptionFailure(): boolean {
return this.decryptionFailure;
}
}
type MatrixJsClientStub = EventEmitter & {
startClient: ReturnType<typeof vi.fn>;
stopClient: ReturnType<typeof vi.fn>;
initRustCrypto: ReturnType<typeof vi.fn>;
getUserId: ReturnType<typeof vi.fn>;
getJoinedRooms: ReturnType<typeof vi.fn>;
getJoinedRoomMembers: ReturnType<typeof vi.fn>;
getStateEvent: ReturnType<typeof vi.fn>;
getAccountData: ReturnType<typeof vi.fn>;
setAccountData: ReturnType<typeof vi.fn>;
getRoomIdForAlias: ReturnType<typeof vi.fn>;
sendMessage: ReturnType<typeof vi.fn>;
sendEvent: ReturnType<typeof vi.fn>;
sendStateEvent: ReturnType<typeof vi.fn>;
redactEvent: ReturnType<typeof vi.fn>;
getProfileInfo: ReturnType<typeof vi.fn>;
joinRoom: ReturnType<typeof vi.fn>;
mxcUrlToHttp: ReturnType<typeof vi.fn>;
uploadContent: ReturnType<typeof vi.fn>;
fetchRoomEvent: ReturnType<typeof vi.fn>;
sendTyping: ReturnType<typeof vi.fn>;
getRoom: ReturnType<typeof vi.fn>;
getCrypto: ReturnType<typeof vi.fn>;
};
function createMatrixJsClientStub(): MatrixJsClientStub {
const client = new EventEmitter() as MatrixJsClientStub;
client.startClient = vi.fn(async () => {});
client.stopClient = vi.fn();
client.initRustCrypto = vi.fn(async () => {});
client.getUserId = vi.fn(() => "@bot:example.org");
client.getJoinedRooms = vi.fn(async () => ({ joined_rooms: [] }));
client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} }));
client.getStateEvent = vi.fn(async () => ({}));
client.getAccountData = vi.fn(() => undefined);
client.setAccountData = vi.fn(async () => {});
client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" }));
client.sendMessage = vi.fn(async () => ({ event_id: "$sent" }));
client.sendEvent = vi.fn(async () => ({ event_id: "$sent-event" }));
client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" }));
client.redactEvent = vi.fn(async () => ({ event_id: "$redact" }));
client.getProfileInfo = vi.fn(async () => ({}));
client.joinRoom = vi.fn(async () => ({}));
client.mxcUrlToHttp = vi.fn(() => null);
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
client.fetchRoomEvent = vi.fn(async () => ({}));
client.sendTyping = vi.fn(async () => {});
client.getRoom = vi.fn(() => ({ hasEncryptionStateEvent: () => false }));
client.getCrypto = vi.fn(() => undefined);
return client;
}
let matrixJsClient = createMatrixJsClientStub();
vi.mock("matrix-js-sdk", () => ({
ClientEvent: { Event: "event" },
MatrixEventEvent: { Decrypted: "decrypted" },
createClient: vi.fn(() => matrixJsClient),
}));
import { MatrixClient } from "./sdk.js";
describe("MatrixClient request hardening", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
vi.useRealTimers();
vi.unstubAllGlobals();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("blocks cross-protocol redirects", async () => {
const fetchMock = vi.fn(async () => {
return new Response("", {
status: 302,
headers: {
location: "http://evil.example.org/next",
},
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await expect(client.doRequest("GET", "https://matrix.example.org/start")).rejects.toThrow(
"Blocked cross-protocol redirect",
);
});
it("strips authorization when redirect crosses origin", async () => {
const calls: Array<{ url: string; headers: Headers }> = [];
const fetchMock = vi.fn(async (url: URL | string, init?: RequestInit) => {
calls.push({
url: String(url),
headers: new Headers(init?.headers),
});
if (calls.length === 1) {
return new Response("", {
status: 302,
headers: { location: "https://cdn.example.org/next" },
});
}
return new Response("{}", {
status: 200,
headers: { "content-type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");
await client.doRequest("GET", "https://matrix.example.org/start");
expect(calls).toHaveLength(2);
expect(calls[0]?.url).toBe("https://matrix.example.org/start");
expect(calls[0]?.headers.get("authorization")).toBe("Bearer token");
expect(calls[1]?.url).toBe("https://cdn.example.org/next");
expect(calls[1]?.headers.get("authorization")).toBeNull();
});
it("aborts requests after timeout", async () => {
vi.useFakeTimers();
const fetchMock = vi.fn((_: URL | string, init?: RequestInit) => {
return new Promise<Response>((_, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(new Error("aborted"));
});
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
localTimeoutMs: 25,
});
const pending = client.doRequest("GET", "/_matrix/client/v3/account/whoami");
const assertion = expect(pending).rejects.toThrow("aborted");
await vi.advanceTimersByTimeAsync(30);
await assertion;
});
});
describe("MatrixClient event bridge", () => {
beforeEach(() => {
matrixJsClient = createMatrixJsClientStub();
});
it("emits room.message only after encrypted events decrypt", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const messageEvents: Array<{ roomId: string; type: string }> = [];
client.on("room.message", (roomId, event) => {
messageEvents.push({ roomId, type: event.type });
});
await client.start();
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
});
const decrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.message",
ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
},
});
matrixJsClient.emit("event", encrypted);
expect(messageEvents).toHaveLength(0);
encrypted.emit("decrypted", decrypted);
// Simulate a second normal event emission from the SDK after decryption.
matrixJsClient.emit("event", decrypted);
expect(messageEvents).toEqual([
{
roomId: "!room:example.org",
type: "m.room.message",
},
]);
});
it("emits room.failed_decryption when decrypting fails", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const failed: string[] = [];
const delivered: string[] = [];
client.on("room.failed_decryption", (_roomId, _event, error) => {
failed.push(error.message);
});
client.on("room.message", (_roomId, event) => {
delivered.push(event.type);
});
await client.start();
const encrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.encrypted",
ts: Date.now(),
content: {},
});
const decrypted = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$event",
sender: "@alice:example.org",
type: "m.room.message",
ts: Date.now(),
content: {
msgtype: "m.text",
body: "hello",
},
});
matrixJsClient.emit("event", encrypted);
encrypted.emit("decrypted", decrypted, new Error("decrypt failed"));
expect(failed).toEqual(["decrypt failed"]);
expect(delivered).toHaveLength(0);
});
it("emits room.invite when a membership invite targets the current user", async () => {
const client = new MatrixClient("https://matrix.example.org", "token");
const invites: string[] = [];
client.on("room.invite", (roomId) => {
invites.push(roomId);
});
await client.start();
const inviteMembership = new FakeMatrixEvent({
roomId: "!room:example.org",
eventId: "$invite",
sender: "@alice:example.org",
type: "m.room.member",
ts: Date.now(),
stateKey: "@bot:example.org",
content: {
membership: "invite",
},
});
matrixJsClient.emit("event", inviteMembership);
expect(invites).toEqual(["!room:example.org"]);
});
});

View File

@@ -0,0 +1,923 @@
import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs";
import {
ClientEvent,
MatrixEventEvent,
createClient as createMatrixJsClient,
type MatrixClient as MatrixJsClient,
type MatrixEvent,
} from "matrix-js-sdk";
import { EventEmitter } from "node:events";
type Logger = {
trace: (module: string, ...messageOrObject: unknown[]) => void;
debug: (module: string, ...messageOrObject: unknown[]) => void;
info: (module: string, ...messageOrObject: unknown[]) => void;
warn: (module: string, ...messageOrObject: unknown[]) => void;
error: (module: string, ...messageOrObject: unknown[]) => void;
};
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
type QueryValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean | null | undefined>;
type QueryParams = Record<string, QueryValue> | null | undefined;
type MatrixRawEvent = {
event_id: string;
sender: string;
type: string;
origin_server_ts: number;
content: Record<string, unknown>;
unsigned?: {
age?: number;
redacted_because?: unknown;
};
state_key?: string;
};
type MatrixClientEventMap = {
"room.event": [roomId: string, event: MatrixRawEvent];
"room.message": [roomId: string, event: MatrixRawEvent];
"room.encrypted_event": [roomId: string, event: MatrixRawEvent];
"room.decrypted_event": [roomId: string, event: MatrixRawEvent];
"room.failed_decryption": [roomId: string, event: MatrixRawEvent, error: Error];
"room.invite": [roomId: string, event: MatrixRawEvent];
"room.join": [roomId: string, event: MatrixRawEvent];
};
function noop(): void {
// no-op
}
export class ConsoleLogger {
trace(module: string, ...messageOrObject: unknown[]): void {
console.debug(`[${module}]`, ...messageOrObject);
}
debug(module: string, ...messageOrObject: unknown[]): void {
console.debug(`[${module}]`, ...messageOrObject);
}
info(module: string, ...messageOrObject: unknown[]): void {
console.info(`[${module}]`, ...messageOrObject);
}
warn(module: string, ...messageOrObject: unknown[]): void {
console.warn(`[${module}]`, ...messageOrObject);
}
error(module: string, ...messageOrObject: unknown[]): void {
console.error(`[${module}]`, ...messageOrObject);
}
}
const defaultLogger = new ConsoleLogger();
let activeLogger: Logger = defaultLogger;
export const LogService = {
setLogger(logger: Logger): void {
activeLogger = logger;
},
trace(module: string, ...messageOrObject: unknown[]): void {
activeLogger.trace(module, ...messageOrObject);
},
debug(module: string, ...messageOrObject: unknown[]): void {
activeLogger.debug(module, ...messageOrObject);
},
info(module: string, ...messageOrObject: unknown[]): void {
activeLogger.info(module, ...messageOrObject);
},
warn(module: string, ...messageOrObject: unknown[]): void {
activeLogger.warn(module, ...messageOrObject);
},
error(module: string, ...messageOrObject: unknown[]): void {
activeLogger.error(module, ...messageOrObject);
},
};
export type EncryptedFile = {
url: string;
key: {
kty: string;
key_ops: string[];
alg: string;
k: string;
ext: boolean;
};
iv: string;
hashes: Record<string, string>;
v: string;
};
export type FileWithThumbnailInfo = {
size?: number;
mimetype?: string;
thumbnail_url?: string;
thumbnail_info?: {
w?: number;
h?: number;
mimetype?: string;
size?: number;
};
};
export type DimensionalFileInfo = FileWithThumbnailInfo & {
w?: number;
h?: number;
};
export type TimedFileInfo = FileWithThumbnailInfo & {
duration?: number;
};
export type VideoFileInfo = DimensionalFileInfo &
TimedFileInfo & {
duration?: number;
};
export type MessageEventContent = {
msgtype?: string;
body?: string;
format?: string;
formatted_body?: string;
filename?: string;
url?: string;
file?: EncryptedFile;
info?: Record<string, unknown>;
"m.relates_to"?: Record<string, unknown>;
"m.new_content"?: unknown;
"m.mentions"?: {
user_ids?: string[];
room?: boolean;
};
[key: string]: unknown;
};
export type TextualMessageEventContent = MessageEventContent & {
msgtype: string;
body: string;
};
export type LocationMessageEventContent = MessageEventContent & {
msgtype?: string;
geo_uri?: string;
};
type MatrixCryptoFacade = {
prepare: (joinedRooms: string[]) => Promise<void>;
updateSyncData: (
toDeviceMessages: unknown,
otkCounts: unknown,
unusedFallbackKeyAlgs: unknown,
changedDeviceLists: unknown,
leftDeviceLists: unknown,
) => Promise<void>;
isRoomEncrypted: (roomId: string) => Promise<boolean>;
requestOwnUserVerification: () => Promise<unknown | null>;
encryptMedia: (buffer: Buffer) => Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }>;
decryptMedia: (file: EncryptedFile) => Promise<Buffer>;
};
export class MatrixClient {
private readonly client: MatrixJsClient;
private readonly emitter = new EventEmitter();
private readonly homeserver: string;
private readonly accessToken: string;
private readonly localTimeoutMs: number;
private readonly initialSyncLimit?: number;
private readonly encryptionEnabled: boolean;
private bridgeRegistered = false;
private started = false;
private selfUserId: string | null;
private readonly dmRoomIds = new Set<string>();
private cryptoInitialized = false;
private readonly decryptedMessageDedupe = new Map<string, number>();
readonly dms = {
update: async (): Promise<void> => {
await this.refreshDmCache();
},
isDm: (roomId: string): boolean => this.dmRoomIds.has(roomId),
};
crypto?: MatrixCryptoFacade;
constructor(
homeserver: string,
accessToken: string,
_storage?: unknown,
_cryptoStorage?: unknown,
opts: {
userId?: string;
deviceId?: string;
localTimeoutMs?: number;
encryption?: boolean;
initialSyncLimit?: number;
} = {},
) {
this.homeserver = homeserver;
this.accessToken = accessToken;
this.localTimeoutMs = Math.max(1, opts.localTimeoutMs ?? 60_000);
this.initialSyncLimit = opts.initialSyncLimit;
this.encryptionEnabled = opts.encryption === true;
this.selfUserId = opts.userId?.trim() || null;
this.client = createMatrixJsClient({
baseUrl: homeserver,
accessToken,
userId: opts.userId,
deviceId: opts.deviceId,
localTimeoutMs: this.localTimeoutMs,
});
if (this.encryptionEnabled) {
this.crypto = this.createCryptoFacade();
}
}
on<TEvent extends keyof MatrixClientEventMap>(
eventName: TEvent,
listener: (...args: MatrixClientEventMap[TEvent]) => void,
): this;
on(eventName: string, listener: (...args: unknown[]) => void): this;
on(eventName: string, listener: (...args: unknown[]) => void): this {
this.emitter.on(eventName, listener as (...args: unknown[]) => void);
return this;
}
off<TEvent extends keyof MatrixClientEventMap>(
eventName: TEvent,
listener: (...args: MatrixClientEventMap[TEvent]) => void,
): this;
off(eventName: string, listener: (...args: unknown[]) => void): this;
off(eventName: string, listener: (...args: unknown[]) => void): this {
this.emitter.off(eventName, listener as (...args: unknown[]) => void);
return this;
}
async start(): Promise<void> {
if (this.started) {
return;
}
this.registerBridge();
if (this.encryptionEnabled && !this.cryptoInitialized) {
try {
await this.client.initRustCrypto();
this.cryptoInitialized = true;
} catch (err) {
LogService.warn("MatrixClientLite", "Failed to initialize rust crypto:", err);
}
}
await this.client.startClient({
initialSyncLimit: this.initialSyncLimit,
});
this.started = true;
await this.refreshDmCache().catch(noop);
}
stop(): void {
this.client.stopClient();
this.started = false;
}
async getUserId(): Promise<string> {
const fromClient = this.client.getUserId();
if (fromClient) {
this.selfUserId = fromClient;
return fromClient;
}
if (this.selfUserId) {
return this.selfUserId;
}
const whoami = (await this.doRequest("GET", "/_matrix/client/v3/account/whoami")) as {
user_id?: string;
};
const resolved = whoami.user_id?.trim();
if (!resolved) {
throw new Error("Matrix whoami did not return user_id");
}
this.selfUserId = resolved;
return resolved;
}
async getJoinedRooms(): Promise<string[]> {
const joined = await this.client.getJoinedRooms();
return Array.isArray(joined.joined_rooms) ? joined.joined_rooms : [];
}
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
const members = await this.client.getJoinedRoomMembers(roomId);
const joined = members?.joined;
if (!joined || typeof joined !== "object") {
return [];
}
return Object.keys(joined);
}
async getRoomStateEvent(
roomId: string,
eventType: string,
stateKey = "",
): Promise<Record<string, unknown>> {
const state = await this.client.getStateEvent(roomId, eventType, stateKey);
return (state ?? {}) as Record<string, unknown>;
}
async getAccountData(eventType: string): Promise<Record<string, unknown> | undefined> {
const event = this.client.getAccountData(eventType);
return (event?.getContent() as Record<string, unknown> | undefined) ?? undefined;
}
async setAccountData(eventType: string, content: Record<string, unknown>): Promise<void> {
await this.client.setAccountData(eventType as never, content as never);
await this.refreshDmCache().catch(noop);
}
async resolveRoom(aliasOrRoomId: string): Promise<string | null> {
if (aliasOrRoomId.startsWith("!")) {
return aliasOrRoomId;
}
if (!aliasOrRoomId.startsWith("#")) {
return aliasOrRoomId;
}
try {
const resolved = await this.client.getRoomIdForAlias(aliasOrRoomId);
return resolved.room_id ?? null;
} catch {
return null;
}
}
async sendMessage(roomId: string, content: MessageEventContent): Promise<string> {
const sent = await this.client.sendMessage(roomId, content as never);
return sent.event_id;
}
async sendEvent(
roomId: string,
eventType: string,
content: Record<string, unknown>,
): Promise<string> {
const sent = await this.client.sendEvent(roomId, eventType as never, content as never);
return sent.event_id;
}
async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
content: Record<string, unknown>,
): Promise<string> {
const sent = await this.client.sendStateEvent(
roomId,
eventType as never,
content as never,
stateKey,
);
return sent.event_id;
}
async redactEvent(roomId: string, eventId: string, reason?: string): Promise<string> {
const sent = await this.client.redactEvent(
roomId,
eventId,
undefined,
reason?.trim() ? { reason } : undefined,
);
return sent.event_id;
}
async doRequest(
method: HttpMethod,
endpoint: string,
qs?: QueryParams,
body?: unknown,
): Promise<unknown> {
return await this.requestJson({
method,
endpoint,
qs,
body,
timeoutMs: this.localTimeoutMs,
});
}
async getUserProfile(userId: string): Promise<{ displayname?: string; avatar_url?: string }> {
return await this.client.getProfileInfo(userId);
}
async joinRoom(roomId: string): Promise<void> {
await this.client.joinRoom(roomId);
}
mxcToHttp(mxcUrl: string): string | null {
return this.client.mxcUrlToHttp(mxcUrl, undefined, undefined, undefined, true, false, true);
}
async downloadContent(mxcUrl: string, allowRemote = true): Promise<Buffer> {
const parsed = parseMxc(mxcUrl);
if (!parsed) {
throw new Error(`Invalid Matrix content URI: ${mxcUrl}`);
}
const endpoint = `/_matrix/media/v3/download/${encodeURIComponent(parsed.server)}/${encodeURIComponent(parsed.mediaId)}`;
const response = await this.requestRaw({
method: "GET",
endpoint,
qs: { allow_remote: allowRemote },
timeoutMs: this.localTimeoutMs,
});
return response;
}
async uploadContent(file: Buffer, contentType?: string, filename?: string): Promise<string> {
const uploaded = await this.client.uploadContent(file, {
type: contentType || "application/octet-stream",
name: filename,
includeFilename: Boolean(filename),
});
return uploaded.content_uri;
}
async getEvent(roomId: string, eventId: string): Promise<Record<string, unknown>> {
return (await this.client.fetchRoomEvent(roomId, eventId)) as Record<string, unknown>;
}
async setTyping(roomId: string, typing: boolean, timeoutMs: number): Promise<void> {
await this.client.sendTyping(roomId, typing, timeoutMs);
}
async sendReadReceipt(roomId: string, eventId: string): Promise<void> {
await this.requestJson({
method: "POST",
endpoint: `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/receipt/m.read/${encodeURIComponent(
eventId,
)}`,
body: {},
timeoutMs: this.localTimeoutMs,
});
}
private registerBridge(): void {
if (this.bridgeRegistered) {
return;
}
this.bridgeRegistered = true;
this.client.on(ClientEvent.Event, (event: MatrixEvent) => {
const roomId = event.getRoomId();
if (!roomId) {
return;
}
const raw = matrixEventToRaw(event);
const isEncryptedEvent = raw.type === "m.room.encrypted";
this.emitter.emit("room.event", roomId, raw);
if (isEncryptedEvent) {
this.emitter.emit("room.encrypted_event", roomId, raw);
} else {
if (!this.isDuplicateDecryptedMessage(roomId, raw.event_id)) {
this.emitter.emit("room.message", roomId, raw);
}
}
const stateKey = raw.state_key ?? "";
const selfUserId = this.client.getUserId() ?? this.selfUserId ?? "";
const membership =
raw.type === "m.room.member"
? (raw.content as { membership?: string }).membership
: undefined;
if (stateKey && selfUserId && stateKey === selfUserId) {
if (membership === "invite") {
this.emitter.emit("room.invite", roomId, raw);
} else if (membership === "join") {
this.emitter.emit("room.join", roomId, raw);
}
}
if (isEncryptedEvent) {
event.on(MatrixEventEvent.Decrypted, (decryptedEvent: MatrixEvent, err?: Error) => {
const decryptedRoomId = decryptedEvent.getRoomId() || roomId;
const decryptedRaw = matrixEventToRaw(decryptedEvent);
if (err) {
this.emitter.emit("room.failed_decryption", decryptedRoomId, decryptedRaw, err);
return;
}
const failed =
typeof (decryptedEvent as { isDecryptionFailure?: () => boolean })
.isDecryptionFailure === "function" &&
(decryptedEvent as { isDecryptionFailure: () => boolean }).isDecryptionFailure();
if (failed) {
this.emitter.emit(
"room.failed_decryption",
decryptedRoomId,
decryptedRaw,
new Error("Matrix event failed to decrypt"),
);
return;
}
this.emitter.emit("room.decrypted_event", decryptedRoomId, decryptedRaw);
this.rememberDecryptedMessage(decryptedRoomId, decryptedRaw.event_id);
this.emitter.emit("room.message", decryptedRoomId, decryptedRaw);
});
}
});
}
private rememberDecryptedMessage(roomId: string, eventId: string): void {
if (!eventId) {
return;
}
const now = Date.now();
this.pruneDecryptedMessageDedupe(now);
this.decryptedMessageDedupe.set(`${roomId}|${eventId}`, now);
}
private isDuplicateDecryptedMessage(roomId: string, eventId: string): boolean {
if (!eventId) {
return false;
}
const key = `${roomId}|${eventId}`;
const createdAt = this.decryptedMessageDedupe.get(key);
if (createdAt === undefined) {
return false;
}
this.decryptedMessageDedupe.delete(key);
return true;
}
private pruneDecryptedMessageDedupe(now: number): void {
const ttlMs = 30_000;
for (const [key, createdAt] of this.decryptedMessageDedupe) {
if (now - createdAt > ttlMs) {
this.decryptedMessageDedupe.delete(key);
}
}
const maxEntries = 2048;
while (this.decryptedMessageDedupe.size > maxEntries) {
const oldest = this.decryptedMessageDedupe.keys().next().value;
if (oldest === undefined) {
break;
}
this.decryptedMessageDedupe.delete(oldest);
}
}
private createCryptoFacade(): MatrixCryptoFacade {
return {
prepare: async (_joinedRooms: string[]) => {
// matrix-js-sdk performs crypto prep during startup; no extra work required here.
},
updateSyncData: async (
_toDeviceMessages: unknown,
_otkCounts: unknown,
_unusedFallbackKeyAlgs: unknown,
_changedDeviceLists: unknown,
_leftDeviceLists: unknown,
) => {
// compatibility no-op
},
isRoomEncrypted: async (roomId: string): Promise<boolean> => {
const room = this.client.getRoom(roomId);
if (room?.hasEncryptionStateEvent()) {
return true;
}
try {
const event = await this.getRoomStateEvent(roomId, "m.room.encryption", "");
return typeof event.algorithm === "string" && event.algorithm.length > 0;
} catch {
return false;
}
},
requestOwnUserVerification: async (): Promise<unknown | null> => {
const crypto = this.client.getCrypto();
if (!crypto) {
return null;
}
return await crypto.requestOwnUserVerification();
},
encryptMedia: async (
buffer: Buffer,
): Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }> => {
const encrypted = Attachment.encrypt(new Uint8Array(buffer));
const mediaInfoJson = encrypted.mediaEncryptionInfo;
if (!mediaInfoJson) {
throw new Error("Matrix media encryption failed: missing media encryption info");
}
const parsed = JSON.parse(mediaInfoJson) as EncryptedFile;
return {
buffer: Buffer.from(encrypted.encryptedData),
file: {
key: parsed.key,
iv: parsed.iv,
hashes: parsed.hashes,
v: parsed.v,
},
};
},
decryptMedia: async (file: EncryptedFile): Promise<Buffer> => {
const encrypted = await this.downloadContent(file.url);
const metadata: EncryptedFile = {
url: file.url,
key: file.key,
iv: file.iv,
hashes: file.hashes,
v: file.v,
};
const attachment = new EncryptedAttachment(
new Uint8Array(encrypted),
JSON.stringify(metadata),
);
const decrypted = Attachment.decrypt(attachment);
return Buffer.from(decrypted);
},
};
}
private async refreshDmCache(): Promise<void> {
const direct = await this.getAccountData("m.direct");
this.dmRoomIds.clear();
if (!direct || typeof direct !== "object") {
return;
}
for (const value of Object.values(direct)) {
if (!Array.isArray(value)) {
continue;
}
for (const roomId of value) {
if (typeof roomId === "string" && roomId.trim()) {
this.dmRoomIds.add(roomId);
}
}
}
}
private async requestJson(params: {
method: HttpMethod;
endpoint: string;
qs?: QueryParams;
body?: unknown;
timeoutMs: number;
}): Promise<unknown> {
const { response, text } = await this.performRequest({
method: params.method,
endpoint: params.endpoint,
qs: params.qs,
body: params.body,
timeoutMs: params.timeoutMs,
});
if (!response.ok) {
throw buildHttpError(response.status, text);
}
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
if (!text.trim()) {
return {};
}
return JSON.parse(text);
}
return text;
}
private async requestRaw(params: {
method: HttpMethod;
endpoint: string;
qs?: QueryParams;
timeoutMs: number;
}): Promise<Buffer> {
const { response, buffer } = await this.performRequest({
method: params.method,
endpoint: params.endpoint,
qs: params.qs,
timeoutMs: params.timeoutMs,
raw: true,
});
if (!response.ok) {
throw buildHttpError(response.status, buffer.toString("utf8"));
}
return buffer;
}
private async performRequest(params: {
method: HttpMethod;
endpoint: string;
qs?: QueryParams;
body?: unknown;
timeoutMs: number;
raw?: boolean;
}): Promise<{ response: Response; text: string; buffer: Buffer }> {
const baseUrl =
params.endpoint.startsWith("http://") || params.endpoint.startsWith("https://")
? new URL(params.endpoint)
: new URL(normalizeEndpoint(params.endpoint), this.homeserver);
applyQuery(baseUrl, params.qs);
const headers = new Headers();
headers.set("Accept", params.raw ? "*/*" : "application/json");
if (this.accessToken) {
headers.set("Authorization", `Bearer ${this.accessToken}`);
}
let body: BodyInit | undefined;
if (params.body !== undefined) {
if (
params.body instanceof Uint8Array ||
params.body instanceof ArrayBuffer ||
typeof params.body === "string"
) {
body = params.body as BodyInit;
} else {
headers.set("Content-Type", "application/json");
body = JSON.stringify(params.body);
}
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), params.timeoutMs);
try {
const response = await fetchWithSafeRedirects(baseUrl, {
method: params.method,
headers,
body,
signal: controller.signal,
});
if (params.raw) {
const bytes = Buffer.from(await response.arrayBuffer());
return {
response,
text: bytes.toString("utf8"),
buffer: bytes,
};
}
const text = await response.text();
return {
response,
text,
buffer: Buffer.from(text, "utf8"),
};
} finally {
clearTimeout(timeoutId);
}
}
}
function matrixEventToRaw(event: MatrixEvent): MatrixRawEvent {
const unsigned = (event.getUnsigned?.() ?? {}) as {
age?: number;
redacted_because?: unknown;
};
const raw: MatrixRawEvent = {
event_id: event.getId() ?? "",
sender: event.getSender() ?? "",
type: event.getType() ?? "",
origin_server_ts: event.getTs() ?? 0,
content: ((event.getContent?.() ?? {}) as Record<string, unknown>) || {},
unsigned,
};
const stateKey = resolveMatrixStateKey(event);
if (typeof stateKey === "string") {
raw.state_key = stateKey;
}
return raw;
}
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;
}
function normalizeEndpoint(endpoint: string): string {
if (!endpoint) {
return "/";
}
return endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
}
function applyQuery(url: URL, qs: QueryParams): void {
if (!qs) {
return;
}
for (const [key, rawValue] of Object.entries(qs)) {
if (rawValue === undefined || rawValue === null) {
continue;
}
if (Array.isArray(rawValue)) {
for (const item of rawValue) {
if (item === undefined || item === null) {
continue;
}
url.searchParams.append(key, String(item));
}
continue;
}
url.searchParams.set(key, String(rawValue));
}
}
function parseMxc(url: string): { server: string; mediaId: string } | null {
const match = /^mxc:\/\/([^/]+)\/(.+)$/.exec(url.trim());
if (!match) {
return null;
}
return {
server: match[1],
mediaId: match[2],
};
}
function buildHttpError(statusCode: number, bodyText: string): Error & { statusCode: number } {
let message = `Matrix HTTP ${statusCode}`;
if (bodyText.trim()) {
try {
const parsed = JSON.parse(bodyText) as { error?: string };
if (typeof parsed.error === "string" && parsed.error.trim()) {
message = parsed.error.trim();
} else {
message = bodyText.slice(0, 500);
}
} catch {
message = bodyText.slice(0, 500);
}
}
return Object.assign(new Error(message), { statusCode });
}
function isRedirectStatus(statusCode: number): boolean {
return statusCode >= 300 && statusCode < 400;
}
async function fetchWithSafeRedirects(url: URL, init: RequestInit): Promise<Response> {
let currentUrl = new URL(url.toString());
let method = (init.method ?? "GET").toUpperCase();
let body = init.body;
let headers = new Headers(init.headers ?? {});
const maxRedirects = 5;
for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) {
const response = await fetch(currentUrl, {
...init,
method,
body,
headers,
redirect: "manual",
});
if (!isRedirectStatus(response.status)) {
return response;
}
const location = response.headers.get("location");
if (!location) {
throw new Error(`Matrix redirect missing location header (${currentUrl.toString()})`);
}
const nextUrl = new URL(location, currentUrl);
if (nextUrl.protocol !== currentUrl.protocol) {
throw new Error(
`Blocked cross-protocol redirect (${currentUrl.protocol} -> ${nextUrl.protocol})`,
);
}
if (nextUrl.origin !== currentUrl.origin) {
headers = new Headers(headers);
headers.delete("authorization");
}
if (
response.status === 303 ||
((response.status === 301 || response.status === 302) &&
method !== "GET" &&
method !== "HEAD")
) {
method = "GET";
body = undefined;
headers = new Headers(headers);
headers.delete("content-type");
headers.delete("content-length");
}
currentUrl = nextUrl;
}
throw new Error(`Too many redirects while requesting ${url.toString()}`);
}

View File

@@ -2,22 +2,6 @@ import type { PluginRuntime } from "openclaw/plugin-sdk";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../runtime.js";
vi.mock("@vector-im/matrix-bot-sdk", () => ({
ConsoleLogger: class {
trace = vi.fn();
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
LogService: {
setLogger: vi.fn(),
},
MatrixClient: vi.fn(),
SimpleFsStorageProvider: vi.fn(),
RustSdkCryptoStorageProvider: vi.fn(),
}));
const loadWebMediaMock = vi.fn().mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "photo.png",
@@ -59,7 +43,7 @@ const makeClient = () => {
sendMessage,
uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
} as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
} as unknown as import("./sdk.js").MatrixClient;
return { client, sendMessage, uploadContent };
};

View File

@@ -1,5 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PollInput } from "openclaw/plugin-sdk";
import type { MatrixClient } from "./sdk.js";
import { getMatrixRuntime } from "../runtime.js";
import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
@@ -71,7 +71,6 @@ export async function sendMessageMatrix(
? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => {
// @vector-im/matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
@@ -175,7 +174,6 @@ export async function sendPollMatrix(
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent;
// @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
@@ -60,7 +60,6 @@ export async function resolveMatrixClient(opts: {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
// @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}

View File

@@ -1,3 +1,4 @@
import { parseBuffer, type IFileInfo } from "music-metadata";
import type {
DimensionalFileInfo,
EncryptedFile,
@@ -5,8 +6,7 @@ import type {
MatrixClient,
TimedFileInfo,
VideoFileInfo,
} from "@vector-im/matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata";
} from "../sdk.js";
import { getMatrixRuntime } from "../../runtime.js";
import { applyMatrixFormatting } from "./formatting.js";
import {

View File

@@ -1,5 +1,5 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "../sdk.js";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;

View File

@@ -1,4 +1,4 @@
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MatrixClient } from "../sdk.js";
import { EventType, type MatrixDirectAccountData } from "./types.js";
function normalizeTarget(raw: string): string {

View File

@@ -6,7 +6,7 @@ import type {
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
} from "@vector-im/matrix-bot-sdk";
} from "../sdk.js";
// Message types
export const MsgType = {
@@ -85,7 +85,7 @@ export type MatrixSendResult = {
};
export type MatrixSendOpts = {
client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
client?: import("../sdk.js").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;

View File

@@ -192,11 +192,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
statusLines: [
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady
? "install @vector-im/matrix-bot-sdk"
: configured
? "configured"
: "needs auth",
selectionHint: !sdkReady ? "install matrix-js-sdk" : configured ? "configured" : "needs auth",
};
},
configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => {

View File

@@ -53,7 +53,7 @@ export type MatrixConfig = {
password?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
/** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */
/** Initial sync limit for startup (defaults to matrix-js-sdk behavior). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;

991
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff