@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 +6 -0
- package/package.json +1 -4
- package/src/matrix/deps.ts +2 -82
- 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/monitor/index.ts +6 -13
- package/src/matrix/monitor/replies.test.ts +52 -0
- package/src/matrix/monitor/replies.ts +24 -0
- 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.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"
|
package/src/matrix/deps.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
logger
|
|
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
|
+
}
|
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();
|