@openclaw/crabline 0.1.8 → 0.1.10

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 (190) hide show
  1. package/README.md +52 -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 +4 -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 +38 -0
  34. package/dist/src/openclaw/artifact-generation.js +314 -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 +26 -0
  54. package/dist/src/openclaw/private-file.js +411 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +23 -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 +949 -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 +195 -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 +539 -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.d.ts +2 -0
  141. package/dist/src/servers/matrix.js +466 -52
  142. package/dist/src/servers/matrix.js.map +1 -1
  143. package/dist/src/servers/mattermost.d.ts +7 -0
  144. package/dist/src/servers/mattermost.js +307 -59
  145. package/dist/src/servers/mattermost.js.map +1 -1
  146. package/dist/src/servers/pending-events.d.ts +2 -0
  147. package/dist/src/servers/pending-events.js +11 -0
  148. package/dist/src/servers/pending-events.js.map +1 -0
  149. package/dist/src/servers/recorder.d.ts +12 -0
  150. package/dist/src/servers/recorder.js +51 -0
  151. package/dist/src/servers/recorder.js.map +1 -0
  152. package/dist/src/servers/signal.d.ts +4 -0
  153. package/dist/src/servers/signal.js +445 -57
  154. package/dist/src/servers/signal.js.map +1 -1
  155. package/dist/src/servers/slack.d.ts +7 -0
  156. package/dist/src/servers/slack.js +516 -50
  157. package/dist/src/servers/slack.js.map +1 -1
  158. package/dist/src/servers/telegram.d.ts +113 -0
  159. package/dist/src/servers/telegram.js +814 -155
  160. package/dist/src/servers/telegram.js.map +1 -1
  161. package/dist/src/servers/webhook-target.d.ts +40 -0
  162. package/dist/src/servers/webhook-target.js +349 -0
  163. package/dist/src/servers/webhook-target.js.map +1 -0
  164. package/dist/src/servers/websocket.d.ts +2 -0
  165. package/dist/src/servers/websocket.js +29 -0
  166. package/dist/src/servers/websocket.js.map +1 -0
  167. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  168. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  169. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  170. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  171. package/dist/src/servers/whatsapp-jid.js +32 -0
  172. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  174. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  175. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  177. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  178. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  179. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  180. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  181. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  182. package/dist/src/servers/whatsapp.d.ts +14 -7
  183. package/dist/src/servers/whatsapp.js +272 -110
  184. package/dist/src/servers/whatsapp.js.map +1 -1
  185. package/dist/src/servers/zalo.d.ts +16 -0
  186. package/dist/src/servers/zalo.js +524 -91
  187. package/dist/src/servers/zalo.js.map +1 -1
  188. package/docs/channel-setup.md +197 -19
  189. package/fixtures/examples/crabline.example.yaml +10 -0
  190. package/package.json +20 -15
@@ -1,21 +1,123 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import fs from "node:fs/promises";
2
+ import { isIP } from "node:net";
3
3
  import path from "node:path";
4
- import { adminAuthError, hasAdminToken, jsonResponse, parseRequestBody, queryRecord, readInteger, readTrimmedString, startHttpJsonServer, } from "./http.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 fs.mkdir(path.dirname(state.recorderPath), { recursive: true });
10
- await fs.appendFile(state.recorderPath, `${JSON.stringify(event)}\n`, "utf8");
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));
11
98
  }
12
99
  function authorized(request, token) {
13
- const [scheme, value] = request.headers.authorization?.trim().split(/\s+/, 2) ?? [];
14
- return scheme?.toLowerCase() === "bearer" && value === token;
100
+ const match = /^Bearer\s+(\S+)$/iu.exec(request.headers.authorization ?? "");
101
+ return match?.[1] === token;
15
102
  }
16
103
  function matrixError(errcode, error, status) {
17
104
  return jsonResponse({ errcode, error }, status);
18
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
+ }
19
121
  function eventId(state) {
20
122
  return matrixId("$", `event-${state.nextEvent++}`, state.serverName);
21
123
  }
