@openclaw/matrix 2026.3.2 → 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 +24 -0
- package/index.ts +2 -2
- package/package.json +9 -1
- package/src/actions.ts +1 -1
- package/src/channel.directory.test.ts +1 -1
- package/src/channel.ts +40 -46
- package/src/config-schema.ts +1 -1
- package/src/directory-live.ts +1 -1
- package/src/group-mentions.ts +1 -1
- package/src/matrix/accounts.ts +7 -43
- package/src/matrix/client/config.ts +1 -1
- package/src/matrix/deps.ts +1 -1
- package/src/matrix/monitor/access-policy.ts +6 -7
- package/src/matrix/monitor/allowlist.ts +6 -2
- package/src/matrix/monitor/auto-join.ts +1 -1
- package/src/matrix/monitor/direct.test.ts +374 -39
- package/src/matrix/monitor/direct.ts +48 -13
- package/src/matrix/monitor/events.test.ts +1 -1
- package/src/matrix/monitor/events.ts +1 -1
- package/src/matrix/monitor/handler.body-for-agent.test.ts +56 -2
- package/src/matrix/monitor/handler.ts +118 -45
- package/src/matrix/monitor/index.ts +6 -7
- package/src/matrix/monitor/location.ts +1 -1
- package/src/matrix/monitor/media.test.ts +1 -1
- package/src/matrix/monitor/replies.test.ts +1 -1
- package/src/matrix/monitor/replies.ts +1 -1
- package/src/matrix/monitor/rooms.test.ts +85 -0
- package/src/matrix/monitor/rooms.ts +1 -1
- package/src/matrix/poll-types.ts +1 -1
- package/src/matrix/probe.ts +1 -1
- package/src/matrix/send/client.ts +8 -6
- package/src/matrix/send/types.ts +1 -0
- package/src/matrix/send.test.ts +86 -2
- package/src/matrix/send.ts +6 -4
- package/src/onboarding.ts +19 -16
- package/src/outbound.test.ts +159 -0
- package/src/outbound.ts +7 -4
- package/src/resolve-targets.test.ts +1 -1
- package/src/resolve-targets.ts +39 -40
- package/src/runtime.ts +5 -13
- package/src/secret-input.ts +8 -14
- package/src/tool-actions.ts +1 -1
- package/src/types.ts +1 -1
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
-
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import type { MatrixAuth } from "../client.js";
|
|
5
5
|
import { registerMatrixMonitorEvents } from "./events.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
-
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
|
|
3
3
|
import type { MatrixAuth } from "../client.js";
|
|
4
4
|
import { sendReadReceiptMatrix } from "../send.js";
|
|
5
5
|
import type { MatrixRawEvent } from "./types.js";
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
|
2
|
-
import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk";
|
|
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
|
});
|