@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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.8-beta.1
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.8
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.3.7
4
16
 
5
17
  ### Changes
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@openclaw/matrix",
3
- "version": "2026.3.7",
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.55.3",
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
- 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,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 { 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
  });
@@ -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
- const isDirectMessage = await directTracker.isDirectMessage({
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
- const roomConfigInfo = isRoom
199
- ? resolveMatrixRoomConfig({
200
- rooms: roomsConfig,
201
- roomId,
202
- aliases: roomAliases,
203
- name: roomName,
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
- ? `${baseRoute.sessionKey}:thread:${threadRootId}`
444
- : baseRoute.sessionKey,
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
- let runtime: PluginRuntime | null = null;
4
-
5
- export function setMatrixRuntime(next: PluginRuntime) {
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 };