@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.
@@ -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
+ }
@@ -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
  ? {
@@ -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: () => "image",
37
- isVoiceCompatibleAudio: () => false,
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
- describe("sendMessageMatrix media", () => {
67
- beforeAll(async () => {
68
- setMatrixRuntime(runtimeStub);
69
- ({ sendMessageMatrix } = await import("./send.js"));
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
- describe("sendMessageMatrix threads", () => {
139
- beforeAll(async () => {
140
- setMatrixRuntime(runtimeStub);
141
- ({ sendMessageMatrix } = await import("./send.js"));
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);
@@ -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). */