@openclaw/matrix 2026.2.12 → 2026.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/channel.directory.test.ts +81 -1
- package/src/channel.ts +59 -18
- package/src/directory-live.test.ts +54 -0
- package/src/directory-live.ts +4 -2
- package/src/group-mentions.ts +5 -2
- package/src/matrix/accounts.ts +80 -8
- package/src/matrix/actions/client.ts +7 -1
- package/src/matrix/actions/types.ts +1 -0
- package/src/matrix/active-client.ts +26 -5
- package/src/matrix/client/config.ts +76 -17
- package/src/matrix/client/shared.ts +67 -38
- package/src/matrix/client.ts +11 -2
- package/src/matrix/credentials.ts +31 -11
- package/src/matrix/monitor/handler.ts +3 -0
- package/src/matrix/monitor/index.ts +21 -15
- package/src/matrix/send/client.ts +52 -4
- package/src/matrix/send/formatting.ts +10 -6
- package/src/matrix/send/media.ts +2 -1
- package/src/matrix/send.test.ts +77 -12
- package/src/matrix/send.ts +3 -1
- package/src/outbound.ts +6 -3
- package/src/types.ts +5 -0
|
@@ -77,13 +77,17 @@ export function resolveMatrixVoiceDecision(opts: {
|
|
|
77
77
|
if (!opts.wantsVoice) {
|
|
78
78
|
return { useVoice: false };
|
|
79
79
|
}
|
|
80
|
-
if (
|
|
81
|
-
getCore().media.isVoiceCompatibleAudio({
|
|
82
|
-
contentType: opts.contentType,
|
|
83
|
-
fileName: opts.fileName,
|
|
84
|
-
})
|
|
85
|
-
) {
|
|
80
|
+
if (isMatrixVoiceCompatibleAudio(opts)) {
|
|
86
81
|
return { useVoice: true };
|
|
87
82
|
}
|
|
88
83
|
return { useVoice: false };
|
|
89
84
|
}
|
|
85
|
+
|
|
86
|
+
function isMatrixVoiceCompatibleAudio(opts: { contentType?: string; fileName?: string }): boolean {
|
|
87
|
+
// Matrix currently shares the core voice compatibility policy.
|
|
88
|
+
// Keep this wrapper as the seam if Matrix policy diverges later.
|
|
89
|
+
return getCore().media.isVoiceCompatibleAudio({
|
|
90
|
+
contentType: opts.contentType,
|
|
91
|
+
fileName: opts.fileName,
|
|
92
|
+
});
|
|
93
|
+
}
|
package/src/matrix/send/media.ts
CHANGED
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
TimedFileInfo,
|
|
7
7
|
VideoFileInfo,
|
|
8
8
|
} from "@vector-im/matrix-bot-sdk";
|
|
9
|
-
import { parseBuffer, type IFileInfo } from "music-metadata";
|
|
10
9
|
import { getMatrixRuntime } from "../../runtime.js";
|
|
11
10
|
import { applyMatrixFormatting } from "./formatting.js";
|
|
12
11
|
import {
|
|
@@ -18,6 +17,7 @@ import {
|
|
|
18
17
|
} from "./types.js";
|
|
19
18
|
|
|
20
19
|
const getCore = () => getMatrixRuntime();
|
|
20
|
+
type IFileInfo = import("music-metadata").IFileInfo;
|
|
21
21
|
|
|
22
22
|
export function buildMatrixMediaInfo(params: {
|
|
23
23
|
size: number;
|
|
@@ -164,6 +164,7 @@ export async function resolveMediaDurationMs(params: {
|
|
|
164
164
|
return undefined;
|
|
165
165
|
}
|
|
166
166
|
try {
|
|
167
|
+
const { parseBuffer } = await import("music-metadata");
|
|
167
168
|
const fileInfo: IFileInfo | string | undefined =
|
|
168
169
|
params.contentType || params.fileName
|
|
169
170
|
? {
|
package/src/matrix/send.test.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
|
2
2
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { setMatrixRuntime } from "../runtime.js";
|
|
4
4
|
|
|
5
|
+
vi.mock("music-metadata", () => ({
|
|
6
|
+
// `resolveMediaDurationMs` lazily imports `music-metadata`; in tests we don't
|
|
7
|
+
// need real duration parsing and the real module is expensive to load.
|
|
8
|
+
parseBuffer: vi.fn().mockResolvedValue({ format: {} }),
|
|
9
|
+
}));
|
|
10
|
+
|
|
5
11
|
vi.mock("@vector-im/matrix-bot-sdk", () => ({
|
|
6
12
|
ConsoleLogger: class {
|
|
7
13
|
trace = vi.fn();
|
|
@@ -24,6 +30,8 @@ const loadWebMediaMock = vi.fn().mockResolvedValue({
|
|
|
24
30
|
contentType: "image/png",
|
|
25
31
|
kind: "image",
|
|
26
32
|
});
|
|
33
|
+
const mediaKindFromMimeMock = vi.fn(() => "image");
|
|
34
|
+
const isVoiceCompatibleAudioMock = vi.fn(() => false);
|
|
27
35
|
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
|
28
36
|
const resizeToJpegMock = vi.fn();
|
|
29
37
|
|
|
@@ -33,8 +41,8 @@ const runtimeStub = {
|
|
|
33
41
|
},
|
|
34
42
|
media: {
|
|
35
43
|
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
|
36
|
-
mediaKindFromMime: () =>
|
|
37
|
-
isVoiceCompatibleAudio: () =>
|
|
44
|
+
mediaKindFromMime: (...args: unknown[]) => mediaKindFromMimeMock(...args),
|
|
45
|
+
isVoiceCompatibleAudio: (...args: unknown[]) => isVoiceCompatibleAudioMock(...args),
|
|
38
46
|
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
|
|
39
47
|
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
|
|
40
48
|
},
|
|
@@ -63,14 +71,16 @@ const makeClient = () => {
|
|
|
63
71
|
return { client, sendMessage, uploadContent };
|
|
64
72
|
};
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
setMatrixRuntime(runtimeStub);
|
|
76
|
+
({ sendMessageMatrix } = await import("./send.js"));
|
|
77
|
+
});
|
|
71
78
|
|
|
79
|
+
describe("sendMessageMatrix media", () => {
|
|
72
80
|
beforeEach(() => {
|
|
73
81
|
vi.clearAllMocks();
|
|
82
|
+
mediaKindFromMimeMock.mockReturnValue("image");
|
|
83
|
+
isVoiceCompatibleAudioMock.mockReturnValue(false);
|
|
74
84
|
setMatrixRuntime(runtimeStub);
|
|
75
85
|
});
|
|
76
86
|
|
|
@@ -133,14 +143,69 @@ describe("sendMessageMatrix media", () => {
|
|
|
133
143
|
expect(content.url).toBeUndefined();
|
|
134
144
|
expect(content.file?.url).toBe("mxc://example/file");
|
|
135
145
|
});
|
|
136
|
-
});
|
|
137
146
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
(
|
|
147
|
+
it("marks voice metadata and sends caption follow-up when audioAsVoice is compatible", async () => {
|
|
148
|
+
const { client, sendMessage } = makeClient();
|
|
149
|
+
mediaKindFromMimeMock.mockReturnValue("audio");
|
|
150
|
+
isVoiceCompatibleAudioMock.mockReturnValue(true);
|
|
151
|
+
loadWebMediaMock.mockResolvedValueOnce({
|
|
152
|
+
buffer: Buffer.from("audio"),
|
|
153
|
+
fileName: "clip.mp3",
|
|
154
|
+
contentType: "audio/mpeg",
|
|
155
|
+
kind: "audio",
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await sendMessageMatrix("room:!room:example", "voice caption", {
|
|
159
|
+
client,
|
|
160
|
+
mediaUrl: "file:///tmp/clip.mp3",
|
|
161
|
+
audioAsVoice: true,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(isVoiceCompatibleAudioMock).toHaveBeenCalledWith({
|
|
165
|
+
contentType: "audio/mpeg",
|
|
166
|
+
fileName: "clip.mp3",
|
|
167
|
+
});
|
|
168
|
+
expect(sendMessage).toHaveBeenCalledTimes(2);
|
|
169
|
+
const mediaContent = sendMessage.mock.calls[0]?.[1] as {
|
|
170
|
+
msgtype?: string;
|
|
171
|
+
body?: string;
|
|
172
|
+
"org.matrix.msc3245.voice"?: Record<string, never>;
|
|
173
|
+
};
|
|
174
|
+
expect(mediaContent.msgtype).toBe("m.audio");
|
|
175
|
+
expect(mediaContent.body).toBe("Voice message");
|
|
176
|
+
expect(mediaContent["org.matrix.msc3245.voice"]).toEqual({});
|
|
142
177
|
});
|
|
143
178
|
|
|
179
|
+
it("keeps regular audio payload when audioAsVoice media is incompatible", async () => {
|
|
180
|
+
const { client, sendMessage } = makeClient();
|
|
181
|
+
mediaKindFromMimeMock.mockReturnValue("audio");
|
|
182
|
+
isVoiceCompatibleAudioMock.mockReturnValue(false);
|
|
183
|
+
loadWebMediaMock.mockResolvedValueOnce({
|
|
184
|
+
buffer: Buffer.from("audio"),
|
|
185
|
+
fileName: "clip.wav",
|
|
186
|
+
contentType: "audio/wav",
|
|
187
|
+
kind: "audio",
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await sendMessageMatrix("room:!room:example", "voice caption", {
|
|
191
|
+
client,
|
|
192
|
+
mediaUrl: "file:///tmp/clip.wav",
|
|
193
|
+
audioAsVoice: true,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
197
|
+
const mediaContent = sendMessage.mock.calls[0]?.[1] as {
|
|
198
|
+
msgtype?: string;
|
|
199
|
+
body?: string;
|
|
200
|
+
"org.matrix.msc3245.voice"?: Record<string, never>;
|
|
201
|
+
};
|
|
202
|
+
expect(mediaContent.msgtype).toBe("m.audio");
|
|
203
|
+
expect(mediaContent.body).toBe("voice caption");
|
|
204
|
+
expect(mediaContent["org.matrix.msc3245.voice"]).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("sendMessageMatrix threads", () => {
|
|
144
209
|
beforeEach(() => {
|
|
145
210
|
vi.clearAllMocks();
|
|
146
211
|
setMatrixRuntime(runtimeStub);
|
package/src/matrix/send.ts
CHANGED
|
@@ -45,6 +45,7 @@ export async function sendMessageMatrix(
|
|
|
45
45
|
const { client, stopOnDone } = await resolveMatrixClient({
|
|
46
46
|
client: opts.client,
|
|
47
47
|
timeoutMs: opts.timeoutMs,
|
|
48
|
+
accountId: opts.accountId,
|
|
48
49
|
});
|
|
49
50
|
try {
|
|
50
51
|
const roomId = await resolveMatrixRoomId(client, to);
|
|
@@ -78,7 +79,7 @@ export async function sendMessageMatrix(
|
|
|
78
79
|
|
|
79
80
|
let lastMessageId = "";
|
|
80
81
|
if (opts.mediaUrl) {
|
|
81
|
-
const maxBytes = resolveMediaMaxBytes();
|
|
82
|
+
const maxBytes = resolveMediaMaxBytes(opts.accountId);
|
|
82
83
|
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
|
83
84
|
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
|
|
84
85
|
contentType: media.contentType,
|
|
@@ -166,6 +167,7 @@ export async function sendPollMatrix(
|
|
|
166
167
|
const { client, stopOnDone } = await resolveMatrixClient({
|
|
167
168
|
client: opts.client,
|
|
168
169
|
timeoutMs: opts.timeoutMs,
|
|
170
|
+
accountId: opts.accountId,
|
|
169
171
|
});
|
|
170
172
|
|
|
171
173
|
try {
|
package/src/outbound.ts
CHANGED
|
@@ -7,13 +7,14 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
|
|
7
7
|
chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
8
8
|
chunkerMode: "markdown",
|
|
9
9
|
textChunkLimit: 4000,
|
|
10
|
-
sendText: async ({ to, text, deps, replyToId, threadId }) => {
|
|
10
|
+
sendText: async ({ to, text, deps, replyToId, threadId, accountId }) => {
|
|
11
11
|
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
|
12
12
|
const resolvedThreadId =
|
|
13
13
|
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
|
14
14
|
const result = await send(to, text, {
|
|
15
15
|
replyToId: replyToId ?? undefined,
|
|
16
16
|
threadId: resolvedThreadId,
|
|
17
|
+
accountId: accountId ?? undefined,
|
|
17
18
|
});
|
|
18
19
|
return {
|
|
19
20
|
channel: "matrix",
|
|
@@ -21,7 +22,7 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
|
|
21
22
|
roomId: result.roomId,
|
|
22
23
|
};
|
|
23
24
|
},
|
|
24
|
-
sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId }) => {
|
|
25
|
+
sendMedia: async ({ to, text, mediaUrl, deps, replyToId, threadId, accountId }) => {
|
|
25
26
|
const send = deps?.sendMatrix ?? sendMessageMatrix;
|
|
26
27
|
const resolvedThreadId =
|
|
27
28
|
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
|
@@ -29,6 +30,7 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
|
|
29
30
|
mediaUrl,
|
|
30
31
|
replyToId: replyToId ?? undefined,
|
|
31
32
|
threadId: resolvedThreadId,
|
|
33
|
+
accountId: accountId ?? undefined,
|
|
32
34
|
});
|
|
33
35
|
return {
|
|
34
36
|
channel: "matrix",
|
|
@@ -36,11 +38,12 @@ export const matrixOutbound: ChannelOutboundAdapter = {
|
|
|
36
38
|
roomId: result.roomId,
|
|
37
39
|
};
|
|
38
40
|
},
|
|
39
|
-
sendPoll: async ({ to, poll, threadId }) => {
|
|
41
|
+
sendPoll: async ({ to, poll, threadId, accountId }) => {
|
|
40
42
|
const resolvedThreadId =
|
|
41
43
|
threadId !== undefined && threadId !== null ? String(threadId) : undefined;
|
|
42
44
|
const result = await sendPollMatrix(to, poll, {
|
|
43
45
|
threadId: resolvedThreadId,
|
|
46
|
+
accountId: accountId ?? undefined,
|
|
44
47
|
});
|
|
45
48
|
return {
|
|
46
49
|
channel: "matrix",
|
package/src/types.ts
CHANGED
|
@@ -39,11 +39,16 @@ export type MatrixActionConfig = {
|
|
|
39
39
|
channelInfo?: boolean;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/** Per-account Matrix config (excludes the accounts field to prevent recursion). */
|
|
43
|
+
export type MatrixAccountConfig = Omit<MatrixConfig, "accounts">;
|
|
44
|
+
|
|
42
45
|
export type MatrixConfig = {
|
|
43
46
|
/** Optional display name for this account (used in CLI/UI lists). */
|
|
44
47
|
name?: string;
|
|
45
48
|
/** If false, do not start Matrix. Default: true. */
|
|
46
49
|
enabled?: boolean;
|
|
50
|
+
/** Multi-account configuration keyed by account ID. */
|
|
51
|
+
accounts?: Record<string, MatrixAccountConfig>;
|
|
47
52
|
/** Matrix homeserver URL (https://matrix.example.org). */
|
|
48
53
|
homeserver?: string;
|
|
49
54
|
/** Matrix user id (@user:server). */
|