@openclaw/crabline 0.1.9 → 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 (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 +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 +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 +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 +194 -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.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 +46 -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 +811 -152
  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,13 +1,29 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import path from "node:path";
3
3
  import { WebSocket, WebSocketServer } from "ws";
4
- import { adminAuthError, hasAdminToken, jsonResponse, parseRequestBody, queryRecord, readTrimmedString, startHttpJsonServer, } from "./http.js";
5
- import { recordServerEvent } from "./recorder.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 recordServerEvent({ event, onEvent: state.onEvent, recorderPath: state.recorderPath });
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));
11
27
  }
12
28
  function authorized(request, token) {
13
29
  const [scheme, value] = request.headers.authorization?.trim().split(/\s+/, 2) ?? [];
@@ -21,6 +37,14 @@ function mattermostError(message, status) {
21
37
  status_code: status,
22
38
  }, status);
23
39
  }
40
+ function decodeMattermostPathSegment(value) {
41
+ try {
42
+ return decodeURIComponent(value);
43
+ }
44
+ catch {
45
+ return mattermostError("Invalid path parameter", 400);
46
+ }
47
+ }
24
48
  function eventBroadcast(params) {
25
49
  return {
26
50
  channel_id: params.channelId ?? "",
@@ -43,22 +67,87 @@ function postEvent(event, post, senderName, channelType) {
43
67
  broadcast: eventBroadcast({ channelId: post.channel_id, userId: post.user_id }),
44
68
  };
45
69
  }
70
+ function webSocketEventBytes(event) {
71
+ return Buffer.byteLength(JSON.stringify({ ...event, seq: 0 }), "utf8");
72
+ }
46
73
  function sendEvent(state, client, event) {
47
74
  const seq = state.websocketClients.get(client);
48
75
  if (seq === undefined || client.readyState !== WebSocket.OPEN) {
49
- 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;
50
93
  }
51
- client.send(JSON.stringify({ ...event, seq }));
52
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
+ }
53
118
  }
54
- function broadcast(state, event) {
55
- if (state.websocketClients.size === 0) {
56
- state.pendingEvents.push(event);
57
- 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;
58
129
  }
130
+ let delivered = false;
59
131
  for (const client of state.websocketClients.keys()) {
60
- sendEvent(state, client, event);
132
+ delivered = sendEvent(state, client, event) || delivered;
61
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);
62
151
  }
