@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/index.ts +2 -2
  3. package/package.json +9 -1
  4. package/src/actions.ts +1 -1
  5. package/src/channel.directory.test.ts +1 -1
  6. package/src/channel.ts +40 -46
  7. package/src/config-schema.ts +1 -1
  8. package/src/directory-live.ts +1 -1
  9. package/src/group-mentions.ts +1 -1
  10. package/src/matrix/accounts.ts +7 -43
  11. package/src/matrix/client/config.ts +1 -1
  12. package/src/matrix/deps.ts +1 -1
  13. package/src/matrix/monitor/access-policy.ts +6 -7
  14. package/src/matrix/monitor/allowlist.ts +6 -2
  15. package/src/matrix/monitor/auto-join.ts +1 -1
  16. package/src/matrix/monitor/direct.test.ts +374 -39
  17. package/src/matrix/monitor/direct.ts +48 -13
  18. package/src/matrix/monitor/events.test.ts +1 -1
  19. package/src/matrix/monitor/events.ts +1 -1
  20. package/src/matrix/monitor/handler.body-for-agent.test.ts +56 -2
  21. package/src/matrix/monitor/handler.ts +118 -45
  22. package/src/matrix/monitor/index.ts +6 -7
  23. package/src/matrix/monitor/location.ts +1 -1
  24. package/src/matrix/monitor/media.test.ts +1 -1
  25. package/src/matrix/monitor/replies.test.ts +1 -1
  26. package/src/matrix/monitor/replies.ts +1 -1
  27. package/src/matrix/monitor/rooms.test.ts +85 -0
  28. package/src/matrix/monitor/rooms.ts +1 -1
  29. package/src/matrix/poll-types.ts +1 -1
  30. package/src/matrix/probe.ts +1 -1
  31. package/src/matrix/send/client.ts +8 -6
  32. package/src/matrix/send/types.ts +1 -0
  33. package/src/matrix/send.test.ts +86 -2
  34. package/src/matrix/send.ts +6 -4
  35. package/src/onboarding.ts +19 -16
  36. package/src/outbound.test.ts +159 -0
  37. package/src/outbound.ts +7 -4
  38. package/src/resolve-targets.test.ts +1 -1
  39. package/src/resolve-targets.ts +39 -40
  40. package/src/runtime.ts +5 -13
  41. package/src/secret-input.ts +8 -14
  42. package/src/tool-actions.ts +1 -1
  43. 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
- function createMockClient(params: {
6
- isDm?: boolean;
7
- senderDirect?: boolean;
8
- selfDirect?: boolean;
9
- members?: string[];
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 members = params.members ?? ["@alice:example.org", "@bot:example.org"];
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("@bot:example.org"),
18
- getJoinedRoomMembers: vi.fn().mockResolvedValue(members),
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 (_roomId: string, _event: string, stateKey: string) => {
22
- if (stateKey === "@alice:example.org") {
23
- return { is_direct: params.senderDirect === true };
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
- if (stateKey === "@bot:example.org") {
26
- return { is_direct: params.selfDirect === true };
27
- }
28
- return {};
48
+ return ev;
29
49
  }),
30
- } as unknown as MatrixClient;
50
+ };
31
51
  }
32
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // Tests -- isDirectMessage
55
+ // ---------------------------------------------------------------------------
56
+
33
57
  describe("createDirectRoomTracker", () => {
34
- it("treats m.direct rooms as DMs", async () => {
35
- const tracker = createDirectRoomTracker(createMockClient({ isDm: true }));
36
- await expect(
37
- tracker.isDirectMessage({
38
- roomId: "!room:example.org",
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
- ).resolves.toBe(true);
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
- it("does not classify 2-member rooms as DMs without direct flags", async () => {
45
- const client = createMockClient({ isDm: false });
46
- const tracker = createDirectRoomTracker(client);
47
- await expect(
48
- tracker.isDirectMessage({
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
- ).resolves.toBe(false);
53
- expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
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
- it("uses is_direct member flags when present", async () => {
57
- const tracker = createDirectRoomTracker(createMockClient({ senderDirect: true }));
58
- await expect(
59
- tracker.isDirectMessage({
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
- ).resolves.toBe(true);
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 = includeMemberCountInLogs
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
- // Member count alone is NOT a reliable DM indicator.
104
- // Explicitly configured group rooms with 2 members (e.g. bot + one user)
105
- // were being misclassified as DMs, causing messages to be routed through
106
- // DM policy instead of group policy and silently dropped.
107
- // See: https://github.com/openclaw/openclaw/issues/20145
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 { createMatrixRoomMessageHandler } from "./handler.js";
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
  });