@openclaw/matrix 2026.2.23 → 2026.2.24

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.24
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.2.22
4
10
 
5
11
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/matrix",
3
- "version": "2026.2.23",
3
+ "version": "2026.2.24",
4
4
  "description": "OpenClaw Matrix channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -10,9 +10,6 @@
10
10
  "music-metadata": "^11.12.1",
11
11
  "zod": "^4.3.6"
12
12
  },
13
- "devDependencies": {
14
- "openclaw": "workspace:*"
15
- },
16
13
  "openclaw": {
17
14
  "extensions": [
18
15
  "./index.ts"
@@ -0,0 +1,141 @@
1
+ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
+ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+ import type { MatrixAuth } from "../client.js";
5
+ import { registerMatrixMonitorEvents } from "./events.js";
6
+ import type { MatrixRawEvent } from "./types.js";
7
+
8
+ const sendReadReceiptMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
9
+
10
+ vi.mock("../send.js", () => ({
11
+ sendReadReceiptMatrix: (...args: unknown[]) => sendReadReceiptMatrixMock(...args),
12
+ }));
13
+
14
+ describe("registerMatrixMonitorEvents", () => {
15
+ beforeEach(() => {
16
+ sendReadReceiptMatrixMock.mockClear();
17
+ });
18
+
19
+ function createHarness(options?: { getUserId?: ReturnType<typeof vi.fn> }) {
20
+ const handlers = new Map<string, (...args: unknown[]) => void>();
21
+ const getUserId = options?.getUserId ?? vi.fn().mockResolvedValue("@bot:example.org");
22
+ const client = {
23
+ on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
24
+ handlers.set(event, handler);
25
+ }),
26
+ getUserId,
27
+ crypto: undefined,
28
+ } as unknown as MatrixClient;
29
+
30
+ const onRoomMessage = vi.fn();
31
+ const logVerboseMessage = vi.fn();
32
+ const logger = {
33
+ warn: vi.fn(),
34
+ } as unknown as RuntimeLogger;
35
+
36
+ registerMatrixMonitorEvents({
37
+ client,
38
+ auth: { encryption: false } as MatrixAuth,
39
+ logVerboseMessage,
40
+ warnedEncryptedRooms: new Set<string>(),
41
+ warnedCryptoMissingRooms: new Set<string>(),
42
+ logger,
43
+ formatNativeDependencyHint: (() =>
44
+ "") as PluginRuntime["system"]["formatNativeDependencyHint"],
45
+ onRoomMessage,
46
+ });
47
+
48
+ const roomMessageHandler = handlers.get("room.message");
49
+ if (!roomMessageHandler) {
50
+ throw new Error("missing room.message handler");
51
+ }
52
+
53
+ return { client, getUserId, onRoomMessage, roomMessageHandler, logVerboseMessage };
54
+ }
55
+
56
+ it("sends read receipt immediately for non-self messages", async () => {
57
+ const { client, onRoomMessage, roomMessageHandler } = createHarness();
58
+ const event = {
59
+ event_id: "$e1",
60
+ sender: "@alice:example.org",
61
+ } as MatrixRawEvent;
62
+
63
+ roomMessageHandler("!room:example.org", event);
64
+
65
+ expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
66
+ await vi.waitFor(() => {
67
+ expect(sendReadReceiptMatrixMock).toHaveBeenCalledWith("!room:example.org", "$e1", client);
68
+ });
69
+ });
70
+
71
+ it("does not send read receipts for self messages", async () => {
72
+ const { onRoomMessage, roomMessageHandler } = createHarness();
73
+ const event = {
74
+ event_id: "$e2",
75
+ sender: "@bot:example.org",
76
+ } as MatrixRawEvent;
77
+
78
+ roomMessageHandler("!room:example.org", event);
79
+ await vi.waitFor(() => {
80
+ expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
81
+ });
82
+ expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("skips receipt when message lacks sender or event id", async () => {
86
+ const { onRoomMessage, roomMessageHandler } = createHarness();
87
+ const event = {
88
+ sender: "@alice:example.org",
89
+ } as MatrixRawEvent;
90
+
91
+ roomMessageHandler("!room:example.org", event);
92
+ await vi.waitFor(() => {
93
+ expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
94
+ });
95
+ expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it("caches self user id across messages", async () => {
99
+ const { getUserId, roomMessageHandler } = createHarness();
100
+ const first = { event_id: "$e3", sender: "@alice:example.org" } as MatrixRawEvent;
101
+ const second = { event_id: "$e4", sender: "@bob:example.org" } as MatrixRawEvent;
102
+
103
+ roomMessageHandler("!room:example.org", first);
104
+ roomMessageHandler("!room:example.org", second);
105
+
106
+ await vi.waitFor(() => {
107
+ expect(sendReadReceiptMatrixMock).toHaveBeenCalledTimes(2);
108
+ });
109
+ expect(getUserId).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it("logs and continues when sending read receipt fails", async () => {
113
+ sendReadReceiptMatrixMock.mockRejectedValueOnce(new Error("network boom"));
114
+ const { roomMessageHandler, onRoomMessage, logVerboseMessage } = createHarness();
115
+ const event = { event_id: "$e5", sender: "@alice:example.org" } as MatrixRawEvent;
116
+
117
+ roomMessageHandler("!room:example.org", event);
118
+
119
+ await vi.waitFor(() => {
120
+ expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
121
+ expect(logVerboseMessage).toHaveBeenCalledWith(
122
+ expect.stringContaining("matrix: early read receipt failed"),
123
+ );
124
+ });
125
+ });
126
+
127
+ it("skips read receipts if self-user lookup fails", async () => {
128
+ const { roomMessageHandler, onRoomMessage, getUserId } = createHarness({
129
+ getUserId: vi.fn().mockRejectedValue(new Error("cannot resolve self")),
130
+ });
131
+ const event = { event_id: "$e6", sender: "@alice:example.org" } as MatrixRawEvent;
132
+
133
+ roomMessageHandler("!room:example.org", event);
134
+
135
+ await vi.waitFor(() => {
136
+ expect(onRoomMessage).toHaveBeenCalledWith("!room:example.org", event);
137
+ });
138
+ expect(getUserId).toHaveBeenCalledTimes(1);
139
+ expect(sendReadReceiptMatrixMock).not.toHaveBeenCalled();
140
+ });
141
+ });
@@ -1,9 +1,36 @@
1
1
  import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
2
  import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk";
3
3
  import type { MatrixAuth } from "../client.js";
4
+ import { sendReadReceiptMatrix } from "../send.js";
4
5
  import type { MatrixRawEvent } from "./types.js";
5
6
  import { EventType } from "./types.js";
6
7
 
8
+ function createSelfUserIdResolver(client: Pick<MatrixClient, "getUserId">) {
9
+ let selfUserId: string | undefined;
10
+ let selfUserIdLookup: Promise<string | undefined> | undefined;
11
+
12
+ return async (): Promise<string | undefined> => {
13
+ if (selfUserId) {
14
+ return selfUserId;
15
+ }
16
+ if (!selfUserIdLookup) {
17
+ selfUserIdLookup = client
18
+ .getUserId()
19
+ .then((userId) => {
20
+ selfUserId = userId;
21
+ return userId;
22
+ })
23
+ .catch(() => undefined)
24
+ .finally(() => {
25
+ if (!selfUserId) {
26
+ selfUserIdLookup = undefined;
27
+ }
28
+ });
29
+ }
30
+ return await selfUserIdLookup;
31
+ };
32
+ }
33
+
7
34
  export function registerMatrixMonitorEvents(params: {
8
35
  client: MatrixClient;
9
36
  auth: MatrixAuth;
@@ -25,7 +52,26 @@ export function registerMatrixMonitorEvents(params: {
25
52
  onRoomMessage,
26
53
  } = params;
27
54
 
28
- client.on("room.message", onRoomMessage);
55
+ const resolveSelfUserId = createSelfUserIdResolver(client);
56
+ client.on("room.message", (roomId: string, event: MatrixRawEvent) => {
57
+ const eventId = event?.event_id;
58
+ const senderId = event?.sender;
59
+ if (eventId && senderId) {
60
+ void (async () => {
61
+ const currentSelfUserId = await resolveSelfUserId();
62
+ if (!currentSelfUserId || senderId === currentSelfUserId) {
63
+ return;
64
+ }
65
+ await sendReadReceiptMatrix(roomId, eventId, client).catch((err) => {
66
+ logVerboseMessage(
67
+ `matrix: early read receipt failed room=${roomId} id=${eventId}: ${String(err)}`,
68
+ );
69
+ });
70
+ })();
71
+ }
72
+
73
+ onRoomMessage(roomId, event);
74
+ });
29
75
 
30
76
  client.on("room.encrypted_event", (roomId: string, event: MatrixRawEvent) => {
31
77
  const eventId = event?.event_id ?? "unknown";
@@ -18,12 +18,7 @@ import {
18
18
  parsePollStartContent,
19
19
  type PollStartContent,
20
20
  } from "../poll-types.js";
21
- import {
22
- reactMatrixMessage,
23
- sendMessageMatrix,
24
- sendReadReceiptMatrix,
25
- sendTypingMatrix,
26
- } from "../send.js";
21
+ import { reactMatrixMessage, sendMessageMatrix, sendTypingMatrix } from "../send.js";
27
22
  import {
28
23
  normalizeMatrixAllowList,
29
24
  resolveMatrixAllowListMatch,
@@ -602,14 +597,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
602
597
  return;
603
598
  }
604
599
 
605
- if (messageId) {
606
- sendReadReceiptMatrix(roomId, messageId, client).catch((err) => {
607
- logVerboseMessage(
608
- `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
609
- );
610
- });
611
- }
612
-
613
600
  let didSendReply = false;
614
601
  const tableMode = core.channel.text.resolveMarkdownTableMode({
615
602
  cfg,
@@ -648,6 +635,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
648
635
  core.channel.reply.createReplyDispatcherWithTyping({
649
636
  ...prefixOptions,
650
637
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
638
+ typingCallbacks,
651
639
  deliver: async (payload) => {
652
640
  await deliverMatrixReplies({
653
641
  replies: [payload],
@@ -665,8 +653,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
665
653
  onError: (err, info) => {
666
654
  runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
667
655
  },
668
- onReplyStart: typingCallbacks.onReplyStart,
669
- onIdle: typingCallbacks.onIdle,
670
656
  });
671
657
 
672
658
  const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
@@ -0,0 +1,154 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { DEFAULT_SEND_GAP_MS, enqueueSend } from "./send-queue.js";
3
+
4
+ function deferred<T>() {
5
+ let resolve!: (value: T | PromiseLike<T>) => void;
6
+ let reject!: (reason?: unknown) => void;
7
+ const promise = new Promise<T>((res, rej) => {
8
+ resolve = res;
9
+ reject = rej;
10
+ });
11
+ return { promise, resolve, reject };
12
+ }
13
+
14
+ describe("enqueueSend", () => {
15
+ beforeEach(() => {
16
+ vi.useFakeTimers();
17
+ });
18
+
19
+ afterEach(() => {
20
+ vi.useRealTimers();
21
+ });
22
+
23
+ it("serializes sends per room", async () => {
24
+ const gate = deferred<void>();
25
+ const events: string[] = [];
26
+
27
+ const first = enqueueSend("!room:example.org", async () => {
28
+ events.push("start1");
29
+ await gate.promise;
30
+ events.push("end1");
31
+ return "one";
32
+ });
33
+ const second = enqueueSend("!room:example.org", async () => {
34
+ events.push("start2");
35
+ events.push("end2");
36
+ return "two";
37
+ });
38
+
39
+ await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
40
+ expect(events).toEqual(["start1"]);
41
+
42
+ await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS * 2);
43
+ expect(events).toEqual(["start1"]);
44
+
45
+ gate.resolve();
46
+ await first;
47
+ await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS - 1);
48
+ expect(events).toEqual(["start1", "end1"]);
49
+ await vi.advanceTimersByTimeAsync(1);
50
+ await second;
51
+ expect(events).toEqual(["start1", "end1", "start2", "end2"]);
52
+ });
53
+
54
+ it("does not serialize across different rooms", async () => {
55
+ const events: string[] = [];
56
+
57
+ const a = enqueueSend("!a:example.org", async () => {
58
+ events.push("a");
59
+ return "a";
60
+ });
61
+ const b = enqueueSend("!b:example.org", async () => {
62
+ events.push("b");
63
+ return "b";
64
+ });
65
+
66
+ await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
67
+ await Promise.all([a, b]);
68
+ expect(events.sort()).toEqual(["a", "b"]);
69
+ });
70
+
71
+ it("continues queue after failures", async () => {
72
+ const first = enqueueSend("!room:example.org", async () => {
73
+ throw new Error("boom");
74
+ }).then(
75
+ () => ({ ok: true as const }),
76
+ (error) => ({ ok: false as const, error }),
77
+ );
78
+
79
+ await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
80
+ const firstResult = await first;
81
+ expect(firstResult.ok).toBe(false);
82
+ if (firstResult.ok) {
83
+ throw new Error("expected first queue item to fail");
84
+ }
85
+ expect(firstResult.error).toBeInstanceOf(Error);
86
+ expect(firstResult.error.message).toBe("boom");
87
+
88
+ const second = enqueueSend("!room:example.org", async () => "ok");
89
+ await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
90
+ await expect(second).resolves.toBe("ok");
91
+ });
92
+
93
+ it("continues queued work when the head task fails", async () => {
94
+ const gate = deferred<void>();
95
+ const events: string[] = [];
96
+
97
+ const first = enqueueSend("!room:example.org", async () => {
98
+ events.push("start1");
99
+ await gate.promise;
100
+ throw new Error("boom");
101
+ }).then(
102
+ () => ({ ok: true as const }),
103
+ (error) => ({ ok: false as const, error }),
104
+ );
105
+ const second = enqueueSend("!room:example.org", async () => {
106
+ events.push("start2");
107
+ return "two";
108
+ });
109
+
110
+ await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
111
+ expect(events).toEqual(["start1"]);
112
+
113
+ gate.resolve();
114
+ const firstResult = await first;
115
+ expect(firstResult.ok).toBe(false);
116
+ if (firstResult.ok) {
117
+ throw new Error("expected head queue item to fail");
118
+ }
119
+ expect(firstResult.error).toBeInstanceOf(Error);
120
+
121
+ await vi.advanceTimersByTimeAsync(DEFAULT_SEND_GAP_MS);
122
+ await expect(second).resolves.toBe("two");
123
+ expect(events).toEqual(["start1", "start2"]);
124
+ });
125
+
126
+ it("supports custom gap and delay injection", async () => {
127
+ const events: string[] = [];
128
+ const delayFn = vi.fn(async (_ms: number) => {});
129
+
130
+ const first = enqueueSend(
131
+ "!room:example.org",
132
+ async () => {
133
+ events.push("first");
134
+ return "one";
135
+ },
136
+ { gapMs: 7, delayFn },
137
+ );
138
+ const second = enqueueSend(
139
+ "!room:example.org",
140
+ async () => {
141
+ events.push("second");
142
+ return "two";
143
+ },
144
+ { gapMs: 7, delayFn },
145
+ );
146
+
147
+ await expect(first).resolves.toBe("one");
148
+ await expect(second).resolves.toBe("two");
149
+ expect(events).toEqual(["first", "second"]);
150
+ expect(delayFn).toHaveBeenCalledTimes(2);
151
+ expect(delayFn).toHaveBeenNthCalledWith(1, 7);
152
+ expect(delayFn).toHaveBeenNthCalledWith(2, 7);
153
+ });
154
+ });
@@ -0,0 +1,44 @@
1
+ export const DEFAULT_SEND_GAP_MS = 150;
2
+
3
+ type MatrixSendQueueOptions = {
4
+ gapMs?: number;
5
+ delayFn?: (ms: number) => Promise<void>;
6
+ };
7
+
8
+ // Serialize sends per room to preserve Matrix delivery order.
9
+ const roomQueues = new Map<string, Promise<void>>();
10
+
11
+ export async function enqueueSend<T>(
12
+ roomId: string,
13
+ fn: () => Promise<T>,
14
+ options?: MatrixSendQueueOptions,
15
+ ): Promise<T> {
16
+ const gapMs = options?.gapMs ?? DEFAULT_SEND_GAP_MS;
17
+ const delayFn = options?.delayFn ?? delay;
18
+ const previous = roomQueues.get(roomId) ?? Promise.resolve();
19
+
20
+ const next = previous
21
+ .catch(() => {})
22
+ .then(async () => {
23
+ await delayFn(gapMs);
24
+ return await fn();
25
+ });
26
+
27
+ const queueMarker = next.then(
28
+ () => {},
29
+ () => {},
30
+ );
31
+ roomQueues.set(roomId, queueMarker);
32
+
33
+ queueMarker.finally(() => {
34
+ if (roomQueues.get(roomId) === queueMarker) {
35
+ roomQueues.delete(roomId);
36
+ }
37
+ });
38
+
39
+ return await next;
40
+ }
41
+
42
+ function delay(ms: number): Promise<void> {
43
+ return new Promise((resolve) => setTimeout(resolve, ms));
44
+ }
@@ -2,6 +2,7 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
2
  import type { PollInput } from "openclaw/plugin-sdk";
3
3
  import { getMatrixRuntime } from "../runtime.js";
4
4
  import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
5
+ import { enqueueSend } from "./send-queue.js";
5
6
  import { resolveMatrixClient, resolveMediaMaxBytes } from "./send/client.js";
6
7
  import {
7
8
  buildReplyRelation,
@@ -49,103 +50,105 @@ export async function sendMessageMatrix(
49
50
  });
50
51
  try {
51
52
  const roomId = await resolveMatrixRoomId(client, to);
52
- const cfg = getCore().config.loadConfig();
53
- const tableMode = getCore().channel.text.resolveMarkdownTableMode({
54
- cfg,
55
- channel: "matrix",
56
- accountId: opts.accountId,
57
- });
58
- const convertedMessage = getCore().channel.text.convertMarkdownTables(
59
- trimmedMessage,
60
- tableMode,
61
- );
62
- const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
63
- const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
64
- const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
65
- const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
66
- convertedMessage,
67
- chunkLimit,
68
- chunkMode,
69
- );
70
- const threadId = normalizeThreadId(opts.threadId);
71
- const relation = threadId
72
- ? buildThreadRelation(threadId, opts.replyToId)
73
- : buildReplyRelation(opts.replyToId);
74
- const sendContent = async (content: MatrixOutboundContent) => {
75
- // @vector-im/matrix-bot-sdk uses sendMessage differently
76
- const eventId = await client.sendMessage(roomId, content);
77
- return eventId;
78
- };
79
-
80
- let lastMessageId = "";
81
- if (opts.mediaUrl) {
82
- const maxBytes = resolveMediaMaxBytes(opts.accountId);
83
- const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
84
- const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
85
- contentType: media.contentType,
86
- filename: media.fileName,
87
- });
88
- const durationMs = await resolveMediaDurationMs({
89
- buffer: media.buffer,
90
- contentType: media.contentType,
91
- fileName: media.fileName,
92
- kind: media.kind,
53
+ return await enqueueSend(roomId, async () => {
54
+ const cfg = getCore().config.loadConfig();
55
+ const tableMode = getCore().channel.text.resolveMarkdownTableMode({
56
+ cfg,
57
+ channel: "matrix",
58
+ accountId: opts.accountId,
93
59
  });
94
- const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
95
- const { useVoice } = resolveMatrixVoiceDecision({
96
- wantsVoice: opts.audioAsVoice === true,
97
- contentType: media.contentType,
98
- fileName: media.fileName,
99
- });
100
- const msgtype = useVoice ? MsgType.Audio : baseMsgType;
101
- const isImage = msgtype === MsgType.Image;
102
- const imageInfo = isImage
103
- ? await prepareImageInfo({ buffer: media.buffer, client })
104
- : undefined;
105
- const [firstChunk, ...rest] = chunks;
106
- const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
107
- const content = buildMediaContent({
108
- msgtype,
109
- body,
110
- url: uploaded.url,
111
- file: uploaded.file,
112
- filename: media.fileName,
113
- mimetype: media.contentType,
114
- size: media.buffer.byteLength,
115
- durationMs,
116
- relation,
117
- isVoice: useVoice,
118
- imageInfo,
119
- });
120
- const eventId = await sendContent(content);
121
- lastMessageId = eventId ?? lastMessageId;
122
- const textChunks = useVoice ? chunks : rest;
123
- const followupRelation = threadId ? relation : undefined;
124
- for (const chunk of textChunks) {
125
- const text = chunk.trim();
126
- if (!text) {
127
- continue;
128
- }
129
- const followup = buildTextContent(text, followupRelation);
130
- const followupEventId = await sendContent(followup);
131
- lastMessageId = followupEventId ?? lastMessageId;
132
- }
133
- } else {
134
- for (const chunk of chunks.length ? chunks : [""]) {
135
- const text = chunk.trim();
136
- if (!text) {
137
- continue;
138
- }
139
- const content = buildTextContent(text, relation);
60
+ const convertedMessage = getCore().channel.text.convertMarkdownTables(
61
+ trimmedMessage,
62
+ tableMode,
63
+ );
64
+ const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
65
+ const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
66
+ const chunkMode = getCore().channel.text.resolveChunkMode(cfg, "matrix", opts.accountId);
67
+ const chunks = getCore().channel.text.chunkMarkdownTextWithMode(
68
+ convertedMessage,
69
+ chunkLimit,
70
+ chunkMode,
71
+ );
72
+ const threadId = normalizeThreadId(opts.threadId);
73
+ const relation = threadId
74
+ ? buildThreadRelation(threadId, opts.replyToId)
75
+ : buildReplyRelation(opts.replyToId);
76
+ const sendContent = async (content: MatrixOutboundContent) => {
77
+ // @vector-im/matrix-bot-sdk uses sendMessage differently
78
+ const eventId = await client.sendMessage(roomId, content);
79
+ return eventId;
80
+ };
81
+
82
+ let lastMessageId = "";
83
+ if (opts.mediaUrl) {
84
+ const maxBytes = resolveMediaMaxBytes(opts.accountId);
85
+ const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
86
+ const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
87
+ contentType: media.contentType,
88
+ filename: media.fileName,
89
+ });
90
+ const durationMs = await resolveMediaDurationMs({
91
+ buffer: media.buffer,
92
+ contentType: media.contentType,
93
+ fileName: media.fileName,
94
+ kind: media.kind,
95
+ });
96
+ const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName);
97
+ const { useVoice } = resolveMatrixVoiceDecision({
98
+ wantsVoice: opts.audioAsVoice === true,
99
+ contentType: media.contentType,
100
+ fileName: media.fileName,
101
+ });
102
+ const msgtype = useVoice ? MsgType.Audio : baseMsgType;
103
+ const isImage = msgtype === MsgType.Image;
104
+ const imageInfo = isImage
105
+ ? await prepareImageInfo({ buffer: media.buffer, client })
106
+ : undefined;
107
+ const [firstChunk, ...rest] = chunks;
108
+ const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
109
+ const content = buildMediaContent({
110
+ msgtype,
111
+ body,
112
+ url: uploaded.url,
113
+ file: uploaded.file,
114
+ filename: media.fileName,
115
+ mimetype: media.contentType,
116
+ size: media.buffer.byteLength,
117
+ durationMs,
118
+ relation,
119
+ isVoice: useVoice,
120
+ imageInfo,
121
+ });
140
122
  const eventId = await sendContent(content);
141
123
  lastMessageId = eventId ?? lastMessageId;
124
+ const textChunks = useVoice ? chunks : rest;
125
+ const followupRelation = threadId ? relation : undefined;
126
+ for (const chunk of textChunks) {
127
+ const text = chunk.trim();
128
+ if (!text) {
129
+ continue;
130
+ }
131
+ const followup = buildTextContent(text, followupRelation);
132
+ const followupEventId = await sendContent(followup);
133
+ lastMessageId = followupEventId ?? lastMessageId;
134
+ }
135
+ } else {
136
+ for (const chunk of chunks.length ? chunks : [""]) {
137
+ const text = chunk.trim();
138
+ if (!text) {
139
+ continue;
140
+ }
141
+ const content = buildTextContent(text, relation);
142
+ const eventId = await sendContent(content);
143
+ lastMessageId = eventId ?? lastMessageId;
144
+ }
142
145
  }
143
- }
144
146
 
145
- return {
146
- messageId: lastMessageId || "unknown",
147
- roomId,
148
- };
147
+ return {
148
+ messageId: lastMessageId || "unknown",
149
+ roomId,
150
+ };
151
+ });
149
152
  } finally {
150
153
  if (stopOnDone) {
151
154
  client.stop();