@openclaw/matrix 2026.2.23 → 2026.2.25
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 -4
- package/src/matrix/monitor/events.test.ts +141 -0
- package/src/matrix/monitor/events.ts +47 -1
- package/src/matrix/monitor/handler.ts +2 -16
- package/src/matrix/send-queue.test.ts +154 -0
- package/src/matrix/send-queue.ts +44 -0
- package/src/matrix/send.ts +95 -92
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/matrix",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.25",
|
|
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
|
|
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
|
+
}
|
package/src/matrix/send.ts
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
147
|
+
return {
|
|
148
|
+
messageId: lastMessageId || "unknown",
|
|
149
|
+
roomId,
|
|
150
|
+
};
|
|
151
|
+
});
|
|
149
152
|
} finally {
|
|
150
153
|
if (stopOnDone) {
|
|
151
154
|
client.stop();
|