Compare commits
2 Commits
docs/syste
...
codex/matr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66c0f4bcc7 | ||
|
|
35f60d65d5 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import {
|
||||
EventType,
|
||||
type MatrixMessageSummary,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
|
||||
let activeClient: MatrixClient | null = null;
|
||||
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
127
extensions/matrix/src/matrix/monitor/auto-join.test.ts
Normal file
127
extensions/matrix/src/matrix/monitor/auto-join.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
type DirectMessageCheck = {
|
||||
roomId: string;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
export type MatrixRoomInfo = {
|
||||
name?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
53
extensions/matrix/src/matrix/probe.test.ts
Normal file
53
extensions/matrix/src/matrix/probe.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
342
extensions/matrix/src/matrix/sdk.test.ts
Normal file
342
extensions/matrix/src/matrix/sdk.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
923
extensions/matrix/src/matrix/sdk.ts
Normal file
923
extensions/matrix/src/matrix/sdk.ts
Normal 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()}`);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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
991
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user