@@ -53,10 +155,15 @@ function createEvent(params) {
53
155
  function createRoom(params) {
54
156
  const room = {
55
157
  createdSequence: params.createdSequence,
158
+ ephemeral: [],
56
159
  id: params.id,
160
+ lastDroppedTimelineSequence: undefined,
57
161
  name: params.name,
58
162
  state: [],
163
+ stateBeforeTimeline: new Map(),
59
164
  timeline: [],
165
+ typingTimeouts: new Map(),
166
+ typingUsers: new Set(),
60
167
  users: new Map([[params.botUserId, { display_name: "OpenClaw QA" }]]),
61
168
  };
62
169
  room.state.push(createEvent({
@@ -85,35 +192,151 @@ function createRoom(params) {
85
192
  stateKey: "",
86
193
  type: "m.room.name",
87
194
  }));
195
+ for (const event of room.state) {
196
+ room.stateBeforeTimeline.set(matrixStateKey(event), event);
197
+ }
88
198
  return room;
89
199
  }
90
- function syncRoom(room, since) {
91
- const events = room.timeline
92
- .filter((entry) => since === undefined || entry.sequence > since)
93
- .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
+ }
94
295
  return {
95
296
  account_data: { events: [] },
96
- ephemeral: { events: [] },
97
- state: { events: since === undefined || room.createdSequence > since ? room.state : [] },
98
- 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
+ },
99
310
  unread_notifications: { highlight_count: 0, notification_count: 0 },
100
311
  };
101
312
  }
102
313
  function parseSyncToken(value) {
103
- if (!value) {
314
+ if (value === null) {
104
315
  return undefined;
105
316
  }
106
317
  const match = /^s(\d+)$/u.exec(value);
107
- 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;
108
323
  }
109
324
  async function handleSync(url, state) {
110
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);
111
334
  const timeout = Math.min(readInteger(url.searchParams.get("timeout")) ?? 0, 1_000);
112
- 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));
113
336
  if (since !== undefined && !hasNewEvents && timeout > 0) {
114
337
  await waitForSyncEvent(state, timeout);
115
338
  }
116
- 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)]));
117
340
  return jsonResponse({
118
341
  account_data: { events: [] },
119
342
  device_lists: { changed: [], left: [] },
@@ -125,7 +348,7 @@ async function handleSync(url, state) {
125
348
  });
126
349
  }
127
350
  function findRoom(state, encodedRoomId) {
128
- return state.rooms.get(decodeURIComponent(encodedRoomId));
351
+ return state.rooms.get(decodeMatrixPathSegment(encodedRoomId));
129
352
  }
