@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,14 +1,29 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
- import fs from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import { WebSocket, WebSocketServer } from "ws";
5
- import { adminAuthError, hasAdminToken, jsonResponse, parseRequestBody, queryRecord, readTrimmedString, startHttpJsonServer, } from "./http.js";
4
+ import { adminAuthError, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, jsonResponse, parseUnknownRequestBody, queryRecord, readTrimmedString, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
5
+ import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
6
+ import { resolveMaxPendingInboundEvents } from "./pending-events.js";
7
+ import { closeWebSocketServer } from "./websocket.js";
8
+ const DEFAULT_WEBSOCKET_AUTHENTICATION_TIMEOUT_MS = 5_000;
9
+ const DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES = 1024 * 1024;
10
+ const DEFAULT_MAX_WEBSOCKET_MESSAGE_BYTES = 64 * 1024;
11
+ const DEFAULT_MAX_UNAUTHENTICATED_WEBSOCKET_CLIENTS = 32;
12
+ function resolvePositiveLimit(value, name, fallback) {
13
+ if (value === undefined) {
14
+ return fallback;
15
+ }
16
+ if (!Number.isSafeInteger(value) || value < 1) {
17
+ throw new Error(`${name} must be a positive safe integer.`);
18
+ }
19
+ return value;
20
+ }
6
21
  export function mattermostId(value) {
7
22
  return createHash("sha256").update(value).digest("hex").slice(0, 26);
8
23
  }
9
- async function appendEvent(state, event) {
10
- await fs.mkdir(path.dirname(state.recorderPath), { recursive: true });
11
- await fs.appendFile(state.recorderPath, `${JSON.stringify(event)}\n`, "utf8");
24
+ async function appendEvent(state, event, committed = false) {
25
+ const params = { event, onEvent: state.onEvent, recorderPath: state.recorderPath };
26
+ await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
12
27
  }
13
28
  function authorized(request, token) {
14
29
  const [scheme, value] = request.headers.authorization?.trim().split(/\s+/, 2) ?? [];
@@ -22,6 +37,14 @@ function mattermostError(message, status) {
22
37
  status_code: status,
23
38
  }, status);
24
39
  }
40
+ function decodeMattermostPathSegment(value) {
41
+ try {
42
+ return decodeURIComponent(value);
43
+ }
44
+ catch {
45
+ return mattermostError("Invalid path parameter", 400);
46
+ }
47
+ }
25
48
  function eventBroadcast(params) {
26
49
  return {
27
50
  channel_id: params.channelId ?? "",
@@ -44,22 +67,87 @@ function postEvent(event, post, senderName, channelType) {
44
67
  broadcast: eventBroadcast({ channelId: post.channel_id, userId: post.user_id }),
45
68
  };
46
69
  }
70
+ function webSocketEventBytes(event) {
71
+ return Buffer.byteLength(JSON.stringify({ ...event, seq: 0 }), "utf8");
72
+ }
47
73
  function sendEvent(state, client, event) {
48
74
  const seq = state.websocketClients.get(client);
49
75
  if (seq === undefined || client.readyState !== WebSocket.OPEN) {
50
- return;
76
+ state.websocketClients.delete(client);
77
+ return false;
78
+ }
79
+ const payload = JSON.stringify({ ...event, seq });
80
+ if (client.bufferedAmount + Buffer.byteLength(payload, "utf8") >
81
+ state.maxWebSocketBufferedBytes) {
82
+ state.websocketClients.delete(client);
83
+ client.close(1013, "client too slow");
84
+ return false;
85
+ }
86
+ try {
87
+ client.send(payload);
88
+ }
89
+ catch {
90
+ state.websocketClients.delete(client);
91
+ client.terminate();
92
+ return false;
51
93
  }
52
- client.send(JSON.stringify({ ...event, seq }));
53
94
  state.websocketClients.set(client, seq + 1);
95
+ return true;
96
+ }
97
+ function sendControlMessage(state, client, value) {
98
+ if (client.readyState !== WebSocket.OPEN) {
99
+ state.websocketClients.delete(client);
100
+ return false;
101
+ }
102
+ const payload = JSON.stringify(value);
103
+ if (client.bufferedAmount + Buffer.byteLength(payload, "utf8") >
104
+ state.maxWebSocketBufferedBytes) {
105
+ state.websocketClients.delete(client);
106
+ client.close(1013, "client too slow");
107
+ return false;
108
+ }
109
+ try {
110
+ client.send(payload);
111
+ return true;
112
+ }
113
+ catch {
114
+ state.websocketClients.delete(client);
115
+ client.terminate();
116
+ return false;
117
+ }
54
118
  }
55
- function broadcast(state, event) {
56
- if (state.websocketClients.size === 0) {
57
- state.pendingEvents.push(event);
58
- return;
119
+ function broadcast(state, event, queueIfUndelivered = true) {
120
+ if (event.broadcast.omit_users?.[state.botUserId]) {
121
+ return true;
122
+ }
123
+ if (webSocketEventBytes(event) > state.maxWebSocketBufferedBytes) {
124
+ for (const client of state.websocketClients.keys()) {
125
+ state.websocketClients.delete(client);
126
+ client.close(1013, "client too slow");
127
+ }
128
+ return false;
59
129
  }
130
+ let delivered = false;
60
131
  for (const client of state.websocketClients.keys()) {
61
- sendEvent(state, client, event);
132
+ delivered = sendEvent(state, client, event) || delivered;
62
133
  }
134
+ if (delivered) {
135
+ return true;
136
+ }
137
+ if (!queueIfUndelivered) {
138
+ return false;
139
+ }
140
+ if (state.pendingEvents.length >= state.maxPendingInboundEvents) {
141
+ return false;
142
+ }
143
+ state.pendingEvents.push(event);
144
+ return true;
145
+ }
146
+ function pendingQueueFullResponse(state) {
147
+ return jsonResponse({
148
+ error: `Pending inbound queue is full (${state.maxPendingInboundEvents} events)`,
149
+ ok: false,
150
+ }, 503);
63
151
  }
64
152
  function createPost(params) {
65
153
  const id = mattermostId(`post-${params.state.nextPost++}`);
@@ -75,20 +163,27 @@ function createPost(params) {
75
163
  params.state.posts.set(id, post);
76
164
  return post;
77
165
  }
78
- async function handleAdminInbound(params) {
166
+ function handleAdminInbound(params) {
79
167
  const channelId = readTrimmedString(params.body.channelId ?? params.body.channel_id);
80
168
  const senderId = readTrimmedString(params.body.senderId ?? params.body.user_id);
81
169
  const text = readTrimmedString(params.body.text ?? params.body.message);
82
170
  if (!channelId || !senderId || !text) {
83
171
  return jsonResponse({ error: "channelId, senderId, and text are required", ok: false }, 400);
84
172
  }
85
- const senderName = readTrimmedString(params.body.senderName) ?? senderId;
86
- const channelType = readTrimmedString(params.body.channelType) ?? "D";
173
+ if (params.state.websocketClients.size === 0 &&
174
+ params.state.pendingEvents.length >= params.state.maxPendingInboundEvents) {
175
+ return pendingQueueFullResponse(params.state);
176
+ }
177
+ const previousUser = params.state.users.get(senderId);
178
+ const previousChannel = params.state.channels.get(channelId);
179
+ const senderName = readTrimmedString(params.body.senderName) ?? previousUser?.username ?? senderId;
180
+ const channelType = readTrimmedString(params.body.channelType) ?? previousChannel?.type ?? "D";
181
+ const previousNextPost = params.state.nextPost;
87
182
  params.state.users.set(senderId, { id: senderId, update_at: Date.now(), username: senderName });
88
183
  params.state.channels.set(channelId, {
89
- display_name: channelId,
184
+ display_name: previousChannel?.display_name ?? channelId,
90
185
  id: channelId,
91
- name: channelId,
186
+ name: previousChannel?.name ?? channelId,
92
187
  type: channelType,
93
188
  });
94
189
  const post = createPost({
@@ -98,7 +193,31 @@ async function handleAdminInbound(params) {
98
193
  state: params.state,
99
194
  userId: senderId,
100
195
  });
101
- broadcast(params.state, postEvent("posted", post, senderName, channelType));
196
+ const rollback = () => {
197
+ params.state.posts.delete(post.id);
198
+ params.state.nextPost = previousNextPost;
199
+ if (previousUser) {
200
+ params.state.users.set(senderId, previousUser);
201
+ }
202
+ else {
203
+ params.state.users.delete(senderId);
204
+ }
205
+ if (previousChannel) {
206
+ params.state.channels.set(channelId, previousChannel);
207
+ }
208
+ else {
209
+ params.state.channels.delete(channelId);
210
+ }
211
+ };
212
+ const event = postEvent("posted", post, senderName, channelType);
213
+ if (webSocketEventBytes(event) > params.state.maxWebSocketBufferedBytes) {
214
+ rollback();
215
+ return jsonResponse({ error: "Inbound event is too large", ok: false }, 413);
216
+ }
217
+ if (!broadcast(params.state, event)) {
218
+ rollback();
219
+ return pendingQueueFullResponse(params.state);
220
+ }
102
221
  return jsonResponse({ ok: true, post });
103
222
  }
104
223
  async function handleApi(params) {
@@ -108,7 +227,10 @@ async function handleApi(params) {
108
227
  }
109
228
  const usernameMatch = /^\/users\/username\/([^/]+)$/u.exec(apiPath);
110
229
  if (method === "GET" && usernameMatch?.[1]) {
111
- const username = decodeURIComponent(usernameMatch[1]);
230
+ const username = decodeMattermostPathSegment(usernameMatch[1]);
231
+ if (username instanceof Response) {
232
+ return username;
233
+ }
112
234
  const user = [...state.users.values()].find((entry) => entry.username === username);
113
235
  return user ? jsonResponse(user) : mattermostError("User not found", 404);
114
236
  }
@@ -123,20 +245,36 @@ async function handleApi(params) {
123
245
  return channel ? jsonResponse(channel) : mattermostError("Channel not found", 404);
124
246
  }
125
247
  if (method === "POST" && apiPath === "/channels/direct") {
126
- const userIds = Array.isArray(body) ? body : [];
127
- if (userIds.length !== 2 || userIds.some((value) => typeof value !== "string" || !value)) {
248
+ const userIds = Array.isArray(body) ? body.map(readTrimmedString) : [];
249
+ if (userIds.length !== 2 || userIds.some((value) => !value)) {
128
250
  return mattermostError("Two user IDs are required", 400);
129
251
  }
130
- const channelId = mattermostId(`dm:${userIds.map(String).sort().join(":")}`);
252
+ const [firstUserId, secondUserId] = userIds;
253
+ if (firstUserId === secondUserId) {
254
+ return mattermostError("Direct channel users must be distinct", 400);
255
+ }
256
+ if (!state.users.has(firstUserId) || !state.users.has(secondUserId)) {
257
+ return mattermostError("User not found", 404);
258
+ }
259
+ if (firstUserId !== state.botUserId && secondUserId !== state.botUserId) {
260
+ return mattermostError("Authenticated user must belong to the direct channel", 403);
261
+ }
262
+ const channelId = mattermostId(`dm:${userIds.sort().join(":")}`);
131
263
  const channel = { display_name: "", id: channelId, name: channelId, type: "D" };
132
264
  state.channels.set(channelId, channel);
133
265
  return jsonResponse(channel, 201);
134
266
  }
267
+ if (!isJsonObject(body)) {
268
+ return mattermostError("Request body must be a JSON object", 400);
269
+ }
135
270
  if (method === "POST" && apiPath === "/users/me/typing") {
136
271
  const channelId = readTrimmedString(body.channel_id);
137
272
  if (!channelId) {
138
273
  return mattermostError("channel_id is required", 400);
139
274
  }
275
+ if (!state.channels.has(channelId)) {
276
+ return mattermostError("Channel not found", 404);
277
+ }
140
278
  broadcast(state, {
141
279
  broadcast: eventBroadcast({
142
280
  channelId,
@@ -150,8 +288,8 @@ async function handleApi(params) {
150
288
  user_id: state.botUserId,
151
289
  },
152
290
  event: "typing",
153
- });
154
- return jsonResponse({});
291
+ }, false);
292
+ return jsonResponse({ status: "OK" });
155
293
  }
156
294
  if (method === "POST" && apiPath === "/posts") {
157
295
  const channelId = readTrimmedString(body.channel_id);
@@ -159,15 +297,28 @@ async function handleApi(params) {
159
297
  if (!channelId || !message) {
160
298
  return mattermostError("channel_id and message are required", 400);
161
299
  }
300
+ const channel = state.channels.get(channelId);
301
+ if (!channel) {
302
+ return mattermostError("Channel not found", 404);
303
+ }
304
+ const rootId = readTrimmedString(body.root_id);
305
+ if (rootId) {
306
+ const rootPost = state.posts.get(rootId);
307
+ if (!rootPost) {
308
+ return mattermostError("Root post not found", 404);
309
+ }
310
+ if (rootPost.channel_id !== channelId) {
311
+ return mattermostError("Root post belongs to another channel", 400);
312
+ }
313
+ }
162
314
  const post = createPost({
163
315
  channelId,
164
316
  message,
165
- rootId: readTrimmedString(body.root_id),
317
+ rootId,
166
318
  state,
167
319
  userId: state.botUserId,
168
320
  });
169
- const channelType = state.channels.get(channelId)?.type ?? "O";
170
- broadcast(state, postEvent("posted", post, state.botUsername, channelType));
321
+ broadcast(state, postEvent("posted", post, state.botUsername, channel.type), false);
171
322
  return jsonResponse(post, 201);
172
323
  }
173
324
  const postMatch = /^\/posts\/([^/]+)$/u.exec(apiPath);
@@ -176,9 +327,12 @@ async function handleApi(params) {
176
327
  if (!post) {
177
328
  return mattermostError("Post not found", 404);
178
329
  }
330
+ if (post.user_id !== state.botUserId) {
331
+ return mattermostError("You do not have permission to edit this post", 403);
332
+ }
179
333
  const updated = { ...post, message: readTrimmedString(body.message) ?? post.message };
180
334
  state.posts.set(updated.id, updated);
181
- broadcast(state, postEvent("post_edited", updated, state.botUsername, state.channels.get(updated.channel_id)?.type ?? "O"));
335
+ broadcast(state, postEvent("post_edited", updated, state.botUsername, state.channels.get(updated.channel_id)?.type ?? "O"), false);
182
336
  return jsonResponse(updated);
183
337
  }
184
338
  if (postMatch?.[1] && method === "DELETE") {
@@ -186,9 +340,12 @@ async function handleApi(params) {
186
340
  if (!post) {
187
341
  return mattermostError("Post not found", 404);
188
342
  }
343
+ if (post.user_id !== state.botUserId) {
344
+ return mattermostError("You do not have permission to delete this post", 403);
345
+ }
189
346
  state.posts.delete(post.id);
190
- broadcast(state, postEvent("post_deleted", post, state.botUsername, state.channels.get(post.channel_id)?.type ?? "O"));
191
- return new Response(null, { status: 204 });
347
+ broadcast(state, postEvent("post_deleted", post, state.botUsername, state.channels.get(post.channel_id)?.type ?? "O"), false);
348
+ return jsonResponse({ status: "OK" });
192
349
  }
193
350
  return mattermostError("Not found", 404);
194
351
  }
@@ -197,9 +354,13 @@ async function handleRequest(request, state) {
197
354
  const method = request.method ?? "GET";
198
355
  if (url.pathname === "/crabline/mattermost/inbound" && method === "POST") {
199
356
  if (!hasAdminToken(request, state.adminToken)) {
357
+ drainRequestBody(request);
200
358
  return adminAuthError();
201
359
  }
202
- const body = await parseRequestBody(request);
360
+ const body = await parseUnknownRequestBody(request);
361
+ if (!isJsonObject(body)) {
362
+ return jsonResponse({ error: "Request body must be a JSON object", ok: false }, 400);
363
+ }
203
364
  await appendEvent(state, {
204
365
  at: new Date().toISOString(),
205
366
  body,
@@ -208,27 +369,42 @@ async function handleRequest(request, state) {
208
369
  query: queryRecord(url),
209
370
  type: "admin",
210
371
  });
211
- return await handleAdminInbound({ body, state });
372
+ return handleAdminInbound({ body, state });
212
373
  }
213
374
  if (!url.pathname.startsWith("/api/v4/")) {
214
375
  return new Response("not found", { status: 404 });
215
376
  }
216
377
  if (!authorized(request, state.botToken)) {
378
+ drainRequestBody(request);
217
379
  return mattermostError("Invalid or missing token", 401);
218
380
  }
219
- const body = method === "GET" || method === "DELETE" ? {} : await parseRequestBody(request);
220
- await appendEvent(state, {
381
+ const body = method === "GET" || method === "DELETE" ? {} : await parseUnknownRequestBody(request);
382
+ const event = {
221
383
  at: new Date().toISOString(),
222
- ...(Object.keys(body).length > 0 ? { body } : {}),
384
+ ...(typeof body === "object" && body !== null && Object.keys(body).length > 0 ? { body } : {}),
223
385
  method,
224
386
  path: url.pathname,
225
387
  query: queryRecord(url),
226
388
  type: "api",
389
+ };
390
+ const response = await handleApi({
391
+ body,
392
+ method,
393
+ path: url.pathname.slice("/api/v4".length),
394
+ state,
227
395
  });
228
- return await handleApi({ body, method, path: url.pathname.slice("/api/v4".length), state });
396
+ if (method === "POST" && url.pathname === "/api/v4/posts") {
397
+ event.accepted = response.status === 201;
398
+ }
399
+ await appendEvent(state, event, response.ok && method !== "GET");
400
+ return response;
229
401
  }
230
402
  function attachWebSocketServer(params) {
231
- const websocketServer = new WebSocketServer({ noServer: true });
403
+ const websocketServer = new WebSocketServer({
404
+ maxPayload: params.maxMessageBytes,
405
+ noServer: true,
406
+ });
407
+ const unauthenticatedClients = new Set();
232
408
  const onUpgrade = (request, socket, head) => {
233
409
  if (new URL(request.url ?? "/", "http://localhost").pathname !== "/api/v4/websocket") {
234
410
  socket.destroy();
@@ -240,6 +416,21 @@ function attachWebSocketServer(params) {
240
416
  };
241
417
  params.server.on("upgrade", onUpgrade);
242
418
  websocketServer.on("connection", (client) => {
419
+ client.on("error", () => {
420
+ unauthenticatedClients.delete(client);
421
+ params.state.websocketClients.delete(client);
422
+ });
423
+ if (unauthenticatedClients.size >= params.maxUnauthenticatedClients) {
424
+ client.terminate();
425
+ return;
426
+ }
427
+ unauthenticatedClients.add(client);
428
+ let authenticationOpen = true;
429
+ const authenticationTimeout = setTimeout(() => {
430
+ authenticationOpen = false;
431
+ client.close(4001, "authentication timeout");
432
+ }, params.authenticationTimeoutMs);
433
+ authenticationTimeout.unref();
243
434
  client.on("message", (raw) => {
244
435
  let message;
245
436
  try {
@@ -249,19 +440,31 @@ function attachWebSocketServer(params) {
249
440
  client.close(1003, "invalid json");
250
441
  return;
251
442
  }
443
+ if (!isJsonObject(message)) {
444
+ client.close(1003, "invalid json");
445
+ return;
446
+ }
252
447
  const seq = message.seq ?? 0;
253
448
  if (!params.state.websocketClients.has(client)) {
449
+ if (!authenticationOpen || client.readyState !== WebSocket.OPEN) {
450
+ return;
451
+ }
254
452
  if (message.action !== "authentication_challenge" ||
255
453
  message.data?.token !== params.state.botToken) {
256
- client.send(JSON.stringify({
454
+ sendControlMessage(params.state, client, {
257
455
  error: { id: "api.context.unauthorized", message: "Authentication failed" },
258
456
  seq_reply: seq,
259
457
  status: "FAIL",
260
- }));
458
+ });
459
+ authenticationOpen = false;
460
+ client.close(4001, "authentication failed");
261
461
  return;
262
462
  }
463
+ clearTimeout(authenticationTimeout);
464
+ authenticationOpen = false;
465
+ unauthenticatedClients.delete(client);
263
466
  params.state.websocketClients.set(client, 0);
264
- client.send(JSON.stringify({ seq_reply: seq, status: "OK" }));
467
+ sendControlMessage(params.state, client, { seq_reply: seq, status: "OK" });
265
468
  sendEvent(params.state, client, {
266
469
  broadcast: eventBroadcast({ userId: params.state.botUserId }),
267
470
  data: {
@@ -270,56 +473,86 @@ function attachWebSocketServer(params) {
270
473
  },
271
474
  event: "hello",
272
475
  });
273
- for (const event of params.state.pendingEvents.splice(0)) {
274
- sendEvent(params.state, client, event);
476
+ const pending = params.state.pendingEvents.splice(0);
477
+ for (const [index, event] of pending.entries()) {
478
+ if (!sendEvent(params.state, client, event)) {
479
+ params.state.pendingEvents.unshift(...pending.slice(index));
480
+ break;
481
+ }
275
482
  }
276
483
  return;
277
484
  }
278
- if (message.action === "user_typing" && message.data?.channel_id) {
485
+ if (message.action === "user_typing") {
486
+ const channelId = readTrimmedString(message.data?.channel_id);
487
+ if (!channelId || !params.state.channels.has(channelId)) {
488
+ sendControlMessage(params.state, client, {
489
+ error: {
490
+ id: "api.channel.get.find.app_error",
491
+ message: "Channel not found",
492
+ },
493
+ seq_reply: seq,
494
+ status: "FAIL",
495
+ });
496
+ return;
497
+ }
279
498
  broadcast(params.state, {
280
499
  broadcast: eventBroadcast({
281
- channelId: message.data.channel_id,
500
+ channelId,
282
501
  omitUsers: { [params.state.botUserId]: true },
283
502
  }),
284
503
  data: {
285
- parent_id: message.data.parent_id ?? "",
504
+ channel_id: channelId,
505
+ parent_id: readTrimmedString(message.data?.parent_id) ?? "",
286
506
  user_id: params.state.botUserId,
287
507
  },
288
508
  event: "typing",
289
- });
290
- client.send(JSON.stringify({ seq_reply: seq, status: "OK" }));
509
+ }, false);
510
+ sendControlMessage(params.state, client, { seq_reply: seq, status: "OK" });
291
511
  return;
292
512
  }
293
513
  if (message.action === "ping") {
294
- client.send(JSON.stringify({ data: { text: "pong" }, seq_reply: seq, status: "OK" }));
514
+ sendControlMessage(params.state, client, {
515
+ data: { text: "pong" },
516
+ seq_reply: seq,
517
+ status: "OK",
518
+ });
295
519
  return;
296
520
  }
297
- client.send(JSON.stringify({
521
+ sendControlMessage(params.state, client, {
298
522
  error: { id: "api.websocket.invalid_action", message: "Unsupported action" },
299
523
  seq_reply: seq,
300
524
  status: "FAIL",
301
- }));
525
+ });
526
+ });
527
+ client.once("close", () => {
528
+ authenticationOpen = false;
529
+ clearTimeout(authenticationTimeout);
530
+ unauthenticatedClients.delete(client);
531
+ params.state.websocketClients.delete(client);
302
532
  });
303
- client.once("close", () => params.state.websocketClients.delete(client));
304
533
  });
305
534
  return async () => {
306
535
  params.server.off("upgrade", onUpgrade);
307
- for (const client of websocketServer.clients) {
308
- client.close();
309
- }
310
- await new Promise((resolve, reject) => websocketServer.close((error) => (error ? reject(error) : resolve())));
536
+ await closeWebSocketServer(websocketServer);
311
537
  };
312
538
  }
313
539
  export async function startMattermostServer(params = {}) {
540
+ const host = params.host ?? "127.0.0.1";
314
541
  const botUserId = params.botUserId ?? mattermostId("crabline-mattermost-bot");
315
542
  const botUsername = params.botUsername ?? "crabline_bot";
543
+ const maxWebSocketMessageBytes = resolvePositiveLimit(params.maxWebSocketMessageBytes, "maxWebSocketMessageBytes", DEFAULT_MAX_WEBSOCKET_MESSAGE_BYTES);
544
+ const maxUnauthenticatedWebSocketClients = resolvePositiveLimit(params.maxUnauthenticatedWebSocketClients, "maxUnauthenticatedWebSocketClients", DEFAULT_MAX_UNAUTHENTICATED_WEBSOCKET_CLIENTS);
316
545
  const state = {
317
546
  adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
318
- botToken: params.botToken ?? "crabline-mattermost-token",
547
+ botToken: params.botToken ??
548
+ (isLoopbackHost(host) ? "crabline-mattermost-token" : randomBytes(13).toString("hex")),
319
549
  botUserId,
320
550
  botUsername,
321
551
  channels: new Map(),
552
+ maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
553
+ maxWebSocketBufferedBytes: resolvePositiveLimit(params.maxWebSocketBufferedBytes, "maxWebSocketBufferedBytes", DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES),
322
554
  nextPost: 1,
555
+ onEvent: params.onEvent,
323
556
  pendingEvents: [],
324
557
  posts: new Map(),
325
558
  recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "mattermost.jsonl"),
@@ -328,14 +561,29 @@ export async function startMattermostServer(params = {}) {
328
561
  };
329
562
  const httpServer = await startHttpJsonServer({
330
563
  handle: (request) => handleRequest(request, state),
331
- host: params.host ?? "127.0.0.1",
564
+ handleError: (error) => {
565
+ if (error instanceof InvalidJsonBodyError) {
566
+ return mattermostError("Request body is not valid JSON", 400);
567
+ }
568
+ if (error instanceof RequestBodyTooLargeError) {
569
+ return mattermostError("Request body is too large", 413);
570
+ }
571
+ return undefined;
572
+ },
573
+ host,
332
574
  port: params.port ?? 0,
333
575
  serverName: "Mattermost",
334
576
  });
335
- const closeWebSocketServer = attachWebSocketServer({ server: httpServer.server, state });
577
+ const closeMattermostWebSocketServer = attachWebSocketServer({
578
+ authenticationTimeoutMs: params.websocketAuthenticationTimeoutMs ?? DEFAULT_WEBSOCKET_AUTHENTICATION_TIMEOUT_MS,
579
+ maxMessageBytes: maxWebSocketMessageBytes,
580
+ maxUnauthenticatedClients: maxUnauthenticatedWebSocketClients,
581
+ server: httpServer.server,
582
+ state,
583
+ });
336
584
  return {
337
585
  async close() {
338
- await closeWebSocketServer();
586
+ await closeMattermostWebSocketServer();
339
587
  await httpServer.close();
340
588
  },
341
589
  manifest: {