@openclaw/matrix 2026.3.12 → 2026.5.9-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 (205) hide show
  1. package/dist/account-config-D2W-V1eQ.js +96 -0
  2. package/dist/account-selection-BWwIruri.js +158 -0
  3. package/dist/accounts-Bm90Rzvp.js +130 -0
  4. package/dist/active-client-uhlxdhEy.js +20 -0
  5. package/dist/allowlist-sTzpCn5d.js +68 -0
  6. package/dist/api.js +12 -0
  7. package/dist/approval-handler.runtime-DWTQfd4m.js +370 -0
  8. package/dist/approval-ids-DoC2z7tR.js +7 -0
  9. package/dist/approval-reaction-auth-DbcA1gGd.js +27 -0
  10. package/dist/approval-reactions-o2_tuH8D.js +162 -0
  11. package/dist/async-lock-uQfhfQIY.js +19 -0
  12. package/dist/auth-presence.js +26 -0
  13. package/dist/backup-health-Cabu_WQC.js +60 -0
  14. package/dist/channel-DJNir3Rb.js +1116 -0
  15. package/dist/channel-plugin-api.js +2 -0
  16. package/dist/channel.runtime-BQu0hTih.js +246 -0
  17. package/dist/cli-BmfTmg7x.js +1340 -0
  18. package/dist/cli-metadata-B-PCEzrA.js +22 -0
  19. package/dist/cli-metadata.js +2 -0
  20. package/dist/client-DkcXnm0X.js +25 -0
  21. package/dist/client-_hckQNGW.js +31 -0
  22. package/dist/client-bootstrap-Rb8oHvhH.js +114 -0
  23. package/dist/config--5-S2Akv.js +452 -0
  24. package/dist/config-paths-nsVaysCu.js +19 -0
  25. package/dist/config-schema-nPLpEgHl.js +200 -0
  26. package/dist/config-secret-input.runtime-DiKFehsE.js +2 -0
  27. package/dist/config-update-wZX-HLMn.js +143 -0
  28. package/dist/contract-api.js +9 -0
  29. package/dist/create-client-DCnqDaqd.js +64 -0
  30. package/dist/credentials-DV6fWXhC.js +56 -0
  31. package/dist/credentials-read-cmHgousK.js +112 -0
  32. package/dist/credentials-write.runtime-zniTq-Gr.js +17 -0
  33. package/dist/crypto-node.runtime-pihzdpY7.js +12 -0
  34. package/dist/crypto-runtime-ZI0zAtn3.js +1214 -0
  35. package/dist/deps-C6WqKY7m.js +235 -0
  36. package/dist/device-health-UVYpbA_W.js +16 -0
  37. package/dist/direct-management-DMMMgtTB.js +249 -0
  38. package/dist/direct-room-XkutHjES.js +76 -0
  39. package/dist/directory-live-DmOtMhyr.js +150 -0
  40. package/dist/doctor-C4__7c-U.js +153 -0
  41. package/dist/doctor-contract-D4-64QuJ.js +246 -0
  42. package/dist/doctor-contract-api.js +2 -0
  43. package/dist/draft-stream-BE2QevQQ.js +144 -0
  44. package/dist/encryption-guidance-BPi3A_m3.js +15 -0
  45. package/dist/env-auth-BJqGI8M6.js +63 -0
  46. package/dist/env-vars-C7uQCTKn.js +63 -0
  47. package/dist/errors-CTcpEDq-.js +17 -0
  48. package/dist/exec-approval-resolver-Bza9Dhlm.js +15 -0
  49. package/dist/exec-approvals-Crnh543m.js +196 -0
  50. package/dist/helper-api.js +4 -0
  51. package/dist/http-client-C7AeVJay.js +319 -0
  52. package/dist/index.js +46 -0
  53. package/dist/legacy-crypto-inspector-poDWldgy.js +41 -0
  54. package/dist/legacy-crypto-restore-Biw-w2ng.js +85 -0
  55. package/dist/logger-CnZRVrux.js +78 -0
  56. package/dist/logging-DZHSPP5N.js +99 -0
  57. package/dist/matrix-migration.runtime-WY6ffcrf.js +525 -0
  58. package/dist/media-text-DU6nWZuj.js +146 -0
  59. package/dist/messages-BpihMh82.js +140 -0
  60. package/dist/migration-snapshot-backup-DaCHTp8C.js +69 -0
  61. package/dist/migration-snapshot.runtime-CKHE3xF9.js +2 -0
  62. package/dist/monitor-C_81r_Ck.js +4125 -0
  63. package/dist/plugin-entry.handlers.runtime.js +51 -0
  64. package/dist/probe.runtime-BvAzYAIe.js +3 -0
  65. package/dist/profile-BlHu0wDX.js +111 -0
  66. package/dist/profile-update-DjeBNgIV.js +69 -0
  67. package/dist/reaction-common-ejrL19w-.js +71 -0
  68. package/dist/reaction-events-CiARZfjk.js +121 -0
  69. package/dist/record-shared-CHWJCTWf.js +2 -0
  70. package/dist/recovery-key-store-BTJ6jz5v.js +294 -0
  71. package/dist/resolve-targets-YtJnw1Tb.js +140 -0
  72. package/dist/resolver.runtime-D9piiGEl.js +5 -0
  73. package/dist/rolldown-runtime-DUslC3ob.js +14 -0
  74. package/dist/route-D6rg-iXN.js +161 -0
  75. package/dist/runtime-C6X4h_SJ.js +6 -0
  76. package/dist/runtime-Dog86njy.js +8 -0
  77. package/dist/runtime-api-BXWBFIqm.js +25 -0
  78. package/dist/runtime-api.js +25 -0
  79. package/dist/runtime-heavy-api.js +3 -0
  80. package/dist/runtime-setter-api.js +2 -0
  81. package/dist/sdk-B2vZA27-.js +1416 -0
  82. package/dist/secret-contract-DcrJWCQI.js +120 -0
  83. package/dist/secret-contract-api.js +2 -0
  84. package/dist/send-Bo0DU1ca.js +1200 -0
  85. package/dist/session-store-metadata-DI5SCofx.js +77 -0
  86. package/dist/setup-bootstrap-ImenBsMt.js +62 -0
  87. package/dist/setup-core-CfZy05oW.js +116 -0
  88. package/dist/setup-dm-policy-2-r1FrQh.js +194 -0
  89. package/dist/setup-entry.js +19 -0
  90. package/dist/setup-plugin-api.js +44 -0
  91. package/dist/setup-surface-CqT_o61M.js +540 -0
  92. package/dist/shared-CpMoYKm1.js +195 -0
  93. package/dist/startup-abort-56edvmbM.js +32 -0
  94. package/dist/startup-verification-Demyp0bP.js +132 -0
  95. package/dist/storage-paths-BJLdnCjV.js +52 -0
  96. package/dist/storage-tC3ujLiW.js +281 -0
  97. package/dist/subagent-hooks-DQbyqq9V.js +149 -0
  98. package/dist/subagent-hooks-api.js +23 -0
  99. package/dist/sync-state-C_beeevA.js +12 -0
  100. package/dist/target-ids-80nQ2gql.js +77 -0
  101. package/dist/test-api.js +4 -0
  102. package/dist/thread-binding-api-Cq_E-E1K.js +17 -0
  103. package/dist/thread-binding-api.js +2 -0
  104. package/dist/thread-bindings-B9mesxXk.js +352 -0
  105. package/dist/thread-bindings-runtime.js +2 -0
  106. package/dist/thread-bindings-shared-DK-d-oYX.js +97 -0
  107. package/dist/timeout-abort-signal-CtaIaP1v.js +2 -0
  108. package/dist/tool-actions.runtime-BIH49vRr.js +532 -0
  109. package/dist/url-validation-DiK9j7jz.js +36 -0
  110. package/dist/verification-CZ2rDeHL.js +345 -0
  111. package/openclaw.plugin.json +788 -1
  112. package/package.json +82 -16
  113. package/CHANGELOG.md +0 -98
  114. package/index.ts +0 -22
  115. package/src/actions.ts +0 -195
  116. package/src/channel.directory.test.ts +0 -154
  117. package/src/channel.ts +0 -461
  118. package/src/config-schema.test.ts +0 -26
  119. package/src/config-schema.ts +0 -62
  120. package/src/directory-live.test.ts +0 -85
  121. package/src/directory-live.ts +0 -209
  122. package/src/group-mentions.ts +0 -52
  123. package/src/matrix/accounts.test.ts +0 -131
  124. package/src/matrix/accounts.ts +0 -114
  125. package/src/matrix/actions/client.ts +0 -47
  126. package/src/matrix/actions/limits.test.ts +0 -15
  127. package/src/matrix/actions/limits.ts +0 -6
  128. package/src/matrix/actions/messages.ts +0 -126
  129. package/src/matrix/actions/pins.test.ts +0 -74
  130. package/src/matrix/actions/pins.ts +0 -84
  131. package/src/matrix/actions/reactions.test.ts +0 -109
  132. package/src/matrix/actions/reactions.ts +0 -102
  133. package/src/matrix/actions/room.ts +0 -85
  134. package/src/matrix/actions/summary.ts +0 -75
  135. package/src/matrix/actions/types.ts +0 -85
  136. package/src/matrix/actions.ts +0 -15
  137. package/src/matrix/active-client.ts +0 -32
  138. package/src/matrix/client/config.ts +0 -245
  139. package/src/matrix/client/create-client.ts +0 -125
  140. package/src/matrix/client/logging.ts +0 -46
  141. package/src/matrix/client/runtime.ts +0 -4
  142. package/src/matrix/client/shared.test.ts +0 -85
  143. package/src/matrix/client/shared.ts +0 -210
  144. package/src/matrix/client/startup.test.ts +0 -49
  145. package/src/matrix/client/startup.ts +0 -29
  146. package/src/matrix/client/storage.ts +0 -131
  147. package/src/matrix/client/types.ts +0 -34
  148. package/src/matrix/client-bootstrap.ts +0 -47
  149. package/src/matrix/client.test.ts +0 -56
  150. package/src/matrix/client.ts +0 -14
  151. package/src/matrix/credentials.ts +0 -125
  152. package/src/matrix/deps.test.ts +0 -74
  153. package/src/matrix/deps.ts +0 -126
  154. package/src/matrix/format.test.ts +0 -33
  155. package/src/matrix/format.ts +0 -22
  156. package/src/matrix/index.ts +0 -11
  157. package/src/matrix/monitor/access-policy.ts +0 -126
  158. package/src/matrix/monitor/allowlist.test.ts +0 -45
  159. package/src/matrix/monitor/allowlist.ts +0 -100
  160. package/src/matrix/monitor/auto-join.ts +0 -72
  161. package/src/matrix/monitor/direct.test.ts +0 -400
  162. package/src/matrix/monitor/direct.ts +0 -152
  163. package/src/matrix/monitor/events.test.ts +0 -172
  164. package/src/matrix/monitor/events.ts +0 -168
  165. package/src/matrix/monitor/handler.body-for-agent.test.ts +0 -196
  166. package/src/matrix/monitor/handler.ts +0 -767
  167. package/src/matrix/monitor/inbound-body.test.ts +0 -73
  168. package/src/matrix/monitor/inbound-body.ts +0 -28
  169. package/src/matrix/monitor/index.test.ts +0 -18
  170. package/src/matrix/monitor/index.ts +0 -414
  171. package/src/matrix/monitor/location.ts +0 -100
  172. package/src/matrix/monitor/media.test.ts +0 -86
  173. package/src/matrix/monitor/media.ts +0 -118
  174. package/src/matrix/monitor/mentions.test.ts +0 -154
  175. package/src/matrix/monitor/mentions.ts +0 -62
  176. package/src/matrix/monitor/replies.test.ts +0 -184
  177. package/src/matrix/monitor/replies.ts +0 -124
  178. package/src/matrix/monitor/room-info.ts +0 -55
  179. package/src/matrix/monitor/rooms.test.ts +0 -124
  180. package/src/matrix/monitor/rooms.ts +0 -47
  181. package/src/matrix/monitor/threads.ts +0 -68
  182. package/src/matrix/monitor/types.ts +0 -39
  183. package/src/matrix/poll-types.test.ts +0 -21
  184. package/src/matrix/poll-types.ts +0 -167
  185. package/src/matrix/probe.ts +0 -69
  186. package/src/matrix/sdk-runtime.ts +0 -18
  187. package/src/matrix/send/client.ts +0 -99
  188. package/src/matrix/send/formatting.ts +0 -93
  189. package/src/matrix/send/media.ts +0 -230
  190. package/src/matrix/send/targets.test.ts +0 -98
  191. package/src/matrix/send/targets.ts +0 -150
  192. package/src/matrix/send/types.ts +0 -110
  193. package/src/matrix/send-queue.test.ts +0 -154
  194. package/src/matrix/send-queue.ts +0 -28
  195. package/src/matrix/send.test.ts +0 -326
  196. package/src/matrix/send.ts +0 -267
  197. package/src/onboarding.ts +0 -462
  198. package/src/outbound.test.ts +0 -159
  199. package/src/outbound.ts +0 -58
  200. package/src/resolve-targets.test.ts +0 -67
  201. package/src/resolve-targets.ts +0 -125
  202. package/src/runtime.ts +0 -6
  203. package/src/secret-input.ts +0 -13
  204. package/src/tool-actions.ts +0 -164
  205. package/src/types.ts +0 -118