130
353
  async function handleAdminInbound(params) {
131
354
  const roomId = readTrimmedString(params.body.roomId);
@@ -134,6 +357,12 @@ async function handleAdminInbound(params) {
134
357
  if (!roomId || !sender || !text) {
135
358
  return jsonResponse({ error: "roomId, senderId, and text are required", ok: false }, 400);
136
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
+ }
137
366
  const direct = params.body.direct === true;
138
367
  const room = params.state.rooms.get(roomId) ??
139
368
  createRoom({
@@ -147,7 +376,8 @@ async function handleAdminInbound(params) {
147
376
  });
148
377
  params.state.rooms.set(roomId, room);
149
378
  const senderName = readTrimmedString(params.body.senderName);
150
- if (!room.users.has(sender)) {
379
+ const existingProfile = room.users.get(sender);
380
+ if (existingProfile === undefined) {
151
381
  room.users.set(sender, senderName ? { display_name: senderName } : {});
152
382
  const membership = createEvent({
153
383
  content: {
@@ -161,13 +391,31 @@ async function handleAdminInbound(params) {
161
391
  type: "m.room.member",
162
392
  });
163
393
  room.state.push(membership);
164
- room.timeline.push({ event: membership, sequence: params.state.nextSequence++ });
394
+ appendTimelineEvent(room, { event: membership, sequence: params.state.nextSequence++ });
165
395
  }
166
- else if (senderName) {
167
- 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
+ });
168
417
  }
169
418
  const content = { body: text, msgtype: "m.text" };
170
- const threadId = readTrimmedString(params.body.threadId);
171
419
  if (threadId) {
172
420
  content["m.relates_to"] = {
173
421
  event_id: threadId,
@@ -183,7 +431,7 @@ async function handleAdminInbound(params) {
183
431
  state: params.state,
184
432
  type: "m.room.message",
185
433
  });
186
- room.timeline.push({ event, sequence: params.state.nextSequence++ });
434
+ appendTimelineEvent(room, { event, sequence: params.state.nextSequence++ });
187
435
  notifySyncWaiters(params.state);
188
436
  return jsonResponse({ event, ok: true });
189
437
  }
@@ -204,7 +452,7 @@ async function handleMatrixApi(params) {
204
452
  }
205
453
  let match = /^\/profile\/([^/]+)$/u.exec(relativePath);
206
454
  if (params.method === "GET" && match) {
207
- const userId = decodeURIComponent(match[1]);
455
+ const userId = decodeMatrixPathSegment(match[1]);
208
456
  const member = [...params.state.rooms.values()]
209
457
  .map((room) => room.users.get(userId))
210
458
  .find((profile) => profile !== undefined);
@@ -224,18 +472,28 @@ async function handleMatrixApi(params) {
224
472
  }
225
473
  match = /^\/user\/([^/]+)\/filter$/u.exec(relativePath);
226
474
  if (params.method === "POST" && match) {
227
- const userId = decodeURIComponent(match[1]);
475
+ const userId = decodeMatrixPathSegment(match[1]);
228
476
  if (userId !== params.state.botUserId) {
229
477
  return matrixError("M_FORBIDDEN", "Cannot create a filter for another user", 403);
230
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
+ }
231
484
  const filterId = String(params.state.nextFilter++);
232
- params.state.filters.set(filterId, params.body);
485
+ params.state.filters.set(filterId, { body: params.body, bytes });
486
+ params.state.filterBytes += bytes;
233
487
  return jsonResponse({ filter_id: filterId });
234
488
  }
235
489
  match = /^\/user\/([^/]+)\/filter\/([^/]+)$/u.exec(relativePath);
236
490
  if (params.method === "GET" && match) {
237
- const filter = params.state.filters.get(decodeURIComponent(match[2]));
238
- 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);
239
497
  }
240
498
  match = /^\/rooms\/([^/]+)\/joined_members$/u.exec(relativePath);
241
499
  if (params.method === "GET" && match) {
@@ -250,8 +508,8 @@ async function handleMatrixApi(params) {
250
508
  if (!room) {
251
509
  return matrixError("M_NOT_FOUND", "Unknown room", 404);
252
510
  }
253
- const eventType = decodeURIComponent(match[2]);
254
- const stateKey = decodeURIComponent(match[3] ?? "");
511
+ const eventType = decodeMatrixPathSegment(match[2]);
512
+ const stateKey = decodeMatrixPathSegment(match[3] ?? "");
255
513
  if (eventType === "m.room.name" && stateKey === "") {
256
514
  return jsonResponse({ name: room.name });
257
515
  }
@@ -270,28 +528,125 @@ async function handleMatrixApi(params) {
270
528
  }
271
529
  match = /^\/rooms\/([^/]+)\/send\/([^/]+)\/([^/]+)$/u.exec(relativePath);
272
530
  if (params.method === "PUT" && match) {
273
- 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);
274
551
  if (!room) {
275
- 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);
276
560
  }
277
561
  const event = createEvent({
278
562
  content: params.body,
279
563
  roomId: room.id,
280
564
  sender: params.state.botUserId,
281
565
  state: params.state,
282
- transactionId: decodeURIComponent(match[3]),
283
- 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,
284
575
  });
285
- room.timeline.push({ event, sequence: params.state.nextSequence++ });
286
576
  notifySyncWaiters(params.state);
287
- return jsonResponse({ event_id: event.event_id });
577
+ params.recorderEvent.accepted = true;
578
+ return jsonResponse(body);
288
579
  }
289
580
  match = /^\/rooms\/([^/]+)\/typing\/([^/]+)$/u.exec(relativePath);
290
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);
291
622
  return jsonResponse({});
292
623
  }
293
624
  match = /^\/rooms\/([^/]+)\/receipt\/m\.read\/([^/]+)$/u.exec(relativePath);
294
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);
295
650
  return jsonResponse({});
296
651
  }
297
652
  return matrixError("M_UNRECOGNIZED", "Unrecognized request", 404);
@@ -304,14 +659,17 @@ export async function startMatrixServer(params = {}) {
304
659
  adminToken: params.adminToken ?? randomBytes(24).toString("hex"),
305
660
  botUserId: params.botUserId ?? `@openclaw:${serverName}`,
306
661
  deviceId: params.deviceId ?? "CRABLINE",
662
+ filterBytes: 0,
307
663
  filters: new Map(),
308
664
  nextEvent: 1,
309
665
  nextFilter: 1,
310
666
  nextSequence: 1,
667
+ onEvent: params.onEvent,
311
668
  recorderPath: params.recorderPath ?? path.resolve("artifacts/crabline/matrix.jsonl"),
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,