@openclaw/crabline 0.1.9 → 0.1.11

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 (189) hide show
  1. package/README.md +47 -22
  2. package/assets/crabline-banner.svg +20 -0
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +25 -1
  5. package/dist/src/cli/program.js +579 -82
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +59 -6
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +116 -92
  10. package/dist/src/config/schema.js +179 -73
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.js +9 -2
  13. package/dist/src/core/errors.js.map +1 -1
  14. package/dist/src/core/exit-codes.js.map +1 -1
  15. package/dist/src/core/matcher.js +7 -6
  16. package/dist/src/core/matcher.js.map +1 -1
  17. package/dist/src/core/message-template.js.map +1 -1
  18. package/dist/src/core/nonces.d.ts +1 -0
  19. package/dist/src/core/nonces.js +4 -2
  20. package/dist/src/core/nonces.js.map +1 -1
  21. package/dist/src/core/reporters.d.ts +1 -0
  22. package/dist/src/core/reporters.js +42 -4
  23. package/dist/src/core/reporters.js.map +1 -1
  24. package/dist/src/core/run.d.ts +3 -0
  25. package/dist/src/core/run.js +415 -107
  26. package/dist/src/core/run.js.map +1 -1
  27. package/dist/src/core/safe-regex.d.ts +3 -0
  28. package/dist/src/core/safe-regex.js +21 -0
  29. package/dist/src/core/safe-regex.js.map +1 -0
  30. package/dist/src/index.d.ts +2 -2
  31. package/dist/src/index.js +1 -1
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/openclaw/artifact-generation.d.ts +43 -0
  34. package/dist/src/openclaw/artifact-generation.js +363 -0
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -0
  36. package/dist/src/openclaw/bridges/matrix.js +97 -12
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +32 -10
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/signal.js +55 -18
  41. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  42. package/dist/src/openclaw/bridges/slack.js +85 -7
  43. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  44. package/dist/src/openclaw/bridges/telegram.js +124 -30
  45. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  46. package/dist/src/openclaw/bridges/whatsapp.js +78 -17
  47. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  48. package/dist/src/openclaw/bridges/zalo.js +14 -5
  49. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  50. package/dist/src/openclaw/outbound-contract.d.ts +5 -0
  51. package/dist/src/openclaw/outbound-contract.js +40 -0
  52. package/dist/src/openclaw/outbound-contract.js.map +1 -0
  53. package/dist/src/openclaw/private-file.d.ts +28 -0
  54. package/dist/src/openclaw/private-file.js +465 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +21 -2
  57. package/dist/src/openclaw/shared.js +115 -18
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +63 -0
  60. package/dist/src/openclaw/smoke-lock.js +1004 -0
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -0
  62. package/dist/src/openclaw.d.ts +16 -8
  63. package/dist/src/openclaw.js +334 -52
  64. package/dist/src/openclaw.js.map +1 -1
  65. package/dist/src/providers/builtin/discord.d.ts +10 -1
  66. package/dist/src/providers/builtin/discord.js +144 -18
  67. package/dist/src/providers/builtin/discord.js.map +1 -1
  68. package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
  69. package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
  70. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
  71. package/dist/src/providers/builtin/feishu.d.ts +19 -2
  72. package/dist/src/providers/builtin/feishu.js +307 -25
  73. package/dist/src/providers/builtin/feishu.js.map +1 -1
  74. package/dist/src/providers/builtin/googlechat.d.ts +20 -1
  75. package/dist/src/providers/builtin/googlechat.js +178 -19
  76. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  77. package/dist/src/providers/builtin/imessage.d.ts +4 -0
  78. package/dist/src/providers/builtin/imessage.js +26 -11
  79. package/dist/src/providers/builtin/imessage.js.map +1 -1
  80. package/dist/src/providers/builtin/loopback.js +92 -30
  81. package/dist/src/providers/builtin/loopback.js.map +1 -1
  82. package/dist/src/providers/builtin/matrix.d.ts +11 -1
  83. package/dist/src/providers/builtin/matrix.js +53 -29
  84. package/dist/src/providers/builtin/matrix.js.map +1 -1
  85. package/dist/src/providers/builtin/mattermost.d.ts +10 -0
  86. package/dist/src/providers/builtin/mattermost.js +38 -15
  87. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  88. package/dist/src/providers/builtin/msteams.d.ts +17 -1
  89. package/dist/src/providers/builtin/msteams.js +113 -14
  90. package/dist/src/providers/builtin/msteams.js.map +1 -1
  91. package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
  92. package/dist/src/providers/builtin/native-local-mock.js +12 -35
  93. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  94. package/dist/src/providers/builtin/script.d.ts +11 -15
  95. package/dist/src/providers/builtin/script.js +566 -104
  96. package/dist/src/providers/builtin/script.js.map +1 -1
  97. package/dist/src/providers/builtin/slack.d.ts +6 -0
  98. package/dist/src/providers/builtin/slack.js +99 -40
  99. package/dist/src/providers/builtin/slack.js.map +1 -1
  100. package/dist/src/providers/builtin/telegram.d.ts +6 -0
  101. package/dist/src/providers/builtin/telegram.js +64 -43
  102. package/dist/src/providers/builtin/telegram.js.map +1 -1
  103. package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
  104. package/dist/src/providers/builtin/whatsapp.js +567 -30
  105. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  106. package/dist/src/providers/builtin/zalo.js +30 -19
  107. package/dist/src/providers/builtin/zalo.js.map +1 -1
  108. package/dist/src/providers/catalog.d.ts +3 -3
  109. package/dist/src/providers/catalog.js +3 -3
  110. package/dist/src/providers/catalog.js.map +1 -1
  111. package/dist/src/providers/local-mock.d.ts +11 -2
  112. package/dist/src/providers/local-mock.js +322 -96
  113. package/dist/src/providers/local-mock.js.map +1 -1
  114. package/dist/src/providers/native-ids.d.ts +1 -0
  115. package/dist/src/providers/native-ids.js +6 -1
  116. package/dist/src/providers/native-ids.js.map +1 -1
  117. package/dist/src/providers/recorder.d.ts +30 -2
  118. package/dist/src/providers/recorder.js +549 -70
  119. package/dist/src/providers/recorder.js.map +1 -1
  120. package/dist/src/providers/registry.d.ts +25 -1
  121. package/dist/src/providers/registry.js +280 -40
  122. package/dist/src/providers/registry.js.map +1 -1
  123. package/dist/src/providers/signed-jwt.d.ts +29 -0
  124. package/dist/src/providers/signed-jwt.js +202 -0
  125. package/dist/src/providers/signed-jwt.js.map +1 -0
  126. package/dist/src/providers/slack-ids.js.map +1 -1
  127. package/dist/src/providers/target-normalizers.d.ts +26 -0
  128. package/dist/src/providers/target-normalizers.js +276 -0
  129. package/dist/src/providers/target-normalizers.js.map +1 -0
  130. package/dist/src/providers/types.d.ts +5 -0
  131. package/dist/src/providers/types.js.map +1 -1
  132. package/dist/src/providers/webhook-server.d.ts +2 -0
  133. package/dist/src/providers/webhook-server.js +72 -25
  134. package/dist/src/providers/webhook-server.js.map +1 -1
  135. package/dist/src/servers/http.d.ts +23 -3
  136. package/dist/src/servers/http.js +213 -20
  137. package/dist/src/servers/http.js.map +1 -1
  138. package/dist/src/servers/index.js +20 -21
  139. package/dist/src/servers/index.js.map +1 -1
  140. package/dist/src/servers/matrix.js +465 -51
  141. package/dist/src/servers/matrix.js.map +1 -1
  142. package/dist/src/servers/mattermost.d.ts +5 -0
  143. package/dist/src/servers/mattermost.js +306 -58
  144. package/dist/src/servers/mattermost.js.map +1 -1
  145. package/dist/src/servers/pending-events.d.ts +2 -0
  146. package/dist/src/servers/pending-events.js +11 -0
  147. package/dist/src/servers/pending-events.js.map +1 -0
  148. package/dist/src/servers/recorder.d.ts +5 -0
  149. package/dist/src/servers/recorder.js +262 -3
  150. package/dist/src/servers/recorder.js.map +1 -1
  151. package/dist/src/servers/signal.d.ts +2 -0
  152. package/dist/src/servers/signal.js +444 -56
  153. package/dist/src/servers/signal.js.map +1 -1
  154. package/dist/src/servers/slack.d.ts +5 -0
  155. package/dist/src/servers/slack.js +515 -49
  156. package/dist/src/servers/slack.js.map +1 -1
  157. package/dist/src/servers/telegram.d.ts +111 -0
  158. package/dist/src/servers/telegram.js +830 -158
  159. package/dist/src/servers/telegram.js.map +1 -1
  160. package/dist/src/servers/webhook-target.d.ts +40 -0
  161. package/dist/src/servers/webhook-target.js +349 -0
  162. package/dist/src/servers/webhook-target.js.map +1 -0
  163. package/dist/src/servers/websocket.d.ts +2 -0
  164. package/dist/src/servers/websocket.js +29 -0
  165. package/dist/src/servers/websocket.js.map +1 -0
  166. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  167. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  168. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  169. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  170. package/dist/src/servers/whatsapp-jid.js +32 -0
  171. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  172. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  174. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  175. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  177. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  178. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  179. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  181. package/dist/src/servers/whatsapp.d.ts +12 -7
  182. package/dist/src/servers/whatsapp.js +269 -107
  183. package/dist/src/servers/whatsapp.js.map +1 -1
  184. package/dist/src/servers/zalo.d.ts +14 -0
  185. package/dist/src/servers/zalo.js +521 -88
  186. package/dist/src/servers/zalo.js.map +1 -1
  187. package/docs/channel-setup.md +197 -19
  188. package/fixtures/examples/crabline.example.yaml +10 -0
  189. package/package.json +20 -15