63
152
  function createPost(params) {
64
153
  const id = mattermostId(`post-${params.state.nextPost++}`);
@@ -74,20 +163,27 @@ function createPost(params) {
74
163
  params.state.posts.set(id, post);
75
164
  return post;
76
165
  }
77
- async function handleAdminInbound(params) {
166
+ function handleAdminInbound(params) {
78
167
  const channelId = readTrimmedString(params.body.channelId ?? params.body.channel_id);
79
168
  const senderId = readTrimmedString(params.body.senderId ?? params.body.user_id);
80
169
  const text = readTrimmedString(params.body.text ?? params.body.message);
81
170
  if (!channelId || !senderId || !text) {
82
171
  return jsonResponse({ error: "channelId, senderId, and text are required", ok: false }, 400);
83
172
  }
84
- const senderName = readTrimmedString(params.body.senderName) ?? senderId;
85
- 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;
86
182
  params.state.users.set(senderId, { id: senderId, update_at: Date.now(), username: senderName });
87
183
  params.state.channels.set(channelId, {
88
- display_name: channelId,
184
+ display_name: previousChannel?.display_name ?? channelId,
89
185
  id: channelId,
90
- name: channelId,
186
+ name: previousChannel?.name ?? channelId,
91
187
  type: channelType,
92
188
  });
93
189
  const post = createPost({
@@ -97,7 +193,31 @@ async function handleAdminInbound(params) {
97
193
  state: params.state,
98
194
  userId: senderId,
99
195
  });
100
- 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
+ }
101
221
  return jsonResponse({ ok: true, post });
102
222
  }
103
223
  async function handleApi(params) {
@@ -107,7 +227,10 @@ async function handleApi(params) {
107
227
  }
108
228
  const usernameMatch = /^\/users\/username\/([^/]+)$/u.exec(apiPath);
109
229
  if (method === "GET" && usernameMatch?.[1]) {
110
- const username = decodeURIComponent(usernameMatch[1]);
230
+ const username = decodeMattermostPathSegment(usernameMatch[1]);
231
+ if (username instanceof Response) {
232
+ return username;
233
+ }
111
234
  const user = [...state.users.values()].find((entry) => entry.username === username);
112
235
  return user ? jsonResponse(user) : mattermostError("User not found", 404);
113
236
  }
@@ -122,20 +245,36 @@ async function handleApi(params) {
122
245
  return channel ? jsonResponse(channel) : mattermostError("Channel not found", 404);
123
246
  }
124
247
  if (method === "POST" && apiPath === "/channels/direct") {
125
- const userIds = Array.isArray(body) ? body : [];
126
- 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)) {
127
250
  return mattermostError("Two user IDs are required", 400);
128
251
  }
129
- 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(":")}`);
130
263
  const channel = { display_name: "", id: channelId, name: channelId, type: "D" };
131
264
  state.channels.set(channelId, channel);
132
265
  return jsonResponse(channel, 201);
133
266
  }
267
+ if (!isJsonObject(body)) {
268
+ return mattermostError("Request body must be a JSON object", 400);
269
+ }
134
270
  if (method === "POST" && apiPath === "/users/me/typing") {
135
271
  const channelId = readTrimmedString(body.channel_id);
136
272
  if (!channelId) {
137
273
  return mattermostError("channel_id is required", 400);
138
274
  }
275
+ if (!state.channels.has(channelId)) {
276
+ return mattermostError("Channel not found", 404);
277
+ }
139
278
  broadcast(state, {
140
279
  broadcast: eventBroadcast({
141
280
  channelId,
@@ -149,8 +288,8 @@ async function handleApi(params) {
149
288
  user_id: state.botUserId,
150
289
  },
151
290
  event: "typing",
152
- });
153
- return jsonResponse({});
291
+ }, false);
292
+ return jsonResponse({ status: "OK" });
154
293
  }
155
294
  if (method === "POST" && apiPath === "/posts") {
156
295
  const channelId = readTrimmedString(body.channel_id);
@@ -158,15 +297,28 @@ async function handleApi(params) {
158
297
  if (!channelId || !message) {
159
298
  return mattermostError("channel_id and message are required", 400);
160
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
+ }
161
314
  const post = createPost({
162
315
  channelId,
163
316
  message,
164
- rootId: readTrimmedString(body.root_id),
317
+ rootId,
165
318
  state,
166
319
  userId: state.botUserId,
167
320
  });
168
- const channelType = state.channels.get(channelId)?.type ?? "O";
169
- broadcast(state, postEvent("posted", post, state.botUsername, channelType));
321
+ broadcast(state, postEvent("posted", post, state.botUsername, channel.type), false);
170
322
  return jsonResponse(post, 201);
171
323
  }
172
324
  const postMatch = /^\/posts\/([^/]+)$/u.exec(apiPath);
@@ -175,9 +327,12 @@ async function handleApi(params) {
175
327
  if (!post) {
176
328
  return mattermostError("Post not found", 404);
177
329
  }
330
+ if (post.user_id !== state.botUserId) {
331
+ return mattermostError("You do not have permission to edit this post", 403);
332
+ }
178
333
  const updated = { ...post, message: readTrimmedString(body.message) ?? post.message };
179
334
  state.posts.set(updated.id, updated);
180
- 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);
181
336
  return jsonResponse(updated);
182
337
  }
183
338
  if (postMatch?.[1] && method === "DELETE") {
@@ -185,9 +340,12 @@ async function handleApi(params) {
185
340
  if (!post) {
186
341
  return mattermostError("Post not found", 404);
187
342
  }
343
+ if (post.user_id !== state.botUserId) {
344
+ return mattermostError("You do not have permission to delete this post", 403);
345
+ }
188
346
  state.posts.delete(post.id);
189
- broadcast(state, postEvent("post_deleted", post, state.botUsername, state.channels.get(post.channel_id)?.type ?? "O"));
190
- 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" });
191
349
  }
192
350
  return mattermostError("Not found", 404);
193
351
  }
@@ -196,9 +354,13 @@ async function handleRequest(request, state) {
196
354
  const method = request.method ?? "GET";
197
355
  if (url.pathname === "/crabline/mattermost/inbound" && method === "POST") {
198
356
  if (!hasAdminToken(request, state.adminToken)) {
357
+ drainRequestBody(request);
199
358
  return adminAuthError();
200
359
  }
201
- 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
+ }
202
364
  await appendEvent(state, {
203
365
  at: new Date().toISOString(),
204
366
  body,
@@ -207,27 +369,42 @@ async function handleRequest(request, state) {
207
369
  query: queryRecord(url),
208
370
  type: "admin",
209
371
  });
210
- return await handleAdminInbound({ body, state });
372
+ return handleAdminInbound({ body, state });
211
373
  }
212
374
  if (!url.pathname.startsWith("/api/v4/")) {
213
375
  return new Response("not found", { status: 404 });
214
376
  }
215
377
  if (!authorized(request, state.botToken)) {
378
+ drainRequestBody(request);
216
379
  return mattermostError("Invalid or missing token", 401);
217
380
  }
218
- const body = method === "GET" || method === "DELETE" ? {} : await parseRequestBody(request);
219
- await appendEvent(state, {
381
+ const body = method === "GET" || method === "DELETE" ? {} : await parseUnknownRequestBody(request);
382
+ const event = {
220
383
  at: new Date().toISOString(),
221
- ...(Object.keys(body).length > 0 ? { body } : {}),
384
+ ...(typeof body === "object" && body !== null && Object.keys(body).length > 0 ? { body } : {}),
222
385
  method,
223
386
  path: url.pathname,
224
387
  query: queryRecord(url),
225
388
  type: "api",
389
+ };
390
+ const response = await handleApi({
391
+ body,
392
+ method,
393
+ path: url.pathname.slice("/api/v4".length),
394
+ state,
226
395
  });
227
- 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;
228
401
  }
229
402
  function attachWebSocketServer(params) {
230
- const websocketServer = new WebSocketServer({ noServer: true });
403
+ const websocketServer = new WebSocketServer({
404
+ maxPayload: params.maxMessageBytes,
405
+ noServer: true,
406
+ });
407
+ const unauthenticatedClients = new Set();
231
408
  const onUpgrade = (request, socket, head) => {
232
409
  if (new URL(request.url ?? "/", "http://localhost").pathname !== "/api/v4/websocket") {
233
410
  socket.destroy();
@@ -239,6 +416,21 @@ function attachWebSocketServer(params) {
239
416
  };
240
417
  params.server.on("upgrade", onUpgrade);
241
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();
242
434
  client.on("message", (raw) => {
243
435
  let message;
244
436
  try {
@@ -248,19 +440,31 @@ function attachWebSocketServer(params) {
248
440
  client.close(1003, "invalid json");
249
441
  return;
250
442
  }
443
+ if (!isJsonObject(message)) {
444
+ client.close(1003, "invalid json");
445
+ return;
446
+ }
251
447
  const seq = message.seq ?? 0;
252
448
  if (!params.state.websocketClients.has(client)) {
449
+ if (!authenticationOpen || client.readyState !== WebSocket.OPEN) {
450
+ return;
451
+ }
253
452
  if (message.action !== "authentication_challenge" ||
254
453
  message.data?.token !== params.state.botToken) {
255
- client.send(JSON.stringify({
454
+ sendControlMessage(params.state, client, {
256
455
  error: { id: "api.context.unauthorized", message: "Authentication failed" },
257
456
  seq_reply: seq,
258
457
  status: "FAIL",
259
- }));
458
+ });
459
+ authenticationOpen = false;
460
+ client.close(4001, "authentication failed");
260
461
  return;
261
462
  }
463
+ clearTimeout(authenticationTimeout);
464
+ authenticationOpen = false;
465
+ unauthenticatedClients.delete(client);
262
466
  params.state.websocketClients.set(client, 0);
263
- client.send(JSON.stringify({ seq_reply: seq, status: "OK" }));
467
+ sendControlMessage(params.state, client, { seq_reply: seq, status: "OK" });
264
468
  sendEvent(params.state, client, {
265
469
  broadcast: eventBroadcast({ userId: params.state.botUserId }),
266
470
  data: {
@@ -269,55 +473,84 @@ function attachWebSocketServer(params) {
269
473
  },
270
474
  event: "hello",
271
475
  });
272
- for (const event of params.state.pendingEvents.splice(0)) {
273
- 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
+ }
274
482
  }
275
483
  return;
276
484
  }
277
- 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
+ }
278
498
  broadcast(params.state, {
279
499
  broadcast: eventBroadcast({
280
- channelId: message.data.channel_id,
500
+ channelId,
281
501
  omitUsers: { [params.state.botUserId]: true },
282
502
  }),
283
503
  data: {
284
- parent_id: message.data.parent_id ?? "",
504
+ channel_id: channelId,
505
+ parent_id: readTrimmedString(message.data?.parent_id) ?? "",
285
506
  user_id: params.state.botUserId,
286
507
  },
287
508
  event: "typing",
288
- });
289
- client.send(JSON.stringify({ seq_reply: seq, status: "OK" }));
509
+ }, false);
510
+ sendControlMessage(params.state, client, { seq_reply: seq, status: "OK" });
290
511
  return;
291
512
  }
292
513
  if (message.action === "ping") {
293
- 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
+ });
294
519
  return;
295
520
  }
296
- client.send(JSON.stringify({
521
+ sendControlMessage(params.state, client, {
297
522
  error: { id: "api.websocket.invalid_action", message: "Unsupported action" },
298
523
  seq_reply: seq,
299
524
  status: "FAIL",
300
- }));
525
+ });
526
+ });
527
+ client.once("close", () => {
528
+ authenticationOpen = false;
529
+ clearTimeout(authenticationTimeout);
530
+ unauthenticatedClients.delete(client);
531
+ params.state.websocketClients.delete(client);
301
532
  });
302
- client.once("close", () => params.state.websocketClients.delete(client));
303
533
  });
304
534
  return async () => {
305
535
  params.server.off("upgrade", onUpgrade);
306
- for (const client of websocketServer.clients) {
307
- client.close();
308
- }
309
- await new Promise((resolve, reject) => websocketServer.close((error) => (error ? reject(error) : resolve())));
536
+ await closeWebSocketServer(websocketServer);
310
537
  };
311
538
  }
312
539
  export async function startMattermostServer(params = {}) {
540
+ const host = params.host ?? "127.0.0.1";
313
541
  const botUserId = params.botUserId ?? mattermostId("crabline-mattermost-bot");
314
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);
315
545
  const state = {
316
546
  adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
317
- botToken: params.botToken ?? "crabline-mattermost-token",
547
+ botToken: params.botToken ??
548
+ (isLoopbackHost(host) ? "crabline-mattermost-token" : randomBytes(13).toString("hex")),
318
549
  botUserId,
319
550
  botUsername,
320
551
  channels: new Map(),
552
+ maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
553
+ maxWebSocketBufferedBytes: resolvePositiveLimit(params.maxWebSocketBufferedBytes, "maxWebSocketBufferedBytes", DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES),
321
554
  nextPost: 1,
322
555
  onEvent: params.onEvent,
323
556
  pendingEvents: [],
@@ -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: {