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: 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.
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
||||
### Fixes
|
||||
- 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.
|
||||
|
||||
@@ -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;
|
||||
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:**
|
||||
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,
|
||||
initialSyncLimit: account.config.initialSyncLimit,
|
||||
replyToMode: account.config.replyToMode,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -129,6 +129,14 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
|
||||
encryption: auth.encryption,
|
||||
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();
|
||||
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 {
|
||||
ConsoleLogger,
|
||||
LogService,
|
||||
@@ -90,6 +95,139 @@ function clean(value?: string): string {
|
||||
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[] {
|
||||
if (input == null) return [];
|
||||
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?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -288,31 +410,46 @@ export async function createMatrixClient(params: {
|
||||
accessToken: string;
|
||||
encryption?: boolean;
|
||||
localTimeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<MatrixClient> {
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
const env = process.env;
|
||||
|
||||
// Create storage provider
|
||||
const storagePath = resolveStoragePath(env);
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
||||
const storage: IStorageProvider = new SimpleFsStorageProvider(storagePath);
|
||||
const storagePaths = resolveMatrixStoragePaths({
|
||||
homeserver: params.homeserver,
|
||||
userId: params.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) {
|
||||
const cryptoPath = resolveCryptoStorePath(env);
|
||||
fs.mkdirSync(cryptoPath, { recursive: true });
|
||||
fs.mkdirSync(storagePaths.cryptoPath, { recursive: true });
|
||||
|
||||
try {
|
||||
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) {
|
||||
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(
|
||||
params.homeserver,
|
||||
params.accessToken,
|
||||
@@ -357,13 +494,20 @@ export async function createMatrixClient(params: {
|
||||
return client;
|
||||
}
|
||||
|
||||
function buildSharedClientKey(auth: MatrixAuth): string {
|
||||
return [auth.homeserver, auth.userId, auth.accessToken, auth.encryption ? "e2ee" : "plain"].join("|");
|
||||
function buildSharedClientKey(auth: MatrixAuth, accountId?: string | null): string {
|
||||
return [
|
||||
auth.homeserver,
|
||||
auth.userId,
|
||||
auth.accessToken,
|
||||
auth.encryption ? "e2ee" : "plain",
|
||||
accountId ?? DEFAULT_ACCOUNT_KEY,
|
||||
].join("|");
|
||||
}
|
||||
|
||||
async function createSharedMatrixClient(params: {
|
||||
auth: MatrixAuth;
|
||||
timeoutMs?: number;
|
||||
accountId?: string | null;
|
||||
}): Promise<SharedMatrixClientState> {
|
||||
const client = await createMatrixClient({
|
||||
homeserver: params.auth.homeserver,
|
||||
@@ -371,10 +515,11 @@ async function createSharedMatrixClient(params: {
|
||||
accessToken: params.auth.accessToken,
|
||||
encryption: params.auth.encryption,
|
||||
localTimeoutMs: params.timeoutMs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return {
|
||||
client,
|
||||
key: buildSharedClientKey(params.auth),
|
||||
key: buildSharedClientKey(params.auth, params.accountId),
|
||||
started: false,
|
||||
cryptoReady: false,
|
||||
};
|
||||
@@ -424,10 +569,11 @@ export async function resolveSharedMatrixClient(
|
||||
timeoutMs?: number;
|
||||
auth?: MatrixAuth;
|
||||
startClient?: boolean;
|
||||
accountId?: string | null;
|
||||
} = {},
|
||||
): Promise<MatrixClient> {
|
||||
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;
|
||||
|
||||
if (sharedClientState?.key === key) {
|
||||
@@ -463,6 +609,7 @@ export async function resolveSharedMatrixClient(
|
||||
sharedClientPromise = createSharedMatrixClient({
|
||||
auth,
|
||||
timeoutMs: params.timeoutMs,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
try {
|
||||
const created = await sharedClientPromise;
|
||||
|
||||
@@ -36,9 +36,13 @@ export function registerMatrixAutoJoin(params: {
|
||||
|
||||
// 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);
|
||||
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
|
||||
}
|
||||
@@ -46,7 +50,8 @@ export function registerMatrixAutoJoin(params: {
|
||||
const allowed =
|
||||
autoJoinAllowlist.includes("*") ||
|
||||
autoJoinAllowlist.includes(roomId) ||
|
||||
(alias ? autoJoinAllowlist.includes(alias) : false);
|
||||
(alias ? autoJoinAllowlist.includes(alias) : false) ||
|
||||
altAliases.some((value) => autoJoinAllowlist.includes(value));
|
||||
|
||||
if (!allowed) {
|
||||
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
EncryptedFile,
|
||||
LocationMessageEventContent,
|
||||
MatrixClient,
|
||||
MessageEventContent,
|
||||
@@ -77,6 +78,7 @@ type MatrixRawEvent = {
|
||||
|
||||
type RoomMessageEventContent = MessageEventContent & {
|
||||
url?: string;
|
||||
file?: EncryptedFile;
|
||||
info?: {
|
||||
mimetype?: string;
|
||||
};
|
||||
@@ -168,6 +170,7 @@ export type MonitorMatrixOpts = {
|
||||
mediaMaxMb?: number;
|
||||
initialSyncLimit?: number;
|
||||
replyToMode?: ReplyToMode;
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_MEDIA_MAX_MB = 20;
|
||||
@@ -316,6 +319,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
cfg,
|
||||
auth: authWithLimit,
|
||||
startClient: false,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
setActiveMatrixClient(client);
|
||||
|
||||
@@ -592,7 +596,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
} | null = null;
|
||||
const contentUrl =
|
||||
"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;
|
||||
}
|
||||
|
||||
@@ -600,13 +609,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
"info" in content && content.info && "mimetype" in content.info
|
||||
? (content.info as { mimetype?: string }).mimetype
|
||||
: undefined;
|
||||
if (contentUrl?.startsWith("mxc://")) {
|
||||
if (mediaUrl?.startsWith("mxc://")) {
|
||||
try {
|
||||
media = await downloadMatrixMedia({
|
||||
client,
|
||||
mxcUrl: contentUrl,
|
||||
mxcUrl: mediaUrl,
|
||||
contentType,
|
||||
maxBytes: mediaMaxBytes,
|
||||
file: contentFile,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||
@@ -937,6 +947,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
await resolveSharedMatrixClient({
|
||||
cfg,
|
||||
auth: authWithLimit,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
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 = {
|
||||
"m.relates_to"?: MatrixReplyRelation;
|
||||
"m.relates_to"?: MatrixRelation;
|
||||
};
|
||||
|
||||
type MatrixMediaInfo = FileWithThumbnailInfo | DimensionalFileInfo | TimedFileInfo | VideoFileInfo;
|
||||
@@ -234,7 +243,7 @@ function buildMediaContent(params: {
|
||||
filename?: string;
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
relation?: MatrixReplyRelation;
|
||||
relation?: MatrixRelation;
|
||||
isVoice?: boolean;
|
||||
durationMs?: number;
|
||||
imageInfo?: DimensionalFileInfo;
|
||||
@@ -275,7 +284,7 @@ function buildMediaContent(params: {
|
||||
return base;
|
||||
}
|
||||
|
||||
function buildTextContent(body: string, relation?: MatrixReplyRelation): MatrixTextContent {
|
||||
function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
|
||||
const content: MatrixTextContent = relation
|
||||
? {
|
||||
msgtype: MsgType.Text,
|
||||
@@ -303,6 +312,16 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
|
||||
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(
|
||||
contentType?: string,
|
||||
fileName?: string,
|
||||
@@ -468,6 +487,14 @@ async function resolveMatrixClient(opts: {
|
||||
encryption: auth.encryption,
|
||||
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()
|
||||
await client.start();
|
||||
return { client, stopOnDone: true };
|
||||
@@ -493,7 +520,9 @@ export async function sendMessageMatrix(
|
||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||
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) => {
|
||||
// matrix-bot-sdk uses sendMessage differently
|
||||
const eventId = await client.sendMessage(roomId, content);
|
||||
@@ -541,10 +570,11 @@ export async function sendMessageMatrix(
|
||||
const eventId = await sendContent(content);
|
||||
lastMessageId = eventId ?? lastMessageId;
|
||||
const textChunks = useVoice ? chunks : rest;
|
||||
const followupRelation = threadId ? relation : undefined;
|
||||
for (const chunk of textChunks) {
|
||||
const text = chunk.trim();
|
||||
if (!text) continue;
|
||||
const followup = buildTextContent(text);
|
||||
const followup = buildTextContent(text, followupRelation);
|
||||
const followupEventId = await sendContent(followup);
|
||||
lastMessageId = followupEventId ?? lastMessageId;
|
||||
}
|
||||
@@ -588,8 +618,12 @@ export async function sendPollMatrix(
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
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
|
||||
const eventId = await client.sendEvent(roomId, M_POLL_START, pollContent);
|
||||
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
|
||||
|
||||
return {
|
||||
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 path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
function resolvePowerShellPath(): string {
|
||||
|
||||
@@ -112,7 +112,8 @@ export function enqueueCommand<T>(
|
||||
}
|
||||
|
||||
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;
|
||||
return state.queue.length + state.active;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user