@openclaw/matrix 2026.3.7 → 2026.3.8-beta.1
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 +9 -2
- package/src/matrix/monitor/direct.test.ts +374 -39
- package/src/matrix/monitor/direct.ts +48 -13
- package/src/matrix/monitor/handler.body-for-agent.test.ts +55 -1
- package/src/matrix/monitor/handler.ts +88 -12
- package/src/matrix/monitor/rooms.test.ts +85 -0
- package/src/runtime.ts +4 -12
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/matrix",
|
|
3
|
-
"version": "2026.3.
|
|
3
|
+
"version": "2026.3.8-beta.1",
|
|
4
4
|
"description": "OpenClaw Matrix channel plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"@mariozechner/pi-agent-core": "0.
|
|
7
|
+
"@mariozechner/pi-agent-core": "0.57.1",
|
|
8
8
|
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
|
|
9
9
|
"@vector-im/matrix-bot-sdk": "0.8.0-element.3",
|
|
10
10
|
"markdown-it": "14.1.1",
|
|
@@ -29,6 +29,13 @@
|
|
|
29
29
|
"npmSpec": "@openclaw/matrix",
|
|
30
30
|
"localPath": "extensions/matrix",
|
|
31
31
|
"defaultChoice": "npm"
|
|
32
|
+
},
|
|
33
|
+
"releaseChecks": {
|
|
34
|
+
"rootDependencyMirrorAllowlist": [
|
|
35
|
+
"@matrix-org/matrix-sdk-crypto-nodejs",
|
|
36
|
+
"@vector-im/matrix-bot-sdk",
|
|
37
|
+
"music-metadata"
|
|
38
|
+
]
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
}
|
|
@@ -1,65 +1,400 @@
|
|
|
1
|
-
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
3
2
|
import { createDirectRoomTracker } from "./direct.js";
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Helpers -- minimal MatrixClient stub
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
type StateEvent = Record<string, unknown>;
|
|
9
|
+
type DmMap = Record<string, boolean>;
|
|
10
|
+
|
|
11
|
+
function createMockClient(opts: {
|
|
12
|
+
dmRooms?: DmMap;
|
|
13
|
+
membersByRoom?: Record<string, string[]>;
|
|
14
|
+
stateEvents?: Record<string, StateEvent>;
|
|
15
|
+
selfUserId?: string;
|
|
10
16
|
}) {
|
|
11
|
-
const
|
|
17
|
+
const {
|
|
18
|
+
dmRooms = {},
|
|
19
|
+
membersByRoom = {},
|
|
20
|
+
stateEvents = {},
|
|
21
|
+
selfUserId = "@bot:example.org",
|
|
22
|
+
} = opts;
|
|
23
|
+
|
|
12
24
|
return {
|
|
13
25
|
dms: {
|
|
26
|
+
isDm: (roomId: string) => dmRooms[roomId] ?? false,
|
|
14
27
|
update: vi.fn().mockResolvedValue(undefined),
|
|
15
|
-
isDm: vi.fn().mockReturnValue(params.isDm === true),
|
|
16
28
|
},
|
|
17
|
-
getUserId: vi.fn().mockResolvedValue(
|
|
18
|
-
getJoinedRoomMembers: vi.fn().
|
|
29
|
+
getUserId: vi.fn().mockResolvedValue(selfUserId),
|
|
30
|
+
getJoinedRoomMembers: vi.fn().mockImplementation(async (roomId: string) => {
|
|
31
|
+
return membersByRoom[roomId] ?? [];
|
|
32
|
+
}),
|
|
19
33
|
getRoomStateEvent: vi
|
|
20
34
|
.fn()
|
|
21
|
-
.mockImplementation(async (
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
.mockImplementation(async (roomId: string, eventType: string, stateKey: string) => {
|
|
36
|
+
const key = `${roomId}|${eventType}|${stateKey}`;
|
|
37
|
+
const ev = stateEvents[key];
|
|
38
|
+
if (ev === undefined) {
|
|
39
|
+
// Simulate real homeserver M_NOT_FOUND response (matches MatrixError shape)
|
|
40
|
+
const err = new Error(`State event not found: ${key}`) as Error & {
|
|
41
|
+
errcode?: string;
|
|
42
|
+
statusCode?: number;
|
|
43
|
+
};
|
|
44
|
+
err.errcode = "M_NOT_FOUND";
|
|
45
|
+
err.statusCode = 404;
|
|
46
|
+
throw err;
|
|
24
47
|
}
|
|
25
|
-
|
|
26
|
-
return { is_direct: params.selfDirect === true };
|
|
27
|
-
}
|
|
28
|
-
return {};
|
|
48
|
+
return ev;
|
|
29
49
|
}),
|
|
30
|
-
}
|
|
50
|
+
};
|
|
31
51
|
}
|
|
32
52
|
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Tests -- isDirectMessage
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
33
57
|
describe("createDirectRoomTracker", () => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
58
|
+
describe("m.direct detection (SDK DM cache)", () => {
|
|
59
|
+
it("returns true when SDK DM cache marks room as DM", async () => {
|
|
60
|
+
const client = createMockClient({
|
|
61
|
+
dmRooms: { "!dm:example.org": true },
|
|
62
|
+
});
|
|
63
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
64
|
+
|
|
65
|
+
const result = await tracker.isDirectMessage({
|
|
66
|
+
roomId: "!dm:example.org",
|
|
39
67
|
senderId: "@alice:example.org",
|
|
40
|
-
})
|
|
41
|
-
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(result).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns false for rooms not in SDK DM cache (with >2 members)", async () => {
|
|
74
|
+
const client = createMockClient({
|
|
75
|
+
dmRooms: {},
|
|
76
|
+
membersByRoom: {
|
|
77
|
+
"!group:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
81
|
+
|
|
82
|
+
const result = await tracker.isDirectMessage({
|
|
83
|
+
roomId: "!group:example.org",
|
|
84
|
+
senderId: "@alice:example.org",
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result).toBe(false);
|
|
88
|
+
});
|
|
42
89
|
});
|
|
43
90
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
91
|
+
describe("is_direct state flag detection", () => {
|
|
92
|
+
it("returns true when sender's membership has is_direct=true", async () => {
|
|
93
|
+
const client = createMockClient({
|
|
94
|
+
dmRooms: {},
|
|
95
|
+
membersByRoom: { "!room:example.org": ["@alice:example.org", "@bot:example.org"] },
|
|
96
|
+
stateEvents: {
|
|
97
|
+
"!room:example.org|m.room.member|@alice:example.org": { is_direct: true },
|
|
98
|
+
"!room:example.org|m.room.member|@bot:example.org": { is_direct: false },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
102
|
+
|
|
103
|
+
const result = await tracker.isDirectMessage({
|
|
49
104
|
roomId: "!room:example.org",
|
|
50
105
|
senderId: "@alice:example.org",
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("returns true when bot's own membership has is_direct=true", async () => {
|
|
112
|
+
const client = createMockClient({
|
|
113
|
+
dmRooms: {},
|
|
114
|
+
membersByRoom: { "!room:example.org": ["@alice:example.org", "@bot:example.org"] },
|
|
115
|
+
stateEvents: {
|
|
116
|
+
"!room:example.org|m.room.member|@alice:example.org": { is_direct: false },
|
|
117
|
+
"!room:example.org|m.room.member|@bot:example.org": { is_direct: true },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
121
|
+
|
|
122
|
+
const result = await tracker.isDirectMessage({
|
|
123
|
+
roomId: "!room:example.org",
|
|
124
|
+
senderId: "@alice:example.org",
|
|
125
|
+
selfUserId: "@bot:example.org",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result).toBe(true);
|
|
129
|
+
});
|
|
54
130
|
});
|
|
55
131
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
132
|
+
describe("conservative fallback (memberCount + room name)", () => {
|
|
133
|
+
it("returns true for 2-member room WITHOUT a room name (broken flags)", async () => {
|
|
134
|
+
const client = createMockClient({
|
|
135
|
+
dmRooms: {},
|
|
136
|
+
membersByRoom: {
|
|
137
|
+
"!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"],
|
|
138
|
+
},
|
|
139
|
+
stateEvents: {
|
|
140
|
+
// is_direct not set on either member (e.g. Continuwuity bug)
|
|
141
|
+
"!broken-dm:example.org|m.room.member|@alice:example.org": {},
|
|
142
|
+
"!broken-dm:example.org|m.room.member|@bot:example.org": {},
|
|
143
|
+
// No m.room.name -> getRoomStateEvent will throw (event not found)
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
147
|
+
|
|
148
|
+
const result = await tracker.isDirectMessage({
|
|
149
|
+
roomId: "!broken-dm:example.org",
|
|
150
|
+
senderId: "@alice:example.org",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns true for 2-member room with empty room name", async () => {
|
|
157
|
+
const client = createMockClient({
|
|
158
|
+
dmRooms: {},
|
|
159
|
+
membersByRoom: {
|
|
160
|
+
"!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"],
|
|
161
|
+
},
|
|
162
|
+
stateEvents: {
|
|
163
|
+
"!broken-dm:example.org|m.room.member|@alice:example.org": {},
|
|
164
|
+
"!broken-dm:example.org|m.room.member|@bot:example.org": {},
|
|
165
|
+
"!broken-dm:example.org|m.room.name|": { name: "" },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
169
|
+
|
|
170
|
+
const result = await tracker.isDirectMessage({
|
|
171
|
+
roomId: "!broken-dm:example.org",
|
|
172
|
+
senderId: "@alice:example.org",
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(result).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns false for 2-member room WITH a room name (named group)", async () => {
|
|
179
|
+
const client = createMockClient({
|
|
180
|
+
dmRooms: {},
|
|
181
|
+
membersByRoom: {
|
|
182
|
+
"!named-group:example.org": ["@alice:example.org", "@bob:example.org"],
|
|
183
|
+
},
|
|
184
|
+
stateEvents: {
|
|
185
|
+
"!named-group:example.org|m.room.member|@alice:example.org": {},
|
|
186
|
+
"!named-group:example.org|m.room.member|@bob:example.org": {},
|
|
187
|
+
"!named-group:example.org|m.room.name|": { name: "Project Alpha" },
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
191
|
+
|
|
192
|
+
const result = await tracker.isDirectMessage({
|
|
193
|
+
roomId: "!named-group:example.org",
|
|
194
|
+
senderId: "@alice:example.org",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(result).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns false for 3+ member room without any DM signals", async () => {
|
|
201
|
+
const client = createMockClient({
|
|
202
|
+
dmRooms: {},
|
|
203
|
+
membersByRoom: {
|
|
204
|
+
"!group:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
|
205
|
+
},
|
|
206
|
+
stateEvents: {
|
|
207
|
+
"!group:example.org|m.room.member|@alice:example.org": {},
|
|
208
|
+
"!group:example.org|m.room.member|@bob:example.org": {},
|
|
209
|
+
"!group:example.org|m.room.member|@carol:example.org": {},
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
213
|
+
|
|
214
|
+
const result = await tracker.isDirectMessage({
|
|
215
|
+
roomId: "!group:example.org",
|
|
216
|
+
senderId: "@alice:example.org",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(result).toBe(false);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns false for 1-member room (self-chat)", async () => {
|
|
223
|
+
const client = createMockClient({
|
|
224
|
+
dmRooms: {},
|
|
225
|
+
membersByRoom: {
|
|
226
|
+
"!solo:example.org": ["@bot:example.org"],
|
|
227
|
+
},
|
|
228
|
+
stateEvents: {
|
|
229
|
+
"!solo:example.org|m.room.member|@bot:example.org": {},
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
233
|
+
|
|
234
|
+
const result = await tracker.isDirectMessage({
|
|
235
|
+
roomId: "!solo:example.org",
|
|
236
|
+
senderId: "@bot:example.org",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("detection priority", () => {
|
|
244
|
+
it("m.direct takes priority -- skips state and fallback checks", async () => {
|
|
245
|
+
const client = createMockClient({
|
|
246
|
+
dmRooms: { "!dm:example.org": true },
|
|
247
|
+
membersByRoom: {
|
|
248
|
+
"!dm:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
|
249
|
+
},
|
|
250
|
+
stateEvents: {
|
|
251
|
+
"!dm:example.org|m.room.name|": { name: "Named Room" },
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
255
|
+
|
|
256
|
+
const result = await tracker.isDirectMessage({
|
|
257
|
+
roomId: "!dm:example.org",
|
|
258
|
+
senderId: "@alice:example.org",
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result).toBe(true);
|
|
262
|
+
// Should not have checked member state or room name
|
|
263
|
+
expect(client.getRoomStateEvent).not.toHaveBeenCalled();
|
|
264
|
+
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("is_direct takes priority over fallback -- skips member count", async () => {
|
|
268
|
+
const client = createMockClient({
|
|
269
|
+
dmRooms: {},
|
|
270
|
+
stateEvents: {
|
|
271
|
+
"!room:example.org|m.room.member|@alice:example.org": { is_direct: true },
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
275
|
+
|
|
276
|
+
const result = await tracker.isDirectMessage({
|
|
60
277
|
roomId: "!room:example.org",
|
|
61
278
|
senderId: "@alice:example.org",
|
|
62
|
-
})
|
|
63
|
-
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(result).toBe(true);
|
|
282
|
+
// Should not have checked member count
|
|
283
|
+
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("edge cases", () => {
|
|
288
|
+
it("handles member count API failure gracefully", async () => {
|
|
289
|
+
const client = createMockClient({
|
|
290
|
+
dmRooms: {},
|
|
291
|
+
stateEvents: {
|
|
292
|
+
"!failing:example.org|m.room.member|@alice:example.org": {},
|
|
293
|
+
"!failing:example.org|m.room.member|@bot:example.org": {},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
client.getJoinedRoomMembers.mockRejectedValue(new Error("API unavailable"));
|
|
297
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
298
|
+
|
|
299
|
+
const result = await tracker.isDirectMessage({
|
|
300
|
+
roomId: "!failing:example.org",
|
|
301
|
+
senderId: "@alice:example.org",
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Cannot determine member count -> conservative: classify as group
|
|
305
|
+
expect(result).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("treats M_NOT_FOUND for room name as no name (DM)", async () => {
|
|
309
|
+
const client = createMockClient({
|
|
310
|
+
dmRooms: {},
|
|
311
|
+
membersByRoom: {
|
|
312
|
+
"!no-name:example.org": ["@alice:example.org", "@bot:example.org"],
|
|
313
|
+
},
|
|
314
|
+
stateEvents: {
|
|
315
|
+
"!no-name:example.org|m.room.member|@alice:example.org": {},
|
|
316
|
+
"!no-name:example.org|m.room.member|@bot:example.org": {},
|
|
317
|
+
// m.room.name not in stateEvents -> mock throws generic Error
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
// Override to throw M_NOT_FOUND like a real homeserver
|
|
321
|
+
const originalImpl = client.getRoomStateEvent.getMockImplementation()!;
|
|
322
|
+
client.getRoomStateEvent.mockImplementation(
|
|
323
|
+
async (roomId: string, eventType: string, stateKey: string) => {
|
|
324
|
+
if (eventType === "m.room.name") {
|
|
325
|
+
const err = new Error("not found") as Error & {
|
|
326
|
+
errcode?: string;
|
|
327
|
+
statusCode?: number;
|
|
328
|
+
};
|
|
329
|
+
err.errcode = "M_NOT_FOUND";
|
|
330
|
+
err.statusCode = 404;
|
|
331
|
+
throw err;
|
|
332
|
+
}
|
|
333
|
+
return originalImpl(roomId, eventType, stateKey);
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
337
|
+
|
|
338
|
+
const result = await tracker.isDirectMessage({
|
|
339
|
+
roomId: "!no-name:example.org",
|
|
340
|
+
senderId: "@alice:example.org",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(result).toBe(true);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("treats non-404 room name errors as unknown (falls through to group)", async () => {
|
|
347
|
+
const client = createMockClient({
|
|
348
|
+
dmRooms: {},
|
|
349
|
+
membersByRoom: {
|
|
350
|
+
"!error-room:example.org": ["@alice:example.org", "@bot:example.org"],
|
|
351
|
+
},
|
|
352
|
+
stateEvents: {
|
|
353
|
+
"!error-room:example.org|m.room.member|@alice:example.org": {},
|
|
354
|
+
"!error-room:example.org|m.room.member|@bot:example.org": {},
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
// Simulate a network/auth error (not M_NOT_FOUND)
|
|
358
|
+
const originalImpl = client.getRoomStateEvent.getMockImplementation()!;
|
|
359
|
+
client.getRoomStateEvent.mockImplementation(
|
|
360
|
+
async (roomId: string, eventType: string, stateKey: string) => {
|
|
361
|
+
if (eventType === "m.room.name") {
|
|
362
|
+
throw new Error("Connection refused");
|
|
363
|
+
}
|
|
364
|
+
return originalImpl(roomId, eventType, stateKey);
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
368
|
+
|
|
369
|
+
const result = await tracker.isDirectMessage({
|
|
370
|
+
roomId: "!error-room:example.org",
|
|
371
|
+
senderId: "@alice:example.org",
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Network error -> don't assume DM, classify as group
|
|
375
|
+
expect(result).toBe(false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("whitespace-only room name is treated as no name", async () => {
|
|
379
|
+
const client = createMockClient({
|
|
380
|
+
dmRooms: {},
|
|
381
|
+
membersByRoom: {
|
|
382
|
+
"!ws-name:example.org": ["@alice:example.org", "@bot:example.org"],
|
|
383
|
+
},
|
|
384
|
+
stateEvents: {
|
|
385
|
+
"!ws-name:example.org|m.room.member|@alice:example.org": {},
|
|
386
|
+
"!ws-name:example.org|m.room.member|@bot:example.org": {},
|
|
387
|
+
"!ws-name:example.org|m.room.name|": { name: " " },
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
const tracker = createDirectRoomTracker(client as never);
|
|
391
|
+
|
|
392
|
+
const result = await tracker.isDirectMessage({
|
|
393
|
+
roomId: "!ws-name:example.org",
|
|
394
|
+
senderId: "@alice:example.org",
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(result).toBe(true);
|
|
398
|
+
});
|
|
64
399
|
});
|
|
65
400
|
});
|
|
@@ -13,14 +13,22 @@ type DirectRoomTrackerOptions = {
|
|
|
13
13
|
|
|
14
14
|
const DM_CACHE_TTL_MS = 30_000;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Check if an error is a Matrix M_NOT_FOUND response (missing state event).
|
|
18
|
+
* The bot-sdk throws MatrixError with errcode/statusCode on the error object.
|
|
19
|
+
*/
|
|
20
|
+
function isMatrixNotFoundError(err: unknown): boolean {
|
|
21
|
+
if (typeof err !== "object" || err === null) return false;
|
|
22
|
+
const e = err as { errcode?: string; statusCode?: number };
|
|
23
|
+
return e.errcode === "M_NOT_FOUND" || e.statusCode === 404;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
|
|
17
27
|
const log = opts.log ?? (() => {});
|
|
18
28
|
const includeMemberCountInLogs = opts.includeMemberCountInLogs === true;
|
|
19
29
|
let lastDmUpdateMs = 0;
|
|
20
30
|
let cachedSelfUserId: string | null = null;
|
|
21
|
-
const memberCountCache =
|
|
22
|
-
? new Map<string, { count: number; ts: number }>()
|
|
23
|
-
: undefined;
|
|
31
|
+
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
|
24
32
|
|
|
25
33
|
const ensureSelfUserId = async (): Promise<string | null> => {
|
|
26
34
|
if (cachedSelfUserId) {
|
|
@@ -48,9 +56,6 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
|
|
48
56
|
};
|
|
49
57
|
|
|
50
58
|
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
|
|
51
|
-
if (!memberCountCache) {
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
59
|
const cached = memberCountCache.get(roomId);
|
|
55
60
|
const now = Date.now();
|
|
56
61
|
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
|
|
@@ -91,7 +96,6 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
|
|
91
96
|
return true;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
// Check m.room.member state for is_direct flag
|
|
95
99
|
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
|
|
96
100
|
const directViaState =
|
|
97
101
|
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
|
|
@@ -100,16 +104,47 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
|
|
100
104
|
return true;
|
|
101
105
|
}
|
|
102
106
|
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
107
|
+
// Conservative fallback: 2-member rooms without an explicit room name are likely
|
|
108
|
+
// DMs with broken m.direct / is_direct flags. This has been observed on Continuwuity
|
|
109
|
+
// where m.direct pointed to the wrong room and is_direct was never set on the invite.
|
|
110
|
+
// Unlike the removed heuristic, this requires two signals (member count + no name)
|
|
111
|
+
// to avoid false positives on named 2-person group rooms.
|
|
112
|
+
//
|
|
113
|
+
// Performance: member count is cached (resolveMemberCount). The room name state
|
|
114
|
+
// check is not cached but only runs for the subset of 2-member rooms that reach
|
|
115
|
+
// this fallback path (no m.direct, no is_direct). In typical deployments this is
|
|
116
|
+
// a small minority of rooms.
|
|
117
|
+
//
|
|
118
|
+
// Note: there is a narrow race where a room name is being set concurrently with
|
|
119
|
+
// this check. The consequence is a one-time misclassification that self-corrects
|
|
120
|
+
// on the next message (once the state event is synced). This is acceptable given
|
|
121
|
+
// the alternative of an additional API call on every message.
|
|
122
|
+
const memberCount = await resolveMemberCount(roomId);
|
|
123
|
+
if (memberCount === 2) {
|
|
124
|
+
try {
|
|
125
|
+
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "");
|
|
126
|
+
if (!nameState?.name?.trim()) {
|
|
127
|
+
log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`);
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
} catch (err: unknown) {
|
|
131
|
+
// Missing state events (M_NOT_FOUND) are expected for unnamed rooms and
|
|
132
|
+
// strongly indicate a DM. Any other error (network, auth) is ambiguous,
|
|
133
|
+
// so we fall through to classify as group rather than guess.
|
|
134
|
+
if (isMatrixNotFoundError(err)) {
|
|
135
|
+
log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
log(
|
|
139
|
+
`matrix: dm fallback skipped (room name check failed: ${String(err)}) room=${roomId}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
108
144
|
if (!includeMemberCountInLogs) {
|
|
109
145
|
log(`matrix: dm check room=${roomId} result=group`);
|
|
110
146
|
return false;
|
|
111
147
|
}
|
|
112
|
-
const memberCount = await resolveMemberCount(roomId);
|
|
113
148
|
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
|
|
114
149
|
return false;
|
|
115
150
|
},
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
2
|
import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createMatrixRoomMessageHandler,
|
|
6
|
+
resolveMatrixBaseRouteSession,
|
|
7
|
+
shouldOverrideMatrixDmToGroup,
|
|
8
|
+
} from "./handler.js";
|
|
5
9
|
import { EventType, type MatrixRawEvent } from "./types.js";
|
|
6
10
|
|
|
7
11
|
describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
|
|
@@ -18,8 +22,15 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
|
|
|
18
22
|
channel: {
|
|
19
23
|
pairing: {
|
|
20
24
|
readAllowFromStore: vi.fn().mockResolvedValue([]),
|
|
25
|
+
upsertPairingRequest: vi.fn().mockResolvedValue(undefined),
|
|
21
26
|
},
|
|
22
27
|
routing: {
|
|
28
|
+
buildAgentSessionKey: vi
|
|
29
|
+
.fn()
|
|
30
|
+
.mockImplementation(
|
|
31
|
+
(params: { agentId: string; channel: string; peer?: { kind: string; id: string } }) =>
|
|
32
|
+
`agent:${params.agentId}:${params.channel}:${params.peer?.kind ?? "direct"}:${params.peer?.id ?? "unknown"}`,
|
|
33
|
+
),
|
|
23
34
|
resolveAgentRoute: vi.fn().mockReturnValue({
|
|
24
35
|
agentId: "main",
|
|
25
36
|
accountId: undefined,
|
|
@@ -139,4 +150,47 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
|
|
|
139
150
|
}),
|
|
140
151
|
);
|
|
141
152
|
});
|
|
153
|
+
|
|
154
|
+
it("uses room-scoped session keys for DM rooms matched via parentPeer binding", () => {
|
|
155
|
+
const buildAgentSessionKey = vi
|
|
156
|
+
.fn()
|
|
157
|
+
.mockReturnValue("agent:main:matrix:channel:!dmroom:example.org");
|
|
158
|
+
|
|
159
|
+
const resolved = resolveMatrixBaseRouteSession({
|
|
160
|
+
buildAgentSessionKey,
|
|
161
|
+
baseRoute: {
|
|
162
|
+
agentId: "main",
|
|
163
|
+
sessionKey: "agent:main:main",
|
|
164
|
+
mainSessionKey: "agent:main:main",
|
|
165
|
+
matchedBy: "binding.peer.parent",
|
|
166
|
+
},
|
|
167
|
+
isDirectMessage: true,
|
|
168
|
+
roomId: "!dmroom:example.org",
|
|
169
|
+
accountId: undefined,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(buildAgentSessionKey).toHaveBeenCalledWith({
|
|
173
|
+
agentId: "main",
|
|
174
|
+
channel: "matrix",
|
|
175
|
+
accountId: undefined,
|
|
176
|
+
peer: { kind: "channel", id: "!dmroom:example.org" },
|
|
177
|
+
});
|
|
178
|
+
expect(resolved).toEqual({
|
|
179
|
+
sessionKey: "agent:main:matrix:channel:!dmroom:example.org",
|
|
180
|
+
lastRoutePolicy: "session",
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does not override DMs to groups for explicit allow:false room config", () => {
|
|
185
|
+
expect(
|
|
186
|
+
shouldOverrideMatrixDmToGroup({
|
|
187
|
+
isDirectMessage: true,
|
|
188
|
+
roomConfigInfo: {
|
|
189
|
+
config: { allow: false },
|
|
190
|
+
allowed: false,
|
|
191
|
+
matchSource: "direct",
|
|
192
|
+
},
|
|
193
|
+
}),
|
|
194
|
+
).toBe(false);
|
|
195
|
+
});
|
|
142
196
|
});
|
|
@@ -77,6 +77,56 @@ export type MatrixMonitorHandlerParams = {
|
|
|
77
77
|
accountId?: string | null;
|
|
78
78
|
};
|
|
79
79
|
|
|
80
|
+
export function resolveMatrixBaseRouteSession(params: {
|
|
81
|
+
buildAgentSessionKey: (params: {
|
|
82
|
+
agentId: string;
|
|
83
|
+
channel: string;
|
|
84
|
+
accountId?: string | null;
|
|
85
|
+
peer?: { kind: "direct" | "channel"; id: string } | null;
|
|
86
|
+
}) => string;
|
|
87
|
+
baseRoute: {
|
|
88
|
+
agentId: string;
|
|
89
|
+
sessionKey: string;
|
|
90
|
+
mainSessionKey: string;
|
|
91
|
+
matchedBy?: string;
|
|
92
|
+
};
|
|
93
|
+
isDirectMessage: boolean;
|
|
94
|
+
roomId: string;
|
|
95
|
+
accountId?: string | null;
|
|
96
|
+
}): { sessionKey: string; lastRoutePolicy: "main" | "session" } {
|
|
97
|
+
const sessionKey =
|
|
98
|
+
params.isDirectMessage && params.baseRoute.matchedBy === "binding.peer.parent"
|
|
99
|
+
? params.buildAgentSessionKey({
|
|
100
|
+
agentId: params.baseRoute.agentId,
|
|
101
|
+
channel: "matrix",
|
|
102
|
+
accountId: params.accountId,
|
|
103
|
+
peer: { kind: "channel", id: params.roomId },
|
|
104
|
+
})
|
|
105
|
+
: params.baseRoute.sessionKey;
|
|
106
|
+
return {
|
|
107
|
+
sessionKey,
|
|
108
|
+
lastRoutePolicy: sessionKey === params.baseRoute.mainSessionKey ? "main" : "session",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function shouldOverrideMatrixDmToGroup(params: {
|
|
113
|
+
isDirectMessage: boolean;
|
|
114
|
+
roomConfigInfo?:
|
|
115
|
+
| {
|
|
116
|
+
config?: MatrixRoomConfig;
|
|
117
|
+
allowed: boolean;
|
|
118
|
+
matchSource?: string;
|
|
119
|
+
}
|
|
120
|
+
| undefined;
|
|
121
|
+
}): boolean {
|
|
122
|
+
return (
|
|
123
|
+
params.isDirectMessage === true &&
|
|
124
|
+
params.roomConfigInfo?.config !== undefined &&
|
|
125
|
+
params.roomConfigInfo.allowed === true &&
|
|
126
|
+
params.roomConfigInfo.matchSource === "direct"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
80
130
|
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
|
|
81
131
|
const {
|
|
82
132
|
client,
|
|
@@ -188,22 +238,37 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|
|
188
238
|
}
|
|
189
239
|
}
|
|
190
240
|
|
|
191
|
-
|
|
241
|
+
let isDirectMessage = await directTracker.isDirectMessage({
|
|
192
242
|
roomId,
|
|
193
243
|
senderId,
|
|
194
244
|
selfUserId,
|
|
195
245
|
});
|
|
246
|
+
|
|
247
|
+
// Resolve room config early so explicitly configured rooms can override DM classification.
|
|
248
|
+
// This ensures rooms in the groups config are always treated as groups regardless of
|
|
249
|
+
// member count or protocol-level DM flags. Only explicit matches (not wildcards) trigger
|
|
250
|
+
// the override to avoid breaking DM routing when a wildcard entry exists. (See #9106)
|
|
251
|
+
const roomConfigInfo = resolveMatrixRoomConfig({
|
|
252
|
+
rooms: roomsConfig,
|
|
253
|
+
roomId,
|
|
254
|
+
aliases: roomAliases,
|
|
255
|
+
name: roomName,
|
|
256
|
+
});
|
|
257
|
+
if (shouldOverrideMatrixDmToGroup({ isDirectMessage, roomConfigInfo })) {
|
|
258
|
+
logVerboseMessage(
|
|
259
|
+
`matrix: overriding DM to group for configured room=${roomId} (${roomConfigInfo.matchKey})`,
|
|
260
|
+
);
|
|
261
|
+
isDirectMessage = false;
|
|
262
|
+
}
|
|
263
|
+
|
|
196
264
|
const isRoom = !isDirectMessage;
|
|
197
265
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
})
|
|
205
|
-
: undefined;
|
|
206
|
-
const roomConfig = roomConfigInfo?.config;
|
|
266
|
+
if (isRoom && groupPolicy === "disabled") {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// Only expose room config for confirmed group rooms. DMs should never inherit
|
|
270
|
+
// group settings (skills, systemPrompt, autoReply) even when a wildcard entry exists.
|
|
271
|
+
const roomConfig = isRoom ? roomConfigInfo?.config : undefined;
|
|
207
272
|
const roomMatchMeta = roomConfigInfo
|
|
208
273
|
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
|
|
209
274
|
roomConfigInfo.matchSource ?? "none"
|
|
@@ -435,13 +500,24 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
|
|
435
500
|
kind: isDirectMessage ? "direct" : "channel",
|
|
436
501
|
id: isDirectMessage ? senderId : roomId,
|
|
437
502
|
},
|
|
503
|
+
// For DMs, pass roomId as parentPeer so the conversation is bindable by room ID
|
|
504
|
+
// while preserving DM trust semantics (secure 1:1, no group restrictions).
|
|
505
|
+
parentPeer: isDirectMessage ? { kind: "channel", id: roomId } : undefined,
|
|
506
|
+
});
|
|
507
|
+
const baseRouteSession = resolveMatrixBaseRouteSession({
|
|
508
|
+
buildAgentSessionKey: core.channel.routing.buildAgentSessionKey,
|
|
509
|
+
baseRoute,
|
|
510
|
+
isDirectMessage,
|
|
511
|
+
roomId,
|
|
512
|
+
accountId,
|
|
438
513
|
});
|
|
439
514
|
|
|
440
515
|
const route = {
|
|
441
516
|
...baseRoute,
|
|
517
|
+
lastRoutePolicy: baseRouteSession.lastRoutePolicy,
|
|
442
518
|
sessionKey: threadRootId
|
|
443
|
-
? `${
|
|
444
|
-
:
|
|
519
|
+
? `${baseRouteSession.sessionKey}:thread:${threadRootId}`
|
|
520
|
+
: baseRouteSession.sessionKey,
|
|
445
521
|
};
|
|
446
522
|
|
|
447
523
|
let threadStarterBody: string | undefined;
|
|
@@ -36,4 +36,89 @@ describe("resolveMatrixRoomConfig", () => {
|
|
|
36
36
|
expect(byName.allowed).toBe(false);
|
|
37
37
|
expect(byName.config).toBeUndefined();
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
describe("matchSource classification", () => {
|
|
41
|
+
it('returns matchSource="direct" for exact room ID match', () => {
|
|
42
|
+
const result = resolveMatrixRoomConfig({
|
|
43
|
+
rooms: { "!room:example.org": { allow: true } },
|
|
44
|
+
roomId: "!room:example.org",
|
|
45
|
+
aliases: [],
|
|
46
|
+
});
|
|
47
|
+
expect(result.matchSource).toBe("direct");
|
|
48
|
+
expect(result.config).toBeDefined();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('returns matchSource="direct" for alias match', () => {
|
|
52
|
+
const result = resolveMatrixRoomConfig({
|
|
53
|
+
rooms: { "#alias:example.org": { allow: true } },
|
|
54
|
+
roomId: "!room:example.org",
|
|
55
|
+
aliases: ["#alias:example.org"],
|
|
56
|
+
});
|
|
57
|
+
expect(result.matchSource).toBe("direct");
|
|
58
|
+
expect(result.config).toBeDefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns matchSource="wildcard" for wildcard match', () => {
|
|
62
|
+
const result = resolveMatrixRoomConfig({
|
|
63
|
+
rooms: { "*": { allow: true } },
|
|
64
|
+
roomId: "!any:example.org",
|
|
65
|
+
aliases: [],
|
|
66
|
+
});
|
|
67
|
+
expect(result.matchSource).toBe("wildcard");
|
|
68
|
+
expect(result.config).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns undefined matchSource when no match", () => {
|
|
72
|
+
const result = resolveMatrixRoomConfig({
|
|
73
|
+
rooms: { "!other:example.org": { allow: true } },
|
|
74
|
+
roomId: "!room:example.org",
|
|
75
|
+
aliases: [],
|
|
76
|
+
});
|
|
77
|
+
expect(result.matchSource).toBeUndefined();
|
|
78
|
+
expect(result.config).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("direct match takes priority over wildcard", () => {
|
|
82
|
+
const result = resolveMatrixRoomConfig({
|
|
83
|
+
rooms: {
|
|
84
|
+
"!room:example.org": { allow: true, systemPrompt: "room-specific" },
|
|
85
|
+
"*": { allow: true, systemPrompt: "generic" },
|
|
86
|
+
},
|
|
87
|
+
roomId: "!room:example.org",
|
|
88
|
+
aliases: [],
|
|
89
|
+
});
|
|
90
|
+
expect(result.matchSource).toBe("direct");
|
|
91
|
+
expect(result.config?.systemPrompt).toBe("room-specific");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("DM override safety (matchSource distinction)", () => {
|
|
96
|
+
// These tests verify the matchSource property that handler.ts uses
|
|
97
|
+
// to decide whether a configured room should override DM classification.
|
|
98
|
+
// Only "direct" matches should trigger the override -- never "wildcard".
|
|
99
|
+
|
|
100
|
+
it("wildcard config should NOT be usable to override DM classification", () => {
|
|
101
|
+
const result = resolveMatrixRoomConfig({
|
|
102
|
+
rooms: { "*": { allow: true, skills: ["general"] } },
|
|
103
|
+
roomId: "!dm-room:example.org",
|
|
104
|
+
aliases: [],
|
|
105
|
+
});
|
|
106
|
+
// handler.ts checks: matchSource === "direct" -> this is "wildcard", so no override
|
|
107
|
+
expect(result.matchSource).not.toBe("direct");
|
|
108
|
+
expect(result.matchSource).toBe("wildcard");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("explicitly configured room should be usable to override DM classification", () => {
|
|
112
|
+
const result = resolveMatrixRoomConfig({
|
|
113
|
+
rooms: {
|
|
114
|
+
"!configured-room:example.org": { allow: true },
|
|
115
|
+
"*": { allow: true },
|
|
116
|
+
},
|
|
117
|
+
roomId: "!configured-room:example.org",
|
|
118
|
+
aliases: [],
|
|
119
|
+
});
|
|
120
|
+
// handler.ts checks: matchSource === "direct" -> this IS "direct", so override is safe
|
|
121
|
+
expect(result.matchSource).toBe("direct");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
39
124
|
});
|
package/src/runtime.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
|
1
2
|
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
runtime = next;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getMatrixRuntime(): PluginRuntime {
|
|
10
|
-
if (!runtime) {
|
|
11
|
-
throw new Error("Matrix runtime not initialized");
|
|
12
|
-
}
|
|
13
|
-
return runtime;
|
|
14
|
-
}
|
|
4
|
+
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
|
|
5
|
+
createPluginRuntimeStore<PluginRuntime>("Matrix runtime not initialized");
|
|
6
|
+
export { getMatrixRuntime, setMatrixRuntime };
|