fix: polish matrix e2ee storage (#1298) (thanks @sibbl)

This commit is contained in:
Peter Steinberger
2026-01-20 11:51:08 +00:00
parent 9b71382efb
commit d91f0ceeb3
13 changed files with 1196 additions and 140 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,
}); });
}, },
}, },

View File

@@ -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 };
} }

View File

@@ -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;

View File

@@ -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}`);

View File

@@ -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");

View 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");
});
});

View File

@@ -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" },
});
});
});

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View File

@@ -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;
} }