Files
openclaw/extensions/matrix/src/matrix/client.ts
T

339 lines
9.3 KiB
TypeScript

import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../runtime.js";
export type MatrixResolvedConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceName?: string;
initialSyncLimit?: number;
};
export type MatrixAuth = {
homeserver: string;
userId: string;
accessToken: string;
deviceName?: string;
initialSyncLimit?: number;
};
type MatrixSdk = typeof import("matrix-js-sdk");
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
started: boolean;
};
let sharedClientState: SharedMatrixClientState | null = null;
let sharedClientPromise: Promise<SharedMatrixClientState> | null = null;
let sharedClientStartPromise: Promise<void> | null = null;
export function isBunRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun === "string";
}
async function loadMatrixSdk(): Promise<MatrixSdk> {
return (await import("matrix-js-sdk")) as MatrixSdk;
}
function clean(value?: string): string {
return value?.trim() ?? "";
}
export function resolveMatrixConfig(
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
env: NodeJS.ProcessEnv = process.env,
): MatrixResolvedConfig {
const matrix = cfg.channels?.matrix ?? {};
const homeserver = clean(matrix.homeserver) || clean(env.MATRIX_HOMESERVER);
const userId = clean(matrix.userId) || clean(env.MATRIX_USER_ID);
const accessToken =
clean(matrix.accessToken) || clean(env.MATRIX_ACCESS_TOKEN) || undefined;
const password = clean(matrix.password) || clean(env.MATRIX_PASSWORD) || undefined;
const deviceName =
clean(matrix.deviceName) || clean(env.MATRIX_DEVICE_NAME) || undefined;
const initialSyncLimit =
typeof matrix.initialSyncLimit === "number"
? Math.max(0, Math.floor(matrix.initialSyncLimit))
: undefined;
return {
homeserver,
userId,
accessToken,
password,
deviceName,
initialSyncLimit,
};
}
export async function resolveMatrixAuth(params?: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
}): Promise<MatrixAuth> {
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const env = params?.env ?? process.env;
const resolved = resolveMatrixConfig(cfg, env);
if (!resolved.homeserver) {
throw new Error("Matrix homeserver is required (matrix.homeserver)");
}
if (!resolved.userId) {
throw new Error("Matrix userId is required (matrix.userId)");
}
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("./credentials.js");
const cached = loadMatrixCredentials(env);
const cachedCredentials =
cached &&
credentialsMatchConfig(cached, {
homeserver: resolved.homeserver,
userId: resolved.userId,
})
? cached
: null;
if (resolved.accessToken) {
if (cachedCredentials && cachedCredentials.accessToken === resolved.accessToken) {
touchMatrixCredentials(env);
}
return {
homeserver: resolved.homeserver,
userId: resolved.userId,
accessToken: resolved.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
};
}
if (cachedCredentials) {
touchMatrixCredentials(env);
return {
homeserver: cachedCredentials.homeserver,
userId: cachedCredentials.userId,
accessToken: cachedCredentials.accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
};
}
if (!resolved.password) {
throw new Error(
"Matrix access token or password is required (matrix.accessToken or matrix.password)",
);
}
const sdk = await loadMatrixSdk();
const loginClient = sdk.createClient({
baseUrl: resolved.homeserver,
});
const login = await loginClient.loginRequest({
type: "m.login.password",
identifier: { type: "m.id.user", user: resolved.userId },
password: resolved.password,
initial_device_display_name: resolved.deviceName ?? "Clawdbot Gateway",
});
const accessToken = login.access_token?.trim();
if (!accessToken) {
throw new Error("Matrix login did not return an access token");
}
const auth: MatrixAuth = {
homeserver: resolved.homeserver,
userId: login.user_id ?? resolved.userId,
accessToken,
deviceName: resolved.deviceName,
initialSyncLimit: resolved.initialSyncLimit,
};
saveMatrixCredentials({
homeserver: auth.homeserver,
userId: auth.userId,
accessToken: auth.accessToken,
});
return auth;
}
export async function createMatrixClient(params: {
homeserver: string;
userId: string;
accessToken: string;
localTimeoutMs?: number;
}): Promise<MatrixClient> {
const sdk = await loadMatrixSdk();
const store = new sdk.MemoryStore();
return sdk.createClient({
baseUrl: params.homeserver,
userId: params.userId,
accessToken: params.accessToken,
localTimeoutMs: params.localTimeoutMs,
store,
});
}
function buildSharedClientKey(auth: MatrixAuth): string {
return [auth.homeserver, auth.userId, auth.accessToken].join("|");
}
async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
}): Promise<SharedMatrixClientState> {
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
localTimeoutMs: params.timeoutMs,
});
return { client, key: buildSharedClientKey(params.auth), started: false };
}
async function ensureSharedClientStarted(params: {
state: SharedMatrixClientState;
timeoutMs?: number;
initialSyncLimit?: number;
}): Promise<void> {
if (params.state.started) return;
if (sharedClientStartPromise) {
await sharedClientStartPromise;
return;
}
sharedClientStartPromise = (async () => {
const startOpts: Parameters<MatrixClient["startClient"]>[0] = {
lazyLoadMembers: true,
threadSupport: true,
};
if (typeof params.initialSyncLimit === "number") {
startOpts.initialSyncLimit = params.initialSyncLimit;
}
await params.state.client.startClient(startOpts);
await waitForMatrixSync({
client: params.state.client,
timeoutMs: params.timeoutMs,
});
params.state.started = true;
})();
try {
await sharedClientStartPromise;
} finally {
sharedClientStartPromise = null;
}
}
export async function resolveSharedMatrixClient(
params: {
cfg?: CoreConfig;
env?: NodeJS.ProcessEnv;
timeoutMs?: number;
auth?: MatrixAuth;
startClient?: boolean;
} = {},
): Promise<MatrixClient> {
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
const key = buildSharedClientKey(auth);
const shouldStart = params.startClient !== false;
if (sharedClientState?.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: sharedClientState,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
});
}
return sharedClientState.client;
}
if (sharedClientPromise) {
const pending = await sharedClientPromise;
if (pending.key === key) {
if (shouldStart) {
await ensureSharedClientStarted({
state: pending,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
});
}
return pending.client;
}
pending.client.stopClient();
sharedClientState = null;
sharedClientPromise = null;
}
sharedClientPromise = createSharedMatrixClient({
auth,
timeoutMs: params.timeoutMs,
});
try {
const created = await sharedClientPromise;
sharedClientState = created;
if (shouldStart) {
await ensureSharedClientStarted({
state: created,
timeoutMs: params.timeoutMs,
initialSyncLimit: auth.initialSyncLimit,
});
}
return created.client;
} finally {
sharedClientPromise = null;
}
}
export async function waitForMatrixSync(params: {
client: MatrixClient;
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise<void> {
const timeoutMs = Math.max(1000, params.timeoutMs ?? 15_000);
if (params.client.getSyncState() === SyncState.Syncing) return;
await new Promise<void>((resolve, reject) => {
let done = false;
let timer: NodeJS.Timeout | undefined;
const cleanup = () => {
if (done) return;
done = true;
params.client.removeListener(ClientEvent.Sync, onSync);
if (params.abortSignal) {
params.abortSignal.removeEventListener("abort", onAbort);
}
if (timer) {
clearTimeout(timer);
timer = undefined;
}
};
const onSync = (state: SyncState) => {
if (done) return;
if (state === SyncState.Prepared || state === SyncState.Syncing) {
cleanup();
resolve();
}
if (state === SyncState.Error) {
cleanup();
reject(new Error("Matrix sync failed"));
}
};
const onAbort = () => {
cleanup();
reject(new Error("Matrix sync aborted"));
};
params.client.on(ClientEvent.Sync, onSync);
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(() => {
cleanup();
reject(new Error("Matrix sync timed out"));
}, timeoutMs);
});
}