fix: polish matrix e2ee storage (#1298) (thanks @sibbl)
This commit is contained in:
@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
||||||
|
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
||||||
### Fixes
|
### Fixes
|
||||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||||
|
|||||||
@@ -118,7 +118,11 @@ Enable with `channels.matrix.encryption: true`:
|
|||||||
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
|
- If the crypto module cannot be loaded, E2EE is disabled and encrypted rooms will not decrypt;
|
||||||
Clawdbot logs a warning.
|
Clawdbot logs a warning.
|
||||||
|
|
||||||
Crypto state is stored in `~/.clawdbot/matrix/crypto/` (SQLite database).
|
Crypto state is stored per account + access token in
|
||||||
|
`~/.clawdbot/matrix/accounts/<account>/<homeserver>__<user>/<token-hash>/crypto/`
|
||||||
|
(SQLite database). Sync state lives alongside it in `bot-storage.json`.
|
||||||
|
If the access token (device) changes, a new store is created and the bot must be
|
||||||
|
re-verified for encrypted rooms.
|
||||||
|
|
||||||
**Device verification:**
|
**Device verification:**
|
||||||
When E2EE is enabled, the bot will request verification from your other sessions on startup.
|
When E2EE is enabled, the bot will request verification from your other sessions on startup.
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
mediaMaxMb: account.config.mediaMaxMb,
|
mediaMaxMb: account.config.mediaMaxMb,
|
||||||
initialSyncLimit: account.config.initialSyncLimit,
|
initialSyncLimit: account.config.initialSyncLimit,
|
||||||
replyToMode: account.config.replyToMode,
|
replyToMode: account.config.replyToMode,
|
||||||
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
|
|||||||
encryption: auth.encryption,
|
encryption: auth.encryption,
|
||||||
localTimeoutMs: opts.timeoutMs,
|
localTimeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
|
if (auth.encryption && client.crypto) {
|
||||||
|
try {
|
||||||
|
const joinedRooms = await client.getJoinedRooms();
|
||||||
|
await client.crypto.prepare(joinedRooms);
|
||||||
|
} catch {
|
||||||
|
// Ignore crypto prep failures for one-off actions.
|
||||||
|
}
|
||||||
|
}
|
||||||
await client.start();
|
await client.start();
|
||||||
return { client, stopOnDone: true };
|
return { client, stopOnDone: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ConsoleLogger,
|
ConsoleLogger,
|
||||||
LogService,
|
LogService,
|
||||||
@@ -90,6 +95,139 @@ function clean(value?: string): string {
|
|||||||
return value?.trim() ?? "";
|
return value?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNT_KEY = "default";
|
||||||
|
const STORAGE_META_FILENAME = "storage-meta.json";
|
||||||
|
|
||||||
|
type MatrixStoragePaths = {
|
||||||
|
rootDir: string;
|
||||||
|
storagePath: string;
|
||||||
|
cryptoPath: string;
|
||||||
|
metaPath: string;
|
||||||
|
accountKey: string;
|
||||||
|
tokenHash: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizePathSegment(value: string): string {
|
||||||
|
const cleaned = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "");
|
||||||
|
return cleaned || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHomeserverKey(homeserver: string): string {
|
||||||
|
try {
|
||||||
|
const url = new URL(homeserver);
|
||||||
|
if (url.host) return sanitizePathSegment(url.host);
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return sanitizePathSegment(homeserver);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashAccessToken(accessToken: string): string {
|
||||||
|
return crypto.createHash("sha256").update(accessToken).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
|
||||||
|
storagePath: string;
|
||||||
|
cryptoPath: string;
|
||||||
|
} {
|
||||||
|
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
|
return {
|
||||||
|
storagePath: path.join(stateDir, "matrix", "bot-storage.json"),
|
||||||
|
cryptoPath: path.join(stateDir, "matrix", "crypto"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMatrixStoragePaths(params: {
|
||||||
|
homeserver: string;
|
||||||
|
userId: string;
|
||||||
|
accessToken: string;
|
||||||
|
accountId?: string | null;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): MatrixStoragePaths {
|
||||||
|
const env = params.env ?? process.env;
|
||||||
|
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||||
|
const accountKey = sanitizePathSegment(params.accountId ?? DEFAULT_ACCOUNT_KEY);
|
||||||
|
const userKey = sanitizePathSegment(params.userId);
|
||||||
|
const serverKey = resolveHomeserverKey(params.homeserver);
|
||||||
|
const tokenHash = hashAccessToken(params.accessToken);
|
||||||
|
const rootDir = path.join(
|
||||||
|
stateDir,
|
||||||
|
"matrix",
|
||||||
|
"accounts",
|
||||||
|
accountKey,
|
||||||
|
`${serverKey}__${userKey}`,
|
||||||
|
tokenHash,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
rootDir,
|
||||||
|
storagePath: path.join(rootDir, "bot-storage.json"),
|
||||||
|
cryptoPath: path.join(rootDir, "crypto"),
|
||||||
|
metaPath: path.join(rootDir, STORAGE_META_FILENAME),
|
||||||
|
accountKey,
|
||||||
|
tokenHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeMigrateLegacyStorage(params: {
|
||||||
|
storagePaths: MatrixStoragePaths;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): void {
|
||||||
|
const legacy = resolveLegacyStoragePaths(params.env);
|
||||||
|
const hasLegacyStorage = fs.existsSync(legacy.storagePath);
|
||||||
|
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
|
||||||
|
const hasNewStorage =
|
||||||
|
fs.existsSync(params.storagePaths.storagePath) ||
|
||||||
|
fs.existsSync(params.storagePaths.cryptoPath);
|
||||||
|
|
||||||
|
if (!hasLegacyStorage && !hasLegacyCrypto) return;
|
||||||
|
if (hasNewStorage) return;
|
||||||
|
|
||||||
|
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||||
|
if (hasLegacyStorage) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(legacy.storagePath, params.storagePaths.storagePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore migration failures; new store will be created.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasLegacyCrypto) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(legacy.cryptoPath, params.storagePaths.cryptoPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore migration failures; new store will be created.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStorageMeta(params: {
|
||||||
|
storagePaths: MatrixStoragePaths;
|
||||||
|
homeserver: string;
|
||||||
|
userId: string;
|
||||||
|
accountId?: string | null;
|
||||||
|
}): void {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
homeserver: params.homeserver,
|
||||||
|
userId: params.userId,
|
||||||
|
accountId: params.accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||||
|
accessTokenHash: params.storagePaths.tokenHash,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
params.storagePaths.metaPath,
|
||||||
|
JSON.stringify(payload, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore meta write failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
function sanitizeUserIdList(input: unknown, label: string): string[] {
|
||||||
if (input == null) return [];
|
if (input == null) return [];
|
||||||
if (!Array.isArray(input)) {
|
if (!Array.isArray(input)) {
|
||||||
@@ -139,22 +277,6 @@ export function resolveMatrixConfig(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveCryptoStorePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
||||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, () =>
|
|
||||||
require("node:os").homedir(),
|
|
||||||
);
|
|
||||||
const path = require("node:path");
|
|
||||||
return path.join(stateDir, "matrix", "crypto");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveStoragePath(env: NodeJS.ProcessEnv = process.env): string {
|
|
||||||
const stateDir = getMatrixRuntime().state.resolveStateDir(env, () =>
|
|
||||||
require("node:os").homedir(),
|
|
||||||
);
|
|
||||||
const path = require("node:path");
|
|
||||||
return path.join(stateDir, "matrix", "bot-storage.json");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveMatrixAuth(params?: {
|
export async function resolveMatrixAuth(params?: {
|
||||||
cfg?: CoreConfig;
|
cfg?: CoreConfig;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
@@ -288,31 +410,46 @@ export async function createMatrixClient(params: {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
encryption?: boolean;
|
encryption?: boolean;
|
||||||
localTimeoutMs?: number;
|
localTimeoutMs?: number;
|
||||||
|
accountId?: string | null;
|
||||||
}): Promise<MatrixClient> {
|
}): Promise<MatrixClient> {
|
||||||
ensureMatrixSdkLoggingConfigured();
|
ensureMatrixSdkLoggingConfigured();
|
||||||
const env = process.env;
|
const env = process.env;
|
||||||
|
|
||||||
// Create storage provider
|
// Create storage provider
|
||||||
const storagePath = resolveStoragePath(env);
|
const storagePaths = resolveMatrixStoragePaths({
|
||||||
const fs = await import("node:fs");
|
homeserver: params.homeserver,
|
||||||
const path = await import("node:path");
|
userId: params.userId,
|
||||||
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
accessToken: params.accessToken,
|
||||||
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePath);
|
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
|
// Create crypto storage if encryption is enabled
|
||||||
let cryptoStorage: ICryptoStorageProvider | undefined;
|
let cryptoStorage: ICryptoStorageProvider | undefined;
|
||||||
if (params.encryption) {
|
if (params.encryption) {
|
||||||
const cryptoPath = resolveCryptoStorePath(env);
|
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
||||||
fs.mkdirSync(cryptoPath, { recursive: true });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
const { StoreType } = await import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||||
cryptoStorage = new RustSdkCryptoStorageProvider(cryptoPath, StoreType.Sqlite);
|
cryptoStorage = new RustSdkCryptoStorageProvider(
|
||||||
|
storagePaths.cryptoPath,
|
||||||
|
StoreType.Sqlite,
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
|
LogService.warn("MatrixClientLite", "Failed to initialize crypto storage, E2EE disabled:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
writeStorageMeta({
|
||||||
|
storagePaths,
|
||||||
|
homeserver: params.homeserver,
|
||||||
|
userId: params.userId,
|
||||||
|
accountId: params.accountId,
|
||||||
|
});
|
||||||
|
|
||||||
const client = new MatrixClient(
|
const client = new MatrixClient(
|
||||||
params.homeserver,
|
params.homeserver,
|
||||||
params.accessToken,
|
params.accessToken,
|
||||||
@@ -357,13 +494,20 @@ export async function createMatrixClient(params: {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSharedClientKey(auth: MatrixAuth): string {
|
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||||
return [auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain"].join("|");
|
return [
|
||||||
|
auth.homeserver,
|
||||||
|
auth.userId,
|
||||||
|
auth.accessToken,
|
||||||
|
auth.encryption ? "e2ee" : "plain",
|
||||||
|
accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||||
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSharedMatrixClient(params: {
|
async function createSharedMatrixClient(params: {
|
||||||
auth: MatrixAuth;
|
auth: MatrixAuth;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
accountId?: string | null;
|
||||||
}): Promise<SharedMatrixClientState> {
|
}): Promise<SharedMatrixClientState> {
|
||||||
const client = await createMatrixClient({
|
const client = await createMatrixClient({
|
||||||
homeserver: params.auth.homeserver,
|
homeserver: params.auth.homeserver,
|
||||||
@@ -371,10 +515,11 @@ async function createSharedMatrixClient(params: {
|
|||||||
accessToken: params.auth.accessToken,
|
accessToken: params.auth.accessToken,
|
||||||
encryption: params.auth.encryption,
|
encryption: params.auth.encryption,
|
||||||
localTimeoutMs: params.timeoutMs,
|
localTimeoutMs: params.timeoutMs,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
client,
|
client,
|
||||||
key: buildSharedClientKey(params.auth),
|
key: buildSharedClientKey(params.auth, params.accountId),
|
||||||
started: false,
|
started: false,
|
||||||
cryptoReady: false,
|
cryptoReady: false,
|
||||||
};
|
};
|
||||||
@@ -424,10 +569,11 @@ export async function resolveSharedMatrixClient(
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
auth?: MatrixAuth;
|
auth?: MatrixAuth;
|
||||||
startClient?: boolean;
|
startClient?: boolean;
|
||||||
|
accountId?: string | null;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<MatrixClient> {
|
): Promise<MatrixClient> {
|
||||||
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
const auth = params.auth ?? (await resolveMatrixAuth({ cfg: params.cfg, env: params.env }));
|
||||||
const key = buildSharedClientKey(auth);
|
const key = buildSharedClientKey(auth, params.accountId);
|
||||||
const shouldStart = params.startClient !== false;
|
const shouldStart = params.startClient !== false;
|
||||||
|
|
||||||
if (sharedClientState?.key === key) {
|
if (sharedClientState?.key === key) {
|
||||||
@@ -463,6 +609,7 @@ export async function resolveSharedMatrixClient(
|
|||||||
sharedClientPromise = createSharedMatrixClient({
|
sharedClientPromise = createSharedMatrixClient({
|
||||||
auth,
|
auth,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
|
accountId: params.accountId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const created = await sharedClientPromise;
|
const created = await sharedClientPromise;
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
|
|
||||||
// Get room alias if available
|
// Get room alias if available
|
||||||
let alias: string | undefined;
|
let alias: string | undefined;
|
||||||
|
let altAliases: string[] = [];
|
||||||
try {
|
try {
|
||||||
const aliasState = await client.getRoomStateEvent(roomId, "m.room.canonical_alias", "").catch(() => null);
|
const aliasState = await client
|
||||||
|
.getRoomStateEvent(roomId, "m.room.canonical_alias", "")
|
||||||
|
.catch(() => null);
|
||||||
alias = aliasState?.alias;
|
alias = aliasState?.alias;
|
||||||
|
altAliases = Array.isArray(aliasState?.alt_aliases) ? aliasState.alt_aliases : [];
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
@@ -46,7 +50,8 @@ export function registerMatrixAutoJoin(params: {
|
|||||||
const allowed =
|
const allowed =
|
||||||
autoJoinAllowlist.includes("*") ||
|
autoJoinAllowlist.includes("*") ||
|
||||||
autoJoinAllowlist.includes(roomId) ||
|
autoJoinAllowlist.includes(roomId) ||
|
||||||
(alias ? autoJoinAllowlist.includes(alias) : false);
|
(alias ? autoJoinAllowlist.includes(alias) : false) ||
|
||||||
|
altAliases.some((value) => autoJoinAllowlist.includes(value));
|
||||||
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
EncryptedFile,
|
||||||
LocationMessageEventContent,
|
LocationMessageEventContent,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MessageEventContent,
|
MessageEventContent,
|
||||||
@@ -77,6 +78,7 @@ type MatrixRawEvent = {
|
|||||||
|
|
||||||
type RoomMessageEventContent = MessageEventContent & {
|
type RoomMessageEventContent = MessageEventContent & {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
file?: EncryptedFile;
|
||||||
info?: {
|
info?: {
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
};
|
};
|
||||||
@@ -168,6 +170,7 @@ export type MonitorMatrixOpts = {
|
|||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
initialSyncLimit?: number;
|
initialSyncLimit?: number;
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
|
accountId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MEDIA_MAX_MB = 20;
|
const DEFAULT_MEDIA_MAX_MB = 20;
|
||||||
@@ -316,6 +319,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
cfg,
|
cfg,
|
||||||
auth: authWithLimit,
|
auth: authWithLimit,
|
||||||
startClient: false,
|
startClient: false,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
setActiveMatrixClient(client);
|
setActiveMatrixClient(client);
|
||||||
|
|
||||||
@@ -592,7 +596,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
} | null = null;
|
} | null = null;
|
||||||
const contentUrl =
|
const contentUrl =
|
||||||
"url" in content && typeof content.url === "string" ? content.url : undefined;
|
"url" in content && typeof content.url === "string" ? content.url : undefined;
|
||||||
if (!rawBody && !contentUrl) {
|
const contentFile =
|
||||||
|
"file" in content && content.file && typeof content.file === "object"
|
||||||
|
? (content.file as EncryptedFile)
|
||||||
|
: undefined;
|
||||||
|
const mediaUrl = contentUrl ?? contentFile?.url;
|
||||||
|
if (!rawBody && !mediaUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,13 +609,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
"info" in content && content.info && "mimetype" in content.info
|
"info" in content && content.info && "mimetype" in content.info
|
||||||
? (content.info as { mimetype?: string }).mimetype
|
? (content.info as { mimetype?: string }).mimetype
|
||||||
: undefined;
|
: undefined;
|
||||||
if (contentUrl?.startsWith("mxc://")) {
|
if (mediaUrl?.startsWith("mxc://")) {
|
||||||
try {
|
try {
|
||||||
media = await downloadMatrixMedia({
|
media = await downloadMatrixMedia({
|
||||||
client,
|
client,
|
||||||
mxcUrl: contentUrl,
|
mxcUrl: mediaUrl,
|
||||||
contentType,
|
contentType,
|
||||||
maxBytes: mediaMaxBytes,
|
maxBytes: mediaMaxBytes,
|
||||||
|
file: contentFile,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||||
@@ -937,6 +947,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
await resolveSharedMatrixClient({
|
await resolveSharedMatrixClient({
|
||||||
cfg,
|
cfg,
|
||||||
auth: authWithLimit,
|
auth: authWithLimit,
|
||||||
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
logVerboseMessage("matrix: client started");
|
logVerboseMessage("matrix: client started");
|
||||||
|
|
||||||
|
|||||||
67
extensions/matrix/src/matrix/monitor/media.test.ts
Normal file
67
extensions/matrix/src/matrix/monitor/media.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
|
import { setMatrixRuntime } from "../../runtime.js";
|
||||||
|
import { downloadMatrixMedia } from "./media.js";
|
||||||
|
|
||||||
|
describe("downloadMatrixMedia", () => {
|
||||||
|
const saveMediaBuffer = vi.fn().mockResolvedValue({
|
||||||
|
path: "/tmp/media",
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtimeStub = {
|
||||||
|
channel: {
|
||||||
|
media: {
|
||||||
|
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginRuntime;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setMatrixRuntime(runtimeStub);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decrypts encrypted media when file payloads are present", async () => {
|
||||||
|
const decryptMedia = vi.fn().mockResolvedValue(Buffer.from("decrypted"));
|
||||||
|
const downloadContent = vi.fn().mockResolvedValue(Buffer.from("encrypted"));
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
downloadContent,
|
||||||
|
crypto: { decryptMedia },
|
||||||
|
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
|
||||||
|
} as unknown as import("matrix-bot-sdk").MatrixClient;
|
||||||
|
|
||||||
|
const file = {
|
||||||
|
url: "mxc://example/file",
|
||||||
|
key: {
|
||||||
|
kty: "oct",
|
||||||
|
key_ops: ["encrypt", "decrypt"],
|
||||||
|
alg: "A256CTR",
|
||||||
|
k: "secret",
|
||||||
|
ext: true,
|
||||||
|
},
|
||||||
|
iv: "iv",
|
||||||
|
hashes: { sha256: "hash" },
|
||||||
|
v: "v2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await downloadMatrixMedia({
|
||||||
|
client,
|
||||||
|
mxcUrl: "mxc://example/file",
|
||||||
|
contentType: "image/png",
|
||||||
|
maxBytes: 1024,
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(decryptMedia).toHaveBeenCalled();
|
||||||
|
expect(saveMediaBuffer).toHaveBeenCalledWith(
|
||||||
|
Buffer.from("decrypted"),
|
||||||
|
"image/png",
|
||||||
|
"inbound",
|
||||||
|
1024,
|
||||||
|
);
|
||||||
|
expect(result?.path).toBe("/tmp/media");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -131,3 +131,38 @@ describe("sendMessageMatrix media", () => {
|
|||||||
expect(content.file?.url).toBe("mxc://example/file");
|
expect(content.file?.url).toBe("mxc://example/file");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sendMessageMatrix threads", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
setMatrixRuntime(runtimeStub);
|
||||||
|
({ sendMessageMatrix } = await import("./send.js"));
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setMatrixRuntime(runtimeStub);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes thread relation metadata when threadId is set", async () => {
|
||||||
|
const { client, sendMessage } = makeClient();
|
||||||
|
|
||||||
|
await sendMessageMatrix("room:!room:example", "hello thread", {
|
||||||
|
client,
|
||||||
|
threadId: "$thread",
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = sendMessage.mock.calls[0]?.[1] as {
|
||||||
|
"m.relates_to"?: {
|
||||||
|
rel_type?: string;
|
||||||
|
event_id?: string;
|
||||||
|
"m.in_reply_to"?: { event_id?: string };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(content["m.relates_to"]).toMatchObject({
|
||||||
|
rel_type: "m.thread",
|
||||||
|
event_id: "$thread",
|
||||||
|
"m.in_reply_to": { event_id: "$thread" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,8 +56,17 @@ type MatrixReplyRelation = {
|
|||||||
"m.in_reply_to": { event_id: string };
|
"m.in_reply_to": { event_id: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MatrixThreadRelation = {
|
||||||
|
rel_type: typeof RelationType.Thread;
|
||||||
|
event_id: string;
|
||||||
|
is_falling_back?: boolean;
|
||||||
|
"m.in_reply_to"?: { event_id: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatrixRelation = MatrixReplyRelation | MatrixThreadRelation;
|
||||||
|
|
||||||
type MatrixReplyMeta = {
|
type MatrixReplyMeta = {
|
||||||
"m.relates_to"?: MatrixReplyRelation;
|
"m.relates_to"?: MatrixRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MatrixMediaInfo = FileWithThumbnailInfo | DimensionalFileInfo | TimedFileInfo | VideoFileInfo;
|
type MatrixMediaInfo = FileWithThumbnailInfo | DimensionalFileInfo | TimedFileInfo | VideoFileInfo;
|
||||||
@@ -234,7 +243,7 @@ function buildMediaContent(params: {
|
|||||||
filename?: string;
|
filename?: string;
|
||||||
mimetype?: string;
|
mimetype?: string;
|
||||||
size: number;
|
size: number;
|
||||||
relation?: MatrixReplyRelation;
|
relation?: MatrixRelation;
|
||||||
isVoice?: boolean;
|
isVoice?: boolean;
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
imageInfo?: DimensionalFileInfo;
|
imageInfo?: DimensionalFileInfo;
|
||||||
@@ -275,7 +284,7 @@ function buildMediaContent(params: {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTextContent(body: string, relation?: MatrixReplyRelation): MatrixTextContent {
|
function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
|
||||||
const content: MatrixTextContent = relation
|
const content: MatrixTextContent = relation
|
||||||
? {
|
? {
|
||||||
msgtype: MsgType.Text,
|
msgtype: MsgType.Text,
|
||||||
@@ -303,6 +312,16 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
|
|||||||
return { "m.in_reply_to": { event_id: trimmed } };
|
return { "m.in_reply_to": { event_id: trimmed } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildThreadRelation(threadId: string, replyToId?: string): MatrixThreadRelation {
|
||||||
|
const trimmed = threadId.trim();
|
||||||
|
return {
|
||||||
|
rel_type: RelationType.Thread,
|
||||||
|
event_id: trimmed,
|
||||||
|
is_falling_back: true,
|
||||||
|
"m.in_reply_to": { event_id: (replyToId?.trim() || trimmed) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMatrixMsgType(
|
function resolveMatrixMsgType(
|
||||||
contentType?: string,
|
contentType?: string,
|
||||||
fileName?: string,
|
fileName?: string,
|
||||||
@@ -468,6 +487,14 @@ async function resolveMatrixClient(opts: {
|
|||||||
encryption: auth.encryption,
|
encryption: auth.encryption,
|
||||||
localTimeoutMs: opts.timeoutMs,
|
localTimeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
|
if (auth.encryption && client.crypto) {
|
||||||
|
try {
|
||||||
|
const joinedRooms = await client.getJoinedRooms();
|
||||||
|
await client.crypto.prepare(joinedRooms);
|
||||||
|
} catch {
|
||||||
|
// Ignore crypto prep failures for one-off sends; normal sync will retry.
|
||||||
|
}
|
||||||
|
}
|
||||||
// matrix-bot-sdk uses start() instead of startClient()
|
// matrix-bot-sdk uses start() instead of startClient()
|
||||||
await client.start();
|
await client.start();
|
||||||
return { client, stopOnDone: true };
|
return { client, stopOnDone: true };
|
||||||
@@ -493,7 +520,9 @@ export async function sendMessageMatrix(
|
|||||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||||
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||||
const threadId = normalizeThreadId(opts.threadId);
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
const relation = threadId
|
||||||
|
? buildThreadRelation(threadId, opts.replyToId)
|
||||||
|
: buildReplyRelation(opts.replyToId);
|
||||||
const sendContent = async (content: MatrixOutboundContent) => {
|
const sendContent = async (content: MatrixOutboundContent) => {
|
||||||
// matrix-bot-sdk uses sendMessage differently
|
// matrix-bot-sdk uses sendMessage differently
|
||||||
const eventId = await client.sendMessage(roomId, content);
|
const eventId = await client.sendMessage(roomId, content);
|
||||||
@@ -541,10 +570,11 @@ export async function sendMessageMatrix(
|
|||||||
const eventId = await sendContent(content);
|
const eventId = await sendContent(content);
|
||||||
lastMessageId = eventId ?? lastMessageId;
|
lastMessageId = eventId ?? lastMessageId;
|
||||||
const textChunks = useVoice ? chunks : rest;
|
const textChunks = useVoice ? chunks : rest;
|
||||||
|
const followupRelation = threadId ? relation : undefined;
|
||||||
for (const chunk of textChunks) {
|
for (const chunk of textChunks) {
|
||||||
const text = chunk.trim();
|
const text = chunk.trim();
|
||||||
if (!text) continue;
|
if (!text) continue;
|
||||||
const followup = buildTextContent(text);
|
const followup = buildTextContent(text, followupRelation);
|
||||||
const followupEventId = await sendContent(followup);
|
const followupEventId = await sendContent(followup);
|
||||||
lastMessageId = followupEventId ?? lastMessageId;
|
lastMessageId = followupEventId ?? lastMessageId;
|
||||||
}
|
}
|
||||||
@@ -588,8 +618,12 @@ export async function sendPollMatrix(
|
|||||||
try {
|
try {
|
||||||
const roomId = await resolveMatrixRoomId(client, to);
|
const roomId = await resolveMatrixRoomId(client, to);
|
||||||
const pollContent = buildPollStartContent(poll);
|
const pollContent = buildPollStartContent(poll);
|
||||||
|
const threadId = normalizeThreadId(opts.threadId);
|
||||||
|
const pollPayload = threadId
|
||||||
|
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
|
||||||
|
: pollContent;
|
||||||
// matrix-bot-sdk sendEvent returns eventId string directly
|
// matrix-bot-sdk sendEvent returns eventId string directly
|
||||||
const eventId = await client.sendEvent(roomId, M_POLL_START, pollContent);
|
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
eventId: eventId ?? "unknown",
|
eventId: eventId ?? "unknown",
|
||||||
|
|||||||
937
pnpm-lock.yaml
generated
937
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
function resolvePowerShellPath(): string {
|
function resolvePowerShellPath(): string {
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ export function enqueueCommand<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getQueueSize(lane: string = CommandLane.Main) {
|
export function getQueueSize(lane: string = CommandLane.Main) {
|
||||||
const state = lanes.get(lane);
|
const resolved = lane.trim() || CommandLane.Main;
|
||||||
|
const state = lanes.get(resolved);
|
||||||
if (!state) return 0;
|
if (!state) return 0;
|
||||||
return state.queue.length + state.active;
|
return state.queue.length + state.active;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user