@openclaw/matrix 2026.2.22 → 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.22",
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"
@@ -1,9 +1,8 @@
1
- import { spawn } from "node:child_process";
2
1
  import fs from "node:fs";
3
2
  import { createRequire } from "node:module";
4
3
  import path from "node:path";
5
4
  import { fileURLToPath } from "node:url";
6
- import type { RuntimeEnv } from "openclaw/plugin-sdk";
5
+ import { runPluginCommandWithTimeout, type RuntimeEnv } from "openclaw/plugin-sdk";
7
6
 
8
7
  const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
9
8
 
@@ -22,85 +21,6 @@ function resolvePluginRoot(): string {
22
21
  return path.resolve(currentDir, "..", "..");
23
22
  }
24
23
 
25
- type CommandResult = {
26
- code: number;
27
- stdout: string;
28
- stderr: string;
29
- };
30
-
31
- async function runFixedCommandWithTimeout(params: {
32
- argv: string[];
33
- cwd: string;
34
- timeoutMs: number;
35
- env?: NodeJS.ProcessEnv;
36
- }): Promise<CommandResult> {
37
- return await new Promise((resolve) => {
38
- const [command, ...args] = params.argv;
39
- if (!command) {
40
- resolve({
41
- code: 1,
42
- stdout: "",
43
- stderr: "command is required",
44
- });
45
- return;
46
- }
47
-
48
- const proc = spawn(command, args, {
49
- cwd: params.cwd,
50
- env: { ...process.env, ...params.env },
51
- stdio: ["ignore", "pipe", "pipe"],
52
- });
53
-
54
- let stdout = "";
55
- let stderr = "";
56
- let settled = false;
57
- let timer: NodeJS.Timeout | null = null;
58
-
59
- const finalize = (result: CommandResult) => {
60
- if (settled) {
61
- return;
62
- }
63
- settled = true;
64
- if (timer) {
65
- clearTimeout(timer);
66
- }
67
- resolve(result);
68
- };
69
-
70
- proc.stdout?.on("data", (chunk: Buffer | string) => {
71
- stdout += chunk.toString();
72
- });
73
- proc.stderr?.on("data", (chunk: Buffer | string) => {
74
- stderr += chunk.toString();
75
- });
76
-
77
- timer = setTimeout(() => {
78
- proc.kill("SIGKILL");
79
- finalize({
80
- code: 124,
81
- stdout,
82
- stderr: stderr || `command timed out after ${params.timeoutMs}ms`,
83
- });
84
- }, params.timeoutMs);
85
-
86
- proc.on("error", (err) => {
87
- finalize({
88
- code: 1,
89
- stdout,
90
- stderr: err.message,
91
- });
92
- });
93
-
94
- proc.on("close", (code) => {
95
- finalize({
96
- code: code ?? 1,
97
- stdout,
98
- stderr,
99
- });
100
- });
101
- });
102
- }
103
-
104
24
  export async function ensureMatrixSdkInstalled(params: {
105
25
  runtime: RuntimeEnv;
106
26
  confirm?: (message: string) => Promise<boolean>;
@@ -121,7 +41,7 @@ export async function ensureMatrixSdkInstalled(params: {
121
41
  ? ["pnpm", "install"]
122
42
  : ["npm", "install", "--omit=dev", "--silent"];
123
43
  params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
124
- const result = await runFixedCommandWithTimeout({
44
+ const result = await runPluginCommandWithTimeout({
125
45
  argv: command,
126
46
  cwd: root,
127
47
  timeoutMs: 300_000,
@@ -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({
@@ -1,5 +1,5 @@
1
- import { format } from "node:util";
2
1
  import {
2
+ createLoggerBackedRuntime,
3
3
  GROUP_POLICY_BLOCKED_LABEL,
4
4
  mergeAllowlist,
5
5
  resolveAllowlistProviderRuntimeGroupPolicy,
@@ -48,18 +48,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
48
48
  }
49
49
 
50
50
  const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
51
- const formatRuntimeMessage = (...args: Parameters<RuntimeEnv["log"]>) => format(...args);
52
- const runtime: RuntimeEnv = opts.runtime ?? {
53
- log: (...args) => {
54
- logger.info(formatRuntimeMessage(...args));
55
- },
56
- error: (...args) => {
57
- logger.error(formatRuntimeMessage(...args));
58
- },
59
- exit: (code: number): never => {
60
- throw new Error(`exit ${code}`);
61
- },
62
- };
51
+ const runtime: RuntimeEnv =
52
+ opts.runtime ??
53
+ createLoggerBackedRuntime({
54
+ logger,
55
+ });
63
56
  const logVerboseMessage = (message: string) => {
64
57
  if (!core.logging.shouldLogVerbose()) {
65
58
  return;
@@ -108,6 +108,58 @@ describe("deliverMatrixReplies", () => {
108
108
  );
109
109
  });
110
110
 
111
+ it("skips reasoning-only replies with Reasoning prefix", async () => {
112
+ await deliverMatrixReplies({
113
+ replies: [
114
+ { text: "Reasoning:\nThe user wants X because Y.", replyToId: "r1" },
115
+ { text: "Here is the answer.", replyToId: "r2" },
116
+ ],
117
+ roomId: "room:reason",
118
+ client: {} as MatrixClient,
119
+ runtime: runtimeEnv,
120
+ textLimit: 4000,
121
+ replyToMode: "first",
122
+ });
123
+
124
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
125
+ expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Here is the answer.");
126
+ });
127
+
128
+ it("skips reasoning-only replies with thinking tags", async () => {
129
+ await deliverMatrixReplies({
130
+ replies: [
131
+ { text: "<thinking>internal chain of thought</thinking>", replyToId: "r1" },
132
+ { text: " <think>more reasoning</think> ", replyToId: "r2" },
133
+ { text: "<antthinking>hidden</antthinking>", replyToId: "r3" },
134
+ { text: "Visible reply", replyToId: "r4" },
135
+ ],
136
+ roomId: "room:tags",
137
+ client: {} as MatrixClient,
138
+ runtime: runtimeEnv,
139
+ textLimit: 4000,
140
+ replyToMode: "all",
141
+ });
142
+
143
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(1);
144
+ expect(sendMessageMatrixMock.mock.calls[0]?.[1]).toBe("Visible reply");
145
+ });
146
+
147
+ it("delivers all replies when none are reasoning-only", async () => {
148
+ await deliverMatrixReplies({
149
+ replies: [
150
+ { text: "First answer", replyToId: "r1" },
151
+ { text: "Second answer", replyToId: "r2" },
152
+ ],
153
+ roomId: "room:normal",
154
+ client: {} as MatrixClient,
155
+ runtime: runtimeEnv,
156
+ textLimit: 4000,
157
+ replyToMode: "all",
158
+ });
159
+
160
+ expect(sendMessageMatrixMock).toHaveBeenCalledTimes(2);
161
+ });
162
+
111
163
  it("suppresses replyToId when threadId is set", async () => {
112
164
  chunkMarkdownTextWithModeMock.mockImplementation((text: string) => text.split("|"));
113
165
 
@@ -41,6 +41,11 @@ export async function deliverMatrixReplies(params: {
41
41
  params.runtime.error?.("matrix reply missing text/media");
42
42
  continue;
43
43
  }
44
+ // Skip pure reasoning messages so internal thinking traces are never delivered.
45
+ if (reply.text && isReasoningOnlyMessage(reply.text)) {
46
+ logVerbose("matrix reply is reasoning-only; skipping");
47
+ continue;
48
+ }
44
49
  const replyToIdRaw = reply.replyToId?.trim();
45
50
  const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
46
51
  const rawText = reply.text ?? "";
@@ -98,3 +103,22 @@ export async function deliverMatrixReplies(params: {
98
103
  }
99
104
  }
100
105
  }
106
+
107
+ const REASONING_PREFIX = "Reasoning:\n";
108
+ const THINKING_TAG_RE = /^\s*<\s*(?:think(?:ing)?|thought|antthinking)\b/i;
109
+
110
+ /**
111
+ * Detect messages that contain only reasoning/thinking content and no user-facing answer.
112
+ * These are emitted by the agent when `includeReasoning` is active but should not
113
+ * be forwarded to channels that do not support a dedicated reasoning lane.
114
+ */
115
+ function isReasoningOnlyMessage(text: string): boolean {
116
+ const trimmed = text.trim();
117
+ if (trimmed.startsWith(REASONING_PREFIX)) {
118
+ return true;
119
+ }
120
+ if (THINKING_TAG_RE.test(trimmed)) {
121
+ return true;
122
+ }
123
+ return false;
124
+ }
@@ -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();