@@ -1,20 +1,123 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import { isIP } from "node:net";
2
3
  import path from "node:path";
3
- import { adminAuthError, hasAdminToken, jsonResponse, parseRequestBody, queryRecord, readInteger, readTrimmedString, startHttpJsonServer, } from "./http.js";
4
- import { recordServerEvent } from "./recorder.js";
4
+ import { adminAuthError, hasAdminToken, InvalidJsonBodyError, isJsonObject, jsonResponse, parseUnknownRequestBody, queryRecord, readInteger, readTrimmedString, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
5
+ import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
6
+ const MAX_MATRIX_FILTER_BYTES = 1024 * 1024;
7
+ const MAX_MATRIX_FILTERS = 100;
8
+ const MAX_MATRIX_IDENTIFIER_BYTES = 255;
9
+ const MAX_MATRIX_TIMELINE_EVENTS = 1_000;
10
+ const MAX_MATRIX_TRANSACTION_RESPONSES = 1_000;
11
+ const MATRIX_TRANSACTION_RETENTION_MS = 10 * 60_000;
12
+ const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647;
13
+ class InvalidMatrixPathEncodingError extends Error {
14
+ constructor() {
15
+ super("Matrix request path contains invalid percent encoding.");
16
+ this.name = "InvalidMatrixPathEncodingError";
17
+ }
18
+ }
5
19
  function matrixId(prefix, value, serverName) {
6
20
  return `${prefix}${createHash("sha256").update(value).digest("hex").slice(0, 16)}:${serverName}`;
7
21
  }
8
- async function appendEvent(state, event) {
9
- await recordServerEvent({ event, onEvent: state.onEvent, recorderPath: state.recorderPath });
22
+ function isMatrixIpv4Address(value) {
23
+ const octets = value.split(".");
24
+ return (octets.length === 4 && octets.every((octet) => /^\d{1,3}$/u.test(octet) && Number(octet) <= 255));
25
+ }
26
+ function isMatrixServerName(value) {
27
+ const ipv6 = /^\[([^\]]+)\](?::(\d{1,5}))?$/u.exec(value);
28
+ if (ipv6) {
29
+ return isIP(ipv6[1]) === 6;
30
+ }
31
+ const hostAndPort = /^([^:]+?)(?::(\d{1,5}))?$/u.exec(value);
32
+ if (!hostAndPort) {
33
+ return false;
34
+ }
35
+ const hostname = hostAndPort[1];
36
+ if (isMatrixIpv4Address(hostname)) {
37
+ return true;
38
+ }
39
+ if (/^\d+\.\d+\.\d+\.\d+$/u.test(hostname)) {
40
+ return false;
41
+ }
42
+ return hostname.length <= 255 && /^[A-Za-z0-9.-]+$/u.test(hostname);
43
+ }
44
+ function hasLoneSurrogate(value) {
45
+ for (let index = 0; index < value.length; index += 1) {
46
+ const code = value.charCodeAt(index);
47
+ if (code >= 0xd800 && code <= 0xdbff) {
48
+ const next = value.charCodeAt(index + 1);
49
+ if (next < 0xdc00 || next > 0xdfff) {
50
+ return true;
51
+ }
52
+ index += 1;
53
+ }
54
+ else if (code >= 0xdc00 && code <= 0xdfff) {
55
+ return true;
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+ function isMatrixScopedIdentifier(value, sigil) {
61
+ const separator = value.indexOf(":");
62
+ const localpart = value.slice(1, separator);
63
+ return (value.startsWith(sigil) &&
64
+ Buffer.byteLength(value, "utf8") <= MAX_MATRIX_IDENTIFIER_BYTES &&
65
+ separator >= (sigil === "@" ? 1 : 2) &&
66
+ !localpart.includes("\0") &&
67
+ !hasLoneSurrogate(localpart) &&
68
+ isMatrixServerName(value.slice(separator + 1)));
69
+ }
70
+ function isMatrixHashIdentifier(value, sigil, allowLegacyBase64) {
71
+ if (!value.startsWith(sigil) || Buffer.byteLength(value, "utf8") > MAX_MATRIX_IDENTIFIER_BYTES) {
72
+ return false;
73
+ }
74
+ const opaqueId = value.slice(1);
75
+ const encoding = allowLegacyBase64 && /^[A-Za-z0-9+/]{43}$/u.test(opaqueId)
76
+ ? "base64"
77
+ : /^[A-Za-z0-9_-]{43}$/u.test(opaqueId)
78
+ ? "base64url"
79
+ : undefined;
80
+ if (!encoding) {
81
+ return false;
82
+ }
83
+ const decoded = Buffer.from(opaqueId, encoding);
84
+ return decoded.length === 32 && decoded.toString(encoding).replace(/=+$/u, "") === opaqueId;
85
+ }
86
+ function isMatrixRoomId(value) {
87
+ return isMatrixScopedIdentifier(value, "!") || isMatrixHashIdentifier(value, "!", false);
88
+ }
89
+ function isMatrixEventId(value) {
90
+ return isMatrixScopedIdentifier(value, "$") || isMatrixHashIdentifier(value, "$", true);
91
+ }
92
+ function isMatrixUserId(value) {
93
+ return isMatrixScopedIdentifier(value, "@");
94
+ }
95
+ async function appendEvent(state, event, committed = false) {
96
+ const params = { event, onEvent: state.onEvent, recorderPath: state.recorderPath };
97
+ await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
10
98
  }
11
99
  function authorized(request, token) {
12
- const [scheme, value] = request.headers.authorization?.trim().split(/\s+/, 2) ?? [];
13
- return scheme?.toLowerCase() === "bearer" && value === token;
100
+ const match = /^Bearer\s+(\S+)$/iu.exec(request.headers.authorization ?? "");
101
+ return match?.[1] === token;
14
102
  }
15
103
  function matrixError(errcode, error, status) {
16
104
  return jsonResponse({ errcode, error }, status);
17
105
  }
106
+ function matrixResourceLimitError(error) {
107
+ return jsonResponse({
108
+ admin_contact: "mailto:admin@localhost",
109
+ errcode: "M_RESOURCE_LIMIT_EXCEEDED",
110
+ error,
111
+ }, 503);
112
+ }
113
+ function decodeMatrixPathSegment(value) {
114
+ try {
115
+ return decodeURIComponent(value);
116
+ }
117
+ catch {
118
+ throw new InvalidMatrixPathEncodingError();
119
+ }
120
+ }
18
121
  function eventId(state) {
19
122
  return matrixId("$", `event-${state.nextEvent++}`, state.serverName);
20
123
  }
@@ -52,10 +155,15 @@ function createEvent(params) {
52
155
  function createRoom(params) {
53
156
  const room = {
54
157
  createdSequence: params.createdSequence,
158
+ ephemeral: [],
55
159
  id: params.id,
160
+ lastDroppedTimelineSequence: undefined,
56
161
  name: params.name,
57
162
  state: [],
163
+ stateBeforeTimeline: new Map(),
58
164
  timeline: [],
165
+ typingTimeouts: new Map(),
166
+ typingUsers: new Set(),
59
167
  users: new Map([[params.botUserId, { display_name: "OpenClaw QA" }]]),
60
168
  };
61
169
  room.state.push(createEvent({
@@ -84,35 +192,151 @@ function createRoom(params) {
84
192
  stateKey: "",
85
193
  type: "m.room.name",
86
194
  }));
195
+ for (const event of room.state) {
196
+ room.stateBeforeTimeline.set(matrixStateKey(event), event);
197
+ }
87
198
  return room;
88
199
  }
89
- function syncRoom(room, since) {
90
- const events = room.timeline
91
- .filter((entry) => since === undefined || entry.sequence > since)
92
- .map((entry) => entry.event);
200
+ function matrixStateKey(event) {
201
+ return JSON.stringify([event.type, event.state_key ?? ""]);
202
+ }
203
+ function appendTimelineEvent(room, entry) {
204
+ room.timeline.push(entry);
205
+ if (room.timeline.length > MAX_MATRIX_TIMELINE_EVENTS) {
206
+ const dropped = room.timeline.splice(0, room.timeline.length - MAX_MATRIX_TIMELINE_EVENTS);
207
+ room.lastDroppedTimelineSequence = dropped.at(-1)?.sequence;
208
+ for (const droppedEntry of dropped) {
209
+ if (droppedEntry.event.state_key !== undefined) {
210
+ room.stateBeforeTimeline.set(matrixStateKey(droppedEntry.event), droppedEntry.event);
211
+ }
212
+ }
213
+ }
214
+ }
215
+ function appendEphemeralEvent(room, entry) {
216
+ room.ephemeral.push(entry);
217
+ if (room.ephemeral.length > MAX_MATRIX_TIMELINE_EVENTS) {
218
+ room.ephemeral.splice(0, room.ephemeral.length - MAX_MATRIX_TIMELINE_EVENTS);
219
+ }
220
+ }
221
+ function publishTypingState(state, room) {
222
+ appendEphemeralEvent(room, {
223
+ event: { content: { user_ids: [...room.typingUsers] }, type: "m.typing" },
224
+ sequence: state.nextSequence++,
225
+ });
226
+ notifySyncWaiters(state);
227
+ }
228
+ function rememberTransaction(state, key, response) {
229
+ state.transactions.set(key, response);
230
+ }
231
+ function forgetExpiredTransactions(state) {
232
+ const now = Date.now();
233
+ for (const [key, response] of state.transactions) {
234
+ if (response.expiresAt > now) {
235
+ break;
236
+ }
237
+ state.transactions.delete(key);
238
+ }
239
+ }
240
+ function transactionResponse(state, key) {
241
+ forgetExpiredTransactions(state);
242
+ const response = state.transactions.get(key);
243
+ if (!response) {
244
+ return undefined;
245
+ }
246
+ state.transactions.delete(key);
247
+ response.expiresAt = Date.now() + MATRIX_TRANSACTION_RETENTION_MS;
248
+ state.transactions.set(key, response);
249
+ return response;
250
+ }
251
+ function readTimelineLimit(filter) {
252
+ const room = filter?.room;
253
+ const timeline = isJsonObject(room) ? room.timeline : undefined;
254
+ const limit = isJsonObject(timeline) ? readInteger(timeline.limit) : undefined;
255
+ return limit === undefined ? undefined : Math.max(0, limit);
256
+ }
257
+ function resolveSyncFilter(url, state) {
258
+ const value = url.searchParams.get("filter");
259
+ if (value === null) {
260
+ return undefined;
261
+ }
262
+ const stored = state.filters.get(value);
263
+ if (stored) {
264
+ return stored.body;
265
+ }
266
+ try {
267
+ const parsed = JSON.parse(value);
268
+ return isJsonObject(parsed) ? parsed : matrixError("M_INVALID_PARAM", "Invalid filter", 400);
269
+ }
270
+ catch {
271
+ return matrixError("M_INVALID_PARAM", "Unknown filter", 400);
272
+ }
273
+ }
274
+ function syncRoom(room, since, timelineLimit) {
275
+ const available = room.timeline.filter((entry) => since === undefined || entry.sequence > since);
276
+ const timeline = timelineLimit === undefined
277
+ ? available
278
+ : available.slice(Math.max(0, available.length - timelineLimit));
279
+ const firstSequence = timeline[0]?.sequence;
280
+ const historyWasTrimmed = room.lastDroppedTimelineSequence !== undefined &&
281
+ (since === undefined || since < room.lastDroppedTimelineSequence);
282
+ const limited = historyWasTrimmed || timeline.length < available.length;
283
+ const omittedTimeline = available.slice(0, available.length - timeline.length);
284
+ const omittedState = new Map();
285
+ if (since === undefined || room.createdSequence > since || historyWasTrimmed) {
286
+ for (const [key, event] of room.stateBeforeTimeline) {
287
+ omittedState.set(key, event);
288
+ }
289
+ }
290
+ for (const entry of omittedTimeline) {
291
+ if (entry.event.state_key !== undefined) {
292
+ omittedState.set(matrixStateKey(entry.event), entry.event);
293
+ }
294
+ }
93
295
  return {
94
296
  account_data: { events: [] },
95
- ephemeral: { events: [] },
96
- state: { events: since === undefined || room.createdSequence > since ? room.state : [] },
97
- timeline: { events, limited: false, prev_batch: `s${since ?? 0}` },
297
+ ephemeral: {
298
+ events: room.ephemeral
299
+ .filter((entry) => since === undefined || entry.sequence > since)
300
+ .map((entry) => entry.event),
301
+ },
302
+ state: {
303
+ events: [...omittedState.values()].filter((event) => !timeline.some((entry) => entry.event.event_id === event.event_id)),
304
+ },
305
+ timeline: {
306
+ events: timeline.map((entry) => entry.event),
307
+ limited,
308
+ prev_batch: `s${firstSequence === undefined ? (since ?? 0) : firstSequence - 1}`,
309
+ },
98
310
  unread_notifications: { highlight_count: 0, notification_count: 0 },
99
311
  };
100
312
  }
101
313
  function parseSyncToken(value) {
102
- if (!value) {
314
+ if (value === null) {
103
315
  return undefined;
104
316
  }
105
317
  const match = /^s(\d+)$/u.exec(value);
106
- return match ? Number(match[1]) : undefined;
318
+ if (!match) {
319
+ return null;
320
+ }
321
+ const sequence = Number(match[1]);
322
+ return Number.isSafeInteger(sequence) ? sequence : null;
107
323
  }
108
324
  async function handleSync(url, state) {
109
325
  const since = parseSyncToken(url.searchParams.get("since"));
326
+ if (since === null || (since !== undefined && since > state.nextSequence - 1)) {
327
+ return matrixError("M_UNKNOWN_POS", "Unknown position", 400);
328
+ }
329
+ const filter = resolveSyncFilter(url, state);
330
+ if (filter instanceof Response) {
331
+ return filter;
332
+ }
333
+ const timelineLimit = readTimelineLimit(filter);
110
334
  const timeout = Math.min(readInteger(url.searchParams.get("timeout")) ?? 0, 1_000);
111
- const hasNewEvents = [...state.rooms.values()].some((room) => room.timeline.some((entry) => since === undefined || entry.sequence > since));
335
+ const hasNewEvents = [...state.rooms.values()].some((room) => [...room.timeline, ...room.ephemeral].some((entry) => since === undefined || entry.sequence > since));
112
336
  if (since !== undefined && !hasNewEvents && timeout > 0) {
113
337
  await waitForSyncEvent(state, timeout);
114
338
  }
115
- const join = Object.fromEntries([...state.rooms.values()].map((room) => [room.id, syncRoom(room, since)]));
339
+ const join = Object.fromEntries([...state.rooms.values()].map((room) => [room.id, syncRoom(room, since, timelineLimit)]));
116
340
  return jsonResponse({
117
341
  account_data: { events: [] },
118
342
  device_lists: { changed: [], left: [] },
@@ -124,7 +348,7 @@ async function handleSync(url, state) {
124
348
  });
125
349
  }
126
350
  function findRoom(state, encodedRoomId) {
127
- return state.rooms.get(decodeURIComponent(encodedRoomId));
351
+ return state.rooms.get(decodeMatrixPathSegment(encodedRoomId));
128
352
  }
129
353
  async function handleAdminInbound(params) {
130
354
  const roomId = readTrimmedString(params.body.roomId);
@@ -133,6 +357,12 @@ async function handleAdminInbound(params) {
133
357
  if (!roomId || !sender || !text) {
134
358
  return jsonResponse({ error: "roomId, senderId, and text are required", ok: false }, 400);
135
359
  }
360
+ const threadId = readTrimmedString(params.body.threadId);
361
+ if (!isMatrixRoomId(roomId) ||
362
+ !isMatrixUserId(sender) ||
363
+ (threadId !== undefined && !isMatrixEventId(threadId))) {
364
+ return jsonResponse({ error: "Invalid Matrix identifier", ok: false }, 400);
365
+ }
136
366
  const direct = params.body.direct === true;
137
367
  const room = params.state.rooms.get(roomId) ??
138
368
  createRoom({
@@ -146,7 +376,8 @@ async function handleAdminInbound(params) {
146
376
  });
147
377
  params.state.rooms.set(roomId, room);
148
378
  const senderName = readTrimmedString(params.body.senderName);
149
- if (!room.users.has(sender)) {
379
+ const existingProfile = room.users.get(sender);
380
+ if (existingProfile === undefined) {
150
381
  room.users.set(sender, senderName ? { display_name: senderName } : {});
151
382
  const membership = createEvent({
152
383
  content: {
@@ -160,13 +391,31 @@ async function handleAdminInbound(params) {
160
391
  type: "m.room.member",
161
392
  });
162
393
  room.state.push(membership);
163
- room.timeline.push({ event: membership, sequence: params.state.nextSequence++ });
394
+ appendTimelineEvent(room, { event: membership, sequence: params.state.nextSequence++ });
164
395
  }
165
- else if (senderName) {
166
- room.users.set(sender, { display_name: senderName });
396
+ else if (senderName && existingProfile.display_name !== senderName) {
397
+ room.users.set(sender, { ...existingProfile, display_name: senderName });
398
+ const membership = createEvent({
399
+ content: { membership: "join", displayname: senderName },
400
+ roomId,
401
+ sender,
402
+ state: params.state,
403
+ stateKey: sender,
404
+ type: "m.room.member",
405
+ });
406
+ const stateIndex = room.state.findIndex((event) => event.type === "m.room.member" && event.state_key === sender);
407
+ if (stateIndex >= 0) {
408
+ room.state[stateIndex] = membership;
409
+ }
410
+ else {
411
+ room.state.push(membership);
412
+ }
413
+ appendTimelineEvent(room, {
414
+ event: membership,
415
+ sequence: params.state.nextSequence++,
416
+ });
167
417
  }
168
418
  const content = { body: text, msgtype: "m.text" };
169
- const threadId = readTrimmedString(params.body.threadId);
170
419
  if (threadId) {
171
420
  content["m.relates_to"] = {
172
421
  event_id: threadId,
@@ -182,7 +431,7 @@ async function handleAdminInbound(params) {
182
431
  state: params.state,
183
432
  type: "m.room.message",
184
433
  });
185
- room.timeline.push({ event, sequence: params.state.nextSequence++ });
434
+ appendTimelineEvent(room, { event, sequence: params.state.nextSequence++ });
186
435
  notifySyncWaiters(params.state);
187
436
  return jsonResponse({ event, ok: true });
188
437
  }
@@ -203,7 +452,7 @@ async function handleMatrixApi(params) {
203
452
  }
204
453
  let match = /^\/profile\/([^/]+)$/u.exec(relativePath);
205
454
  if (params.method === "GET" && match) {
206
- const userId = decodeURIComponent(match[1]);
455
+ const userId = decodeMatrixPathSegment(match[1]);
207
456
  const member = [...params.state.rooms.values()]
208
457
  .map((room) => room.users.get(userId))
209
458
  .find((profile) => profile !== undefined);
@@ -223,18 +472,28 @@ async function handleMatrixApi(params) {
223
472
  }
224
473
  match = /^\/user\/([^/]+)\/filter$/u.exec(relativePath);
225
474
  if (params.method === "POST" && match) {
226
- const userId = decodeURIComponent(match[1]);
475
+ const userId = decodeMatrixPathSegment(match[1]);
227
476
  if (userId !== params.state.botUserId) {
228
477
  return matrixError("M_FORBIDDEN", "Cannot create a filter for another user", 403);
229
478
  }
479
+ const bytes = Buffer.byteLength(JSON.stringify(params.body), "utf8");
480
+ if (params.state.filters.size >= MAX_MATRIX_FILTERS ||
481
+ params.state.filterBytes + bytes > MAX_MATRIX_FILTER_BYTES) {
482
+ return matrixResourceLimitError("Too many stored filters");
483
+ }
230
484
  const filterId = String(params.state.nextFilter++);
231
- params.state.filters.set(filterId, params.body);
485
+ params.state.filters.set(filterId, { body: params.body, bytes });
486
+ params.state.filterBytes += bytes;
232
487
  return jsonResponse({ filter_id: filterId });
233
488
  }
234
489
  match = /^\/user\/([^/]+)\/filter\/([^/]+)$/u.exec(relativePath);
235
490
  if (params.method === "GET" && match) {
236
- const filter = params.state.filters.get(decodeURIComponent(match[2]));
237
- return filter ? jsonResponse(filter) : matrixError("M_NOT_FOUND", "Unknown filter", 404);
491
+ const userId = decodeMatrixPathSegment(match[1]);
492
+ if (userId !== params.state.botUserId) {
493
+ return matrixError("M_FORBIDDEN", "Cannot get filters for another user", 403);
494
+ }
495
+ const filter = params.state.filters.get(decodeMatrixPathSegment(match[2]));
496
+ return filter ? jsonResponse(filter.body) : matrixError("M_NOT_FOUND", "Unknown filter", 404);
238
497
  }
239
498
  match = /^\/rooms\/([^/]+)\/joined_members$/u.exec(relativePath);
240
499
  if (params.method === "GET" && match) {
@@ -249,8 +508,8 @@ async function handleMatrixApi(params) {
249
508
  if (!room) {
250
509
  return matrixError("M_NOT_FOUND", "Unknown room", 404);
251
510
  }
252
- const eventType = decodeURIComponent(match[2]);
253
- const stateKey = decodeURIComponent(match[3] ?? "");
511
+ const eventType = decodeMatrixPathSegment(match[2]);
512
+ const stateKey = decodeMatrixPathSegment(match[3] ?? "");
254
513
  if (eventType === "m.room.name" && stateKey === "") {
255
514
  return jsonResponse({ name: room.name });
256
515
  }
@@ -269,28 +528,125 @@ async function handleMatrixApi(params) {
269
528
  }
270
529
  match = /^\/rooms\/([^/]+)\/send\/([^/]+)\/([^/]+)$/u.exec(relativePath);
271
530
  if (params.method === "PUT" && match) {
272
- const room = findRoom(params.state, match[1]);
531
+ const roomId = decodeMatrixPathSegment(match[1]);
532
+ const eventType = decodeMatrixPathSegment(match[2]);
533
+ const transactionId = decodeMatrixPathSegment(match[3]);
534
+ const transactionKey = JSON.stringify([
535
+ params.state.botUserId,
536
+ roomId,
537
+ eventType,
538
+ transactionId,
539
+ ]);
540
+ const existingResponse = transactionResponse(params.state, transactionKey);
541
+ if (existingResponse) {
542
+ params.recorderEvent.accepted =
543
+ existingResponse.status >= 200 && existingResponse.status < 300;
544
+ return jsonResponse(existingResponse.body, existingResponse.status);
545
+ }
546
+ if (params.state.transactions.size >= MAX_MATRIX_TRANSACTION_RESPONSES) {
547
+ params.recorderEvent.accepted = false;
548
+ return matrixResourceLimitError("Too many retained transaction responses");
549
+ }
550
+ const room = params.state.rooms.get(roomId);
273
551
  if (!room) {
274
- return matrixError("M_NOT_FOUND", "Unknown room", 404);
552
+ params.recorderEvent.accepted = false;
553
+ const body = { errcode: "M_NOT_FOUND", error: "Unknown room" };
554
+ rememberTransaction(params.state, transactionKey, {
555
+ body,
556
+ expiresAt: Date.now() + MATRIX_TRANSACTION_RETENTION_MS,
557
+ status: 404,
558
+ });
559
+ return jsonResponse(body, 404);
275
560
  }
276
561
  const event = createEvent({
277
562
  content: params.body,
278
563
  roomId: room.id,
279
564
  sender: params.state.botUserId,
280
565
  state: params.state,
281
- transactionId: decodeURIComponent(match[3]),
282
- type: decodeURIComponent(match[2]),
566
+ transactionId,
567
+ type: eventType,
568
+ });
569
+ appendTimelineEvent(room, { event, sequence: params.state.nextSequence++ });
570
+ const body = { event_id: event.event_id };
571
+ rememberTransaction(params.state, transactionKey, {
572
+ body,
573
+ expiresAt: Date.now() + MATRIX_TRANSACTION_RETENTION_MS,
574
+ status: 200,
283
575
  });
284
- room.timeline.push({ event, sequence: params.state.nextSequence++ });
285
576
  notifySyncWaiters(params.state);
286
- return jsonResponse({ event_id: event.event_id });
577
+ params.recorderEvent.accepted = true;
578
+ return jsonResponse(body);
287
579
  }
288
580
  match = /^\/rooms\/([^/]+)\/typing\/([^/]+)$/u.exec(relativePath);
289
581
  if (params.method === "PUT" && match) {
582
+ const room = findRoom(params.state, match[1]);
583
+ if (!room) {
584
+ return matrixError("M_NOT_FOUND", "Unknown room", 404);
585
+ }
586
+ const userId = decodeMatrixPathSegment(match[2]);
587
+ if (userId !== params.state.botUserId) {
588
+ return matrixError("M_FORBIDDEN", "Cannot set typing state for another user", 403);
589
+ }
590
+ if (typeof params.body.typing !== "boolean") {
591
+ return matrixError("M_BAD_JSON", "typing must be a boolean", 400);
592
+ }
593
+ const timeout = params.body.typing &&
594
+ typeof params.body.timeout === "number" &&
595
+ Number.isSafeInteger(params.body.timeout)
596
+ ? params.body.timeout
597
+ : undefined;
598
+ if (params.body.typing &&
599
+ (timeout === undefined || timeout < 1 || timeout > MAX_NODE_TIMER_DELAY_MS)) {
600
+ return matrixError("M_BAD_JSON", `timeout must be a positive integer no greater than ${MAX_NODE_TIMER_DELAY_MS}`, 400);
601
+ }
602
+ const existingTimeout = room.typingTimeouts.get(userId);
603
+ if (existingTimeout) {
604
+ clearTimeout(existingTimeout);
605
+ room.typingTimeouts.delete(userId);
606
+ }
607
+ if (params.body.typing) {
608
+ room.typingUsers.add(userId);
609
+ const timer = setTimeout(() => {
610
+ room.typingTimeouts.delete(userId);
611
+ if (room.typingUsers.delete(userId)) {
612
+ publishTypingState(params.state, room);
613
+ }
614
+ }, timeout);
615
+ timer.unref();
616
+ room.typingTimeouts.set(userId, timer);
617
+ }
618
+ else {
619
+ room.typingUsers.delete(userId);
620
+ }
621
+ publishTypingState(params.state, room);
290
622
  return jsonResponse({});
291
623
  }
292
624
  match = /^\/rooms\/([^/]+)\/receipt\/m\.read\/([^/]+)$/u.exec(relativePath);
293
625
  if (params.method === "POST" && match) {
626
+ const room = findRoom(params.state, match[1]);
627
+ if (!room) {
628
+ return matrixError("M_NOT_FOUND", "Unknown room", 404);
629
+ }
630
+ const receiptEventId = decodeMatrixPathSegment(match[2]);
631
+ appendEphemeralEvent(room, {
632
+ event: {
633
+ content: {
634
+ [receiptEventId]: {
635
+ "m.read": {
636
+ [params.state.botUserId]: {
637
+ ts: Date.now(),
638
+ ...(readTrimmedString(params.body.thread_id)
639
+ ? { thread_id: readTrimmedString(params.body.thread_id) }
640
+ : {}),
641
+ },
642
+ },
643
+ },
644
+ },
645
+ type: "m.receipt",
646
+ },
647
+ sequence: params.state.nextSequence++,
648
+ });
649
+ notifySyncWaiters(params.state);
294
650
  return jsonResponse({});
295
651
  }
296
652
  return matrixError("M_UNRECOGNIZED", "Unrecognized request", 404);
@@ -303,6 +659,7 @@ export async function startMatrixServer(params = {}) {
303
659
  adminToken: params.adminToken ?? randomBytes(24).toString("hex"),
304
660
  botUserId: params.botUserId ?? `@openclaw:${serverName}`,
305
661
  deviceId: params.deviceId ?? "CRABLINE",
662
+ filterBytes: 0,
306
663
  filters: new Map(),
307
664
  nextEvent: 1,
308
665
  nextFilter: 1,
@@ -312,6 +669,7 @@ export async function startMatrixServer(params = {}) {
312
669
  rooms: new Map(),
313
670
  serverName,
314
671
  syncWaiters: new Set(),
672
+ transactions: new Map(),
315
673
  };
316
674
  const roomId = params.roomId ?? matrixId("!", "default-room", serverName);
317
675
  state.rooms.set(roomId, createRoom({
@@ -323,45 +681,101 @@ export async function startMatrixServer(params = {}) {
323
681
  state,
324
682
  }));
325
683
  const server = await startHttpJsonServer({
684
+ handleError: (error) => {
685
+ if (error instanceof InvalidJsonBodyError) {
686
+ return matrixError("M_NOT_JSON", "Request body is not valid JSON", 400);
687
+ }
688
+ if (error instanceof RequestBodyTooLargeError) {
689
+ return matrixError("M_TOO_LARGE", "Request body is too large", 413);
690
+ }
691
+ if (error instanceof InvalidMatrixPathEncodingError) {
692
+ return matrixError("M_INVALID_PARAM", "Invalid request path encoding", 400);
693
+ }
694
+ return matrixError("M_UNKNOWN", "Internal server error", 500);
695
+ },
326
696
  host,
327
697
  port: params.port ?? 0,
328
698
  serverName: "Matrix",
329
699
  async handle(request) {
330
700
  const url = new URL(request.url ?? "/", "http://localhost");
331
701
  const method = request.method ?? "GET";
332
- const body = ["POST", "PUT"].includes(method) ? await parseRequestBody(request) : {};
333
702
  const type = url.pathname === "/crabline/matrix/inbound" ? "admin" : "api";
334
- await appendEvent(state, {
335
- at: new Date().toISOString(),
336
- ...(Object.keys(body).length > 0 ? { body } : {}),
337
- method,
338
- path: url.pathname,
339
- query: queryRecord(url),
340
- type,
341
- });
342
703
  if (type === "admin") {
343
704
  if (method !== "POST") {
344
705
  return jsonResponse({ error: "Method not allowed", ok: false }, 405);
345
706
  }
346
- return hasAdminToken(request, state.adminToken)
347
- ? await handleAdminInbound({ body, state })
348
- : adminAuthError();
707
+ if (!hasAdminToken(request, state.adminToken)) {
708
+ request.resume();
709
+ return adminAuthError();
710
+ }
711
+ const body = await parseUnknownRequestBody(request);
712
+ if (!isJsonObject(body)) {
713
+ return jsonResponse({ error: "Request body must be a JSON object", ok: false }, 400);
714
+ }
715
+ await appendEvent(state, {
716
+ at: new Date().toISOString(),
717
+ ...(Object.keys(body).length > 0 ? { body } : {}),
718
+ method,
719
+ path: url.pathname,
720
+ query: queryRecord(url),
721
+ type,
722
+ });
723
+ return await handleAdminInbound({ body, state });
349
724
  }
350
725
  if (url.pathname === "/_matrix/client/versions" && method === "GET") {
726
+ await appendEvent(state, {
727
+ at: new Date().toISOString(),
728
+ method,
729
+ path: url.pathname,
730
+ query: queryRecord(url),
731
+ type,
732
+ });
351
733
  return jsonResponse({ unstable_features: {}, versions: ["v1.11"] });
352
734
  }
353
735
  if (!url.pathname.startsWith("/_matrix/client/")) {
354
736
  return matrixError("M_UNRECOGNIZED", "Unrecognized request", 404);
355
737
  }
356
738
  if (!authorized(request, state.accessToken)) {
739
+ request.resume();
357
740
  return matrixError("M_UNKNOWN_TOKEN", "Invalid access token", 401);
358
741
  }
359
- return await handleMatrixApi({ body, method, path: url.pathname, state, url });
742
+ const parsedBody = ["POST", "PUT"].includes(method)
743
+ ? await parseUnknownRequestBody(request)
744
+ : {};
745
+ if (!isJsonObject(parsedBody)) {
746
+ return matrixError("M_BAD_JSON", "Request body must be a JSON object", 400);
747
+ }
748
+ const event = {
749
+ at: new Date().toISOString(),
750
+ ...(Object.keys(parsedBody).length > 0 ? { body: parsedBody } : {}),
751
+ method,
752
+ path: url.pathname,
753
+ query: queryRecord(url),
754
+ type,
755
+ };
756
+ const response = await handleMatrixApi({
757
+ body: parsedBody,
758
+ method,
759
+ path: url.pathname,
760
+ recorderEvent: event,
761
+ state,
762
+ url,
763
+ });
764
+ await appendEvent(state, event, response.ok && ["DELETE", "POST", "PUT"].includes(method));
765
+ return response;
360
766
  },
361
767
  });
362
768
  const clientApiRoot = `${server.baseUrl}/_matrix/client/v3`;
363
769
  return {
364
- close: server.close,
770
+ async close() {
771
+ for (const room of state.rooms.values()) {
772
+ for (const timer of room.typingTimeouts.values()) {
773
+ clearTimeout(timer);
774
+ }
775
+ room.typingTimeouts.clear();
776
+ }
777
+ await server.close();
778
+ },
365
779
  manifest: {
366
780
  accessToken: state.accessToken,
367
781
  adminToken: state.adminToken,