@@ -1,400 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { createDirectRoomTracker } from "./direct.js";
3
-
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;
16
- }) {
17
- const {
18
- dmRooms = {},
19
- membersByRoom = {},
20
- stateEvents = {},
21
- selfUserId = "@bot:example.org",
22
- } = opts;
23
-
24
- return {
25
- dms: {
26
- isDm: (roomId: string) => dmRooms[roomId] ?? false,
27
- update: vi.fn().mockResolvedValue(undefined),
28
- },
29
- getUserId: vi.fn().mockResolvedValue(selfUserId),
30
- getJoinedRoomMembers: vi.fn().mockImplementation(async (roomId: string) => {
31
- return membersByRoom[roomId] ?? [];
32
- }),
33
- getRoomStateEvent: vi
34
- .fn()
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;
47
- }
48
- return ev;
49
- }),
50
- };
51
- }
52
-
53
- // ---------------------------------------------------------------------------
54
- // Tests -- isDirectMessage
55
- // ---------------------------------------------------------------------------
56
-
57
- describe("createDirectRoomTracker", () => {
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",
67
- senderId: "@alice:example.org",
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
- });
89
- });
90
-
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({
104
- roomId: "!room:example.org",
105
- senderId: "@alice:example.org",
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
- });
130
- });
131
-
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({
277
- roomId: "!room:example.org",
278
- senderId: "@alice:example.org",
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
- });
399
- });
400
- });
@@ -1,152 +0,0 @@
1
- import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
2
-
3
- type DirectMessageCheck = {
4
- roomId: string;
5
- senderId?: string;
6
- selfUserId?: string;
7
- };
8
-
9
- type DirectRoomTrackerOptions = {
10
- log?: (message: string) => void;
11
- includeMemberCountInLogs?: boolean;
12
- };
13
-
14
- const DM_CACHE_TTL_MS = 30_000;
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
-
26
- export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
27
- const log = opts.log ?? (() => {});
28
- const includeMemberCountInLogs = opts.includeMemberCountInLogs === true;
29
- let lastDmUpdateMs = 0;
30
- let cachedSelfUserId: string | null = null;
31
- const memberCountCache = new Map<string, { count: number; ts: number }>();
32
-
33
- const ensureSelfUserId = async (): Promise<string | null> => {
34
- if (cachedSelfUserId) {
35
- return cachedSelfUserId;
36
- }
37
- try {
38
- cachedSelfUserId = await client.getUserId();
39
- } catch {
40
- cachedSelfUserId = null;
41
- }
42
- return cachedSelfUserId;
43
- };
44
-
45
- const refreshDmCache = async (): Promise<void> => {
46
- const now = Date.now();
47
- if (now - lastDmUpdateMs < DM_CACHE_TTL_MS) {
48
- return;
49
- }
50
- lastDmUpdateMs = now;
51
- try {
52
- await client.dms.update();
53
- } catch (err) {
54
- log(`matrix: dm cache refresh failed (${String(err)})`);
55
- }
56
- };
57
-
58
- const resolveMemberCount = async (roomId: string): Promise<number | null> => {
59
- const cached = memberCountCache.get(roomId);
60
- const now = Date.now();
61
- if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
62
- return cached.count;
63
- }
64
- try {
65
- const members = await client.getJoinedRoomMembers(roomId);
66
- const count = members.length;
67
- memberCountCache.set(roomId, { count, ts: now });
68
- return count;
69
- } catch (err) {
70
- log(`matrix: dm member count failed room=${roomId} (${String(err)})`);
71
- return null;
72
- }
73
- };
74
-
75
- const hasDirectFlag = async (roomId: string, userId?: string): Promise<boolean> => {
76
- const target = userId?.trim();
77
- if (!target) {
78
- return false;
79
- }
80
- try {
81
- const state = await client.getRoomStateEvent(roomId, "m.room.member", target);
82
- return state?.is_direct === true;
83
- } catch {
84
- return false;
85
- }
86
- };
87
-
88
- return {
89
- isDirectMessage: async (params: DirectMessageCheck): Promise<boolean> => {
90
- const { roomId, senderId } = params;
91
- await refreshDmCache();
92
-
93
- // Check m.direct account data (most authoritative)
94
- if (client.dms.isDm(roomId)) {
95
- log(`matrix: dm detected via m.direct room=${roomId}`);
96
- return true;
97
- }
98
-
99
- const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
100
- const directViaState =
101
- (await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
102
- if (directViaState) {
103
- log(`matrix: dm detected via member state room=${roomId}`);
104
- return true;
105
- }
106
-
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
-
144
- if (!includeMemberCountInLogs) {
145
- log(`matrix: dm check room=${roomId} result=group`);
146
- return false;
147
- }
148
- log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
149
- return false;
150
- },
151
- };
152
- }