@openclaw/crabline 0.1.11 → 0.1.13

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 (214) hide show
  1. package/README.md +137 -23
  2. package/dist/src/bin/crabline.js +3 -1
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +5 -1
  5. package/dist/src/cli/program.js +281 -52
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +39 -3
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +20 -0
  10. package/dist/src/config/schema.js +163 -35
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.d.ts +1 -0
  13. package/dist/src/core/errors.js +6 -3
  14. package/dist/src/core/errors.js.map +1 -1
  15. package/dist/src/core/http-path.d.ts +1 -0
  16. package/dist/src/core/http-path.js +14 -0
  17. package/dist/src/core/http-path.js.map +1 -0
  18. package/dist/src/core/matcher.d.ts +3 -1
  19. package/dist/src/core/matcher.js +58 -2
  20. package/dist/src/core/matcher.js.map +1 -1
  21. package/dist/src/core/nonces.d.ts +2 -0
  22. package/dist/src/core/nonces.js +11 -1
  23. package/dist/src/core/nonces.js.map +1 -1
  24. package/dist/src/core/reporters.d.ts +5 -0
  25. package/dist/src/core/reporters.js +26 -2
  26. package/dist/src/core/reporters.js.map +1 -1
  27. package/dist/src/core/run.d.ts +3 -1
  28. package/dist/src/core/run.js +178 -66
  29. package/dist/src/core/run.js.map +1 -1
  30. package/dist/src/index.d.ts +12 -10
  31. package/dist/src/index.js +6 -5
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/matrix-ids.d.ts +5 -0
  34. package/dist/src/matrix-ids.js +83 -0
  35. package/dist/src/matrix-ids.js.map +1 -0
  36. package/dist/src/openclaw/artifact-generation.d.ts +7 -6
  37. package/dist/src/openclaw/artifact-generation.js +385 -41
  38. package/dist/src/openclaw/artifact-generation.js.map +1 -1
  39. package/dist/src/openclaw/bridges/discord.d.ts +1 -0
  40. package/dist/src/openclaw/bridges/discord.js +149 -0
  41. package/dist/src/openclaw/bridges/discord.js.map +1 -0
  42. package/dist/src/openclaw/bridges/matrix.js +22 -5
  43. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  44. package/dist/src/openclaw/bridges/mattermost.js +14 -9
  45. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  46. package/dist/src/openclaw/bridges/probe-response.d.ts +1 -0
  47. package/dist/src/openclaw/bridges/probe-response.js +5 -0
  48. package/dist/src/openclaw/bridges/probe-response.js.map +1 -0
  49. package/dist/src/openclaw/bridges/signal.js +16 -7
  50. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  51. package/dist/src/openclaw/bridges/slack.js +117 -24
  52. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  53. package/dist/src/openclaw/bridges/telegram.js +46 -20
  54. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  55. package/dist/src/openclaw/bridges/whatsapp.js +33 -17
  56. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  57. package/dist/src/openclaw/bridges/zalo.js +9 -4
  58. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  59. package/dist/src/openclaw/outbound-contract.js +3 -0
  60. package/dist/src/openclaw/outbound-contract.js.map +1 -1
  61. package/dist/src/openclaw/private-file.d.ts +38 -3
  62. package/dist/src/openclaw/private-file.js +2008 -87
  63. package/dist/src/openclaw/private-file.js.map +1 -1
  64. package/dist/src/openclaw/{smoke-lock.d.ts → provider-readiness-lock.d.ts} +14 -5
  65. package/dist/src/openclaw/provider-readiness-lock.js +1320 -0
  66. package/dist/src/openclaw/provider-readiness-lock.js.map +1 -0
  67. package/dist/src/openclaw/shared.d.ts +10 -17
  68. package/dist/src/openclaw/shared.js +53 -19
  69. package/dist/src/openclaw/shared.js.map +1 -1
  70. package/dist/src/openclaw.d.ts +3 -5
  71. package/dist/src/openclaw.js +245 -60
  72. package/dist/src/openclaw.js.map +1 -1
  73. package/dist/src/platform/process-owned-lock.d.ts +11 -0
  74. package/dist/src/platform/process-owned-lock.js +1698 -0
  75. package/dist/src/platform/process-owned-lock.js.map +1 -0
  76. package/dist/src/platform/recorder-directory.d.ts +1 -0
  77. package/dist/src/platform/recorder-directory.js +14 -0
  78. package/dist/src/platform/recorder-directory.js.map +1 -0
  79. package/dist/src/platform/windows-acl.d.ts +18 -0
  80. package/dist/src/platform/windows-acl.js +1367 -0
  81. package/dist/src/platform/windows-acl.js.map +1 -0
  82. package/dist/src/platform/windows-lock-root.d.ts +13 -0
  83. package/dist/src/platform/windows-lock-root.js +98 -0
  84. package/dist/src/platform/windows-lock-root.js.map +1 -0
  85. package/dist/src/providers/builtin/discord.d.ts +4 -0
  86. package/dist/src/providers/builtin/discord.js +52 -9
  87. package/dist/src/providers/builtin/discord.js.map +1 -1
  88. package/dist/src/providers/builtin/external-webhook-auth.d.ts +1 -0
  89. package/dist/src/providers/builtin/external-webhook-auth.js +38 -1
  90. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -1
  91. package/dist/src/providers/builtin/feishu.d.ts +1 -0
  92. package/dist/src/providers/builtin/feishu.js +96 -26
  93. package/dist/src/providers/builtin/feishu.js.map +1 -1
  94. package/dist/src/providers/builtin/googlechat.d.ts +1 -0
  95. package/dist/src/providers/builtin/googlechat.js +95 -26
  96. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  97. package/dist/src/providers/builtin/imessage.js +60 -17
  98. package/dist/src/providers/builtin/imessage.js.map +1 -1
  99. package/dist/src/providers/builtin/loopback.js +88 -35
  100. package/dist/src/providers/builtin/loopback.js.map +1 -1
  101. package/dist/src/providers/builtin/matrix.d.ts +2 -2
  102. package/dist/src/providers/builtin/matrix.js +78 -21
  103. package/dist/src/providers/builtin/matrix.js.map +1 -1
  104. package/dist/src/providers/builtin/mattermost.d.ts +11 -7
  105. package/dist/src/providers/builtin/mattermost.js +138 -24
  106. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  107. package/dist/src/providers/builtin/msteams.d.ts +1 -0
  108. package/dist/src/providers/builtin/msteams.js +118 -38
  109. package/dist/src/providers/builtin/msteams.js.map +1 -1
  110. package/dist/src/providers/builtin/native-local-mock.js +4 -4
  111. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  112. package/dist/src/providers/builtin/script.js +1057 -146
  113. package/dist/src/providers/builtin/script.js.map +1 -1
  114. package/dist/src/providers/builtin/slack.d.ts +8 -1
  115. package/dist/src/providers/builtin/slack.js +327 -24
  116. package/dist/src/providers/builtin/slack.js.map +1 -1
  117. package/dist/src/providers/builtin/telegram.d.ts +1 -1
  118. package/dist/src/providers/builtin/telegram.js +55 -14
  119. package/dist/src/providers/builtin/telegram.js.map +1 -1
  120. package/dist/src/providers/builtin/whatsapp.d.ts +4 -3
  121. package/dist/src/providers/builtin/whatsapp.js +157 -29
  122. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  123. package/dist/src/providers/builtin/zalo.d.ts +5 -2
  124. package/dist/src/providers/builtin/zalo.js +31 -7
  125. package/dist/src/providers/builtin/zalo.js.map +1 -1
  126. package/dist/src/providers/catalog.d.ts +1 -1
  127. package/dist/src/providers/catalog.js +1 -1
  128. package/dist/src/providers/catalog.js.map +1 -1
  129. package/dist/src/providers/local-mock.d.ts +4 -0
  130. package/dist/src/providers/local-mock.js +162 -35
  131. package/dist/src/providers/local-mock.js.map +1 -1
  132. package/dist/src/providers/native-ids.d.ts +2 -0
  133. package/dist/src/providers/native-ids.js +3 -0
  134. package/dist/src/providers/native-ids.js.map +1 -1
  135. package/dist/src/providers/recorder.d.ts +14 -3
  136. package/dist/src/providers/recorder.js +521 -41
  137. package/dist/src/providers/recorder.js.map +1 -1
  138. package/dist/src/providers/registry.d.ts +1 -0
  139. package/dist/src/providers/registry.js +65 -20
  140. package/dist/src/providers/registry.js.map +1 -1
  141. package/dist/src/providers/signed-jwt.d.ts +6 -1
  142. package/dist/src/providers/signed-jwt.js +241 -50
  143. package/dist/src/providers/signed-jwt.js.map +1 -1
  144. package/dist/src/providers/slack-ids.d.ts +1 -0
  145. package/dist/src/providers/slack-ids.js +5 -0
  146. package/dist/src/providers/slack-ids.js.map +1 -1
  147. package/dist/src/providers/target-normalizers.d.ts +9 -1
  148. package/dist/src/providers/target-normalizers.js +177 -58
  149. package/dist/src/providers/target-normalizers.js.map +1 -1
  150. package/dist/src/providers/webhook-server.d.ts +3 -0
  151. package/dist/src/providers/webhook-server.js +268 -47
  152. package/dist/src/providers/webhook-server.js.map +1 -1
  153. package/dist/src/servers/discord.d.ts +40 -0
  154. package/dist/src/servers/discord.js +842 -0
  155. package/dist/src/servers/discord.js.map +1 -0
  156. package/dist/src/servers/http.d.ts +14 -2
  157. package/dist/src/servers/http.js +229 -58
  158. package/dist/src/servers/http.js.map +1 -1
  159. package/dist/src/servers/index.d.ts +10 -4
  160. package/dist/src/servers/index.js +4 -0
  161. package/dist/src/servers/index.js.map +1 -1
  162. package/dist/src/servers/matrix.d.ts +3 -0
  163. package/dist/src/servers/matrix.js +268 -110
  164. package/dist/src/servers/matrix.js.map +1 -1
  165. package/dist/src/servers/mattermost.d.ts +5 -0
  166. package/dist/src/servers/mattermost.js +389 -91
  167. package/dist/src/servers/mattermost.js.map +1 -1
  168. package/dist/src/servers/recorder.d.ts +11 -0
  169. package/dist/src/servers/recorder.js +1072 -69
  170. package/dist/src/servers/recorder.js.map +1 -1
  171. package/dist/src/servers/signal.d.ts +1 -0
  172. package/dist/src/servers/signal.js +279 -44
  173. package/dist/src/servers/signal.js.map +1 -1
  174. package/dist/src/servers/slack.d.ts +15 -0
  175. package/dist/src/servers/slack.js +270 -54
  176. package/dist/src/servers/slack.js.map +1 -1
  177. package/dist/src/servers/telegram-identity.d.ts +6 -0
  178. package/dist/src/servers/telegram-identity.js +27 -0
  179. package/dist/src/servers/telegram-identity.js.map +1 -0
  180. package/dist/src/servers/telegram.d.ts +17 -6
  181. package/dist/src/servers/telegram.js +1218 -107
  182. package/dist/src/servers/telegram.js.map +1 -1
  183. package/dist/src/servers/webhook-target.d.ts +12 -4
  184. package/dist/src/servers/webhook-target.js +226 -28
  185. package/dist/src/servers/webhook-target.js.map +1 -1
  186. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +60 -1
  187. package/dist/src/servers/whatsapp-baileys-websocket.js +782 -112
  188. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  189. package/dist/src/servers/whatsapp-jid.d.ts +1 -0
  190. package/dist/src/servers/whatsapp-jid.js +9 -5
  191. package/dist/src/servers/whatsapp-jid.js.map +1 -1
  192. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +1 -0
  193. package/dist/src/servers/whatsapp-wire/binary-node.js +37 -13
  194. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  195. package/dist/src/servers/whatsapp-wire/crypto.d.ts +3 -0
  196. package/dist/src/servers/whatsapp-wire/crypto.js +23 -8
  197. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  198. package/dist/src/servers/whatsapp-wire/handshake.d.ts +9 -3
  199. package/dist/src/servers/whatsapp-wire/handshake.js +173 -12
  200. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  201. package/dist/src/servers/whatsapp-wire/signal.js +3 -3
  202. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  203. package/dist/src/servers/whatsapp.d.ts +7 -1
  204. package/dist/src/servers/whatsapp.js +149 -25
  205. package/dist/src/servers/whatsapp.js.map +1 -1
  206. package/dist/src/servers/zalo.d.ts +1 -0
  207. package/dist/src/servers/zalo.js +193 -82
  208. package/dist/src/servers/zalo.js.map +1 -1
  209. package/docs/channel-setup.md +251 -64
  210. package/fixtures/examples/crabline.example.yaml +3 -2
  211. package/package.json +18 -10
  212. package/dist/src/openclaw/smoke-lock.js +0 -1004
  213. package/dist/src/openclaw/smoke-lock.js.map +0 -1
  214. package/fixtures/examples/openclaw-bridge.yaml +0 -125
@@ -1,7 +1,7 @@
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, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, jsonResponse, parseUnknownRequestBody, queryRecord, readTrimmedString, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
4
+ import { adminAuthError, constantTimeTokenEqual, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, isJsonMediaType, isLoopbackHost, jsonResponse, parseUnknownRequestBody, queryRecord, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
5
5
  import { recordCommittedServerEvent, recordServerEvent, } from "./recorder.js";
6
6
  import { resolveMaxPendingInboundEvents } from "./pending-events.js";
7
7
  import { closeWebSocketServer } from "./websocket.js";
@@ -9,6 +9,15 @@ const DEFAULT_WEBSOCKET_AUTHENTICATION_TIMEOUT_MS = 5_000;
9
9
  const DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES = 1024 * 1024;
10
10
  const DEFAULT_MAX_WEBSOCKET_MESSAGE_BYTES = 64 * 1024;
11
11
  const DEFAULT_MAX_UNAUTHENTICATED_WEBSOCKET_CLIENTS = 32;
12
+ const DEFAULT_MAX_COMMITTED_CHANNELS = 1_000;
13
+ const DEFAULT_MAX_COMMITTED_POSTS = 1_000;
14
+ const DEFAULT_MAX_COMMITTED_STATE_BYTES = 64 * 1024 * 1024;
15
+ const DEFAULT_MAX_COMMITTED_USERS = 1_000;
16
+ const DEFAULT_MAX_PENDING_INBOUND_BYTES = 64 * 1024 * 1024;
17
+ const MATTERMOST_ID_PATTERN = /^[a-z0-9]{26}(?![\s\S])/u;
18
+ const MATTERMOST_USERNAME_PATTERN = /^[a-z0-9._-]{1,64}$/u;
19
+ const MATTERMOST_CHANNEL_TYPES = new Set(["D", "G", "O", "P"]);
20
+ const MATTERMOST_RESTRICTED_USERNAMES = new Set(["all", "channel", "matterbot", "system"]);
12
21
  function resolvePositiveLimit(value, name, fallback) {
13
22
  if (value === undefined) {
14
23
  return fallback;
@@ -26,17 +35,51 @@ async function appendEvent(state, event, committed = false) {
26
35
  await (committed ? recordCommittedServerEvent(params) : recordServerEvent(params));
27
36
  }
28
37
  function authorized(request, token) {
29
- const [scheme, value] = request.headers.authorization?.trim().split(/\s+/, 2) ?? [];
30
- return scheme?.toLowerCase() === "bearer" && value === token;
38
+ const providedToken = /^Bearer ([^\s]+)$/iu.exec(request.headers.authorization ?? "")?.[1];
39
+ return providedToken ? constantTimeTokenEqual(providedToken, token) : false;
31
40
  }
32
- function mattermostError(message, status) {
41
+ function mattermostError(message, status, options = {}) {
33
42
  return jsonResponse({
34
- id: `api.context.${status}`,
43
+ detailed_error: "",
44
+ id: options.id ?? `api.context.${status}.app_error`,
35
45
  message,
36
- request_id: "",
46
+ request_id: options.requestId ??
47
+ mattermostId(`request-${Date.now()}-${Math.random()}-${message}-${status}`),
37
48
  status_code: status,
38
49
  }, status);
39
50
  }
51
+ function readMattermostString(value) {
52
+ if (typeof value !== "string") {
53
+ return undefined;
54
+ }
55
+ const trimmed = value.trim();
56
+ return trimmed.length > 0 ? trimmed : undefined;
57
+ }
58
+ function readMattermostMessage(value) {
59
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
60
+ }
61
+ function readMattermostId(value) {
62
+ return typeof value === "string" && value.length > 0 ? value : undefined;
63
+ }
64
+ function isMattermostId(value) {
65
+ return MATTERMOST_ID_PATTERN.test(value);
66
+ }
67
+ function normalizeMattermostUsername(value) {
68
+ if (typeof value !== "string" || value !== value.trim()) {
69
+ return undefined;
70
+ }
71
+ const normalized = value.toLowerCase();
72
+ return MATTERMOST_USERNAME_PATTERN.test(normalized) &&
73
+ !MATTERMOST_RESTRICTED_USERNAMES.has(normalized)
74
+ ? normalized
75
+ : undefined;
76
+ }
77
+ function requestHasJsonMediaType(request) {
78
+ const contentType = request.headers["content-type"];
79
+ return Array.isArray(contentType)
80
+ ? contentType.some(isJsonMediaType)
81
+ : typeof contentType === "string" && isJsonMediaType(contentType);
82
+ }
40
83
  function decodeMattermostPathSegment(value) {
41
84
  try {
42
85
  return decodeURIComponent(value);
@@ -53,22 +96,48 @@ function eventBroadcast(params) {
53
96
  user_id: params.userId ?? "",
54
97
  };
55
98
  }
56
- function postEvent(event, post, senderName, channelType) {
99
+ function postEvent(event, post, senderName, channel) {
57
100
  return {
58
101
  event,
59
- data: {
60
- channel_display_name: post.channel_id,
61
- channel_id: post.channel_id,
62
- channel_name: post.channel_id,
63
- channel_type: channelType,
64
- post: JSON.stringify(post),
65
- sender_name: senderName,
66
- },
67
- broadcast: eventBroadcast({ channelId: post.channel_id, userId: post.user_id }),
102
+ data: event === "posted"
103
+ ? {
104
+ channel_display_name: channel.display_name,
105
+ channel_name: channel.name,
106
+ channel_type: channel.type,
107
+ post: JSON.stringify(post),
108
+ sender_name: senderName,
109
+ set_online: true,
110
+ team_id: "",
111
+ }
112
+ : { post: JSON.stringify(post) },
113
+ broadcast: eventBroadcast({ channelId: post.channel_id }),
68
114
  };
69
115
  }
70
116
  function webSocketEventBytes(event) {
71
- return Buffer.byteLength(JSON.stringify({ ...event, seq: 0 }), "utf8");
117
+ return Buffer.byteLength(JSON.stringify({ ...event, seq: Number.MAX_SAFE_INTEGER }), "utf8");
118
+ }
119
+ function retainedValueBytes(value) {
120
+ const serialized = JSON.stringify(value);
121
+ return serialized === undefined ? 0 : Buffer.byteLength(serialized, "utf8");
122
+ }
123
+ function retainedValueDelta(previous, next) {
124
+ return retainedValueBytes(next) - retainedValueBytes(previous);
125
+ }
126
+ function canRetainCommittedValues(state, replacements) {
127
+ const delta = replacements.reduce((total, [previous, next]) => total + retainedValueDelta(previous, next), 0);
128
+ return state.committedStateBytes + delta <= state.maxCommittedStateBytes;
129
+ }
130
+ function setCommittedValue(state, values, key, value) {
131
+ state.committedStateBytes += retainedValueDelta(values.get(key), value);
132
+ values.set(key, value);
133
+ }
134
+ function deleteCommittedValue(state, values, key) {
135
+ const previous = values.get(key);
136
+ if (previous === undefined) {
137
+ return;
138
+ }
139
+ state.committedStateBytes -= retainedValueBytes(previous);
140
+ values.delete(key);
72
141
  }
73
142
  function sendEvent(state, client, event) {
74
143
  const seq = state.websocketClients.get(client);
@@ -77,8 +146,11 @@ function sendEvent(state, client, event) {
77
146
  return false;
78
147
  }
79
148
  const payload = JSON.stringify({ ...event, seq });
80
- if (client.bufferedAmount + Buffer.byteLength(payload, "utf8") >
81
- state.maxWebSocketBufferedBytes) {
149
+ const payloadBytes = Buffer.byteLength(payload, "utf8");
150
+ if (payloadBytes > state.maxWebSocketBufferedBytes) {
151
+ return false;
152
+ }
153
+ if (client.bufferedAmount + payloadBytes > state.maxWebSocketBufferedBytes) {
82
154
  state.websocketClients.delete(client);
83
155
  client.close(1013, "client too slow");
84
156
  return false;
@@ -121,10 +193,6 @@ function broadcast(state, event, queueIfUndelivered = true) {
121
193
  return true;
122
194
  }
123
195
  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
196
  return false;
129
197
  }
130
198
  let delivered = false;
@@ -140,7 +208,12 @@ function broadcast(state, event, queueIfUndelivered = true) {
140
208
  if (state.pendingEvents.length >= state.maxPendingInboundEvents) {
141
209
  return false;
142
210
  }
211
+ const eventBytes = retainedValueBytes(event);
212
+ if (state.pendingEventBytes + eventBytes > state.maxPendingInboundBytes) {
213
+ return false;
214
+ }
143
215
  state.pendingEvents.push(event);
216
+ state.pendingEventBytes += eventBytes;
144
217
  return true;
145
218
  }
146
219
  function pendingQueueFullResponse(state) {
@@ -149,71 +222,207 @@ function pendingQueueFullResponse(state) {
149
222
  ok: false,
150
223
  }, 503);
151
224
  }
152
- function createPost(params) {
153
- const id = mattermostId(`post-${params.state.nextPost++}`);
154
- const post = {
225
+ function committedStateFullResponse(resource) {
226
+ return jsonResponse({
227
+ error: `Committed Mattermost ${resource} limit reached`,
228
+ ok: false,
229
+ }, 503);
230
+ }
231
+ function buildPost(params) {
232
+ const now = Date.now();
233
+ return {
155
234
  channel_id: params.channelId,
156
- create_at: Date.now(),
157
- id,
235
+ create_at: now,
236
+ delete_at: 0,
237
+ edit_at: 0,
238
+ id: params.id,
158
239
  message: params.message,
159
240
  root_id: params.rootId ?? "",
160
241
  type: "",
242
+ update_at: now,
161
243
  user_id: params.userId,
162
244
  };
163
- params.state.posts.set(id, post);
164
- return post;
245
+ }
246
+ function buildNextPost(state, params, reservedId) {
247
+ let nextPost = state.nextPost;
248
+ let id;
249
+ do {
250
+ id = mattermostId(`post-${nextPost++}`);
251
+ } while (id === reservedId || state.posts.has(id));
252
+ return { nextPost, post: buildPost({ ...params, id }) };
253
+ }
254
+ function commitNextPost(state, pending) {
255
+ state.nextPost = pending.nextPost;
256
+ setCommittedValue(state, state.posts, pending.post.id, pending.post);
257
+ }
258
+ function commitNextPostIfCapacity(state, pending) {
259
+ if (!canRetainCommittedValues(state, [[undefined, pending.post]])) {
260
+ return false;
261
+ }
262
+ commitNextPost(state, pending);
263
+ return true;
264
+ }
265
+ function nextDirectChannelId(state, seed) {
266
+ let collision = 0;
267
+ let channelId;
268
+ do {
269
+ channelId = mattermostId(collision === 0 ? seed : `${seed}:${collision}`);
270
+ collision += 1;
271
+ } while (state.channels.has(channelId));
272
+ return channelId;
273
+ }
274
+ function webSocketEventTooLargeResponse() {
275
+ return mattermostError("WebSocket event is too large", 413);
165
276
  }
166
277
  function handleAdminInbound(params) {
167
- const channelId = readTrimmedString(params.body.channelId ?? params.body.channel_id);
168
- const senderId = readTrimmedString(params.body.senderId ?? params.body.user_id);
169
- const text = readTrimmedString(params.body.text ?? params.body.message);
278
+ for (const field of ["senderName", "channelType", "rootId", "root_id"]) {
279
+ if (params.body[field] !== undefined && typeof params.body[field] !== "string") {
280
+ return jsonResponse({ error: `${field} must be a string`, ok: false }, 400);
281
+ }
282
+ }
283
+ const channelId = readMattermostId(params.body.channelId ?? params.body.channel_id);
284
+ const senderId = readMattermostId(params.body.senderId ?? params.body.user_id);
285
+ const text = readMattermostMessage(params.body.text ?? params.body.message);
170
286
  if (!channelId || !senderId || !text) {
171
287
  return jsonResponse({ error: "channelId, senderId, and text are required", ok: false }, 400);
172
288
  }
289
+ if (!isMattermostId(channelId)) {
290
+ return jsonResponse({ error: "channelId must be a 26-character Mattermost ID", ok: false }, 400);
291
+ }
292
+ if (!isMattermostId(senderId)) {
293
+ return jsonResponse({ error: "senderId must be a 26-character Mattermost ID", ok: false }, 400);
294
+ }
295
+ const rootId = readMattermostId(params.body.rootId ?? params.body.root_id);
296
+ if (rootId && !isMattermostId(rootId)) {
297
+ return jsonResponse({ error: "rootId must be a 26-character Mattermost ID", ok: false }, 400);
298
+ }
299
+ for (const field of [
300
+ "channelName",
301
+ "channel_name",
302
+ "channelDisplayName",
303
+ "channel_display_name",
304
+ ]) {
305
+ if (params.body[field] !== undefined && typeof params.body[field] !== "string") {
306
+ return jsonResponse({ error: `${field} must be a string`, ok: false }, 400);
307
+ }
308
+ }
173
309
  if (params.state.websocketClients.size === 0 &&
174
310
  params.state.pendingEvents.length >= params.state.maxPendingInboundEvents) {
175
311
  return pendingQueueFullResponse(params.state);
176
312
  }
177
313
  const previousUser = params.state.users.get(senderId);
178
314
  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;
182
- params.state.users.set(senderId, { id: senderId, update_at: Date.now(), username: senderName });
183
- params.state.channels.set(channelId, {
184
- display_name: previousChannel?.display_name ?? channelId,
315
+ const senderName = params.body.senderName === undefined
316
+ ? (previousUser?.username ?? senderId)
317
+ : normalizeMattermostUsername(params.body.senderName);
318
+ if (!senderName) {
319
+ return jsonResponse({ error: "senderName must be a valid Mattermost username", ok: false }, 400);
320
+ }
321
+ if (senderId === params.state.botUserId && senderName !== params.state.botUsername) {
322
+ return jsonResponse({ error: "senderName must match the configured bot username", ok: false }, 400);
323
+ }
324
+ const usernameOwner = params.state.userIdsByUsername.get(senderName);
325
+ if (usernameOwner && usernameOwner !== senderId) {
326
+ return jsonResponse({ error: "senderName is already in use", ok: false }, 400);
327
+ }
328
+ const channelType = params.body.channelType === undefined
329
+ ? (previousChannel?.type ?? "D")
330
+ : readMattermostString(params.body.channelType);
331
+ if (!channelType || !MATTERMOST_CHANNEL_TYPES.has(channelType)) {
332
+ return jsonResponse({ error: "channelType is not supported", ok: false }, 400);
333
+ }
334
+ const channelName = readMattermostString(params.body.channelName ?? params.body.channel_name) ??
335
+ previousChannel?.name ??
336
+ channelId;
337
+ const channelDisplayName = readMattermostString(params.body.channelDisplayName ?? params.body.channel_display_name) ??
338
+ previousChannel?.display_name ??
339
+ channelName;
340
+ const channel = {
341
+ display_name: channelDisplayName,
185
342
  id: channelId,
186
- name: previousChannel?.name ?? channelId,
343
+ name: channelName,
187
344
  type: channelType,
188
- });
189
- const post = createPost({
345
+ };
346
+ const existingRoot = rootId ? params.state.posts.get(rootId) : undefined;
347
+ if (existingRoot && existingRoot.channel_id !== channelId) {
348
+ return jsonResponse({ error: "Root post belongs to another channel", ok: false }, 400);
349
+ }
350
+ if (existingRoot?.root_id) {
351
+ return jsonResponse({ error: "Root post is itself a reply", ok: false }, 400);
352
+ }
353
+ if (!previousUser && params.state.users.size >= params.state.maxCommittedUsers) {
354
+ return committedStateFullResponse("users");
355
+ }
356
+ if (!previousChannel && params.state.channels.size >= params.state.maxCommittedChannels) {
357
+ return committedStateFullResponse("channels");
358
+ }
359
+ // buildNextPost always allocates a fresh ID, including against a synthesized root.
360
+ const requiredPostSlots = 1 + (rootId && !existingRoot ? 1 : 0);
361
+ if (params.state.posts.size + requiredPostSlots > params.state.maxCommittedPosts) {
362
+ return committedStateFullResponse("posts");
363
+ }
364
+ const rootPost = rootId && !existingRoot
365
+ ? buildPost({
366
+ channelId,
367
+ id: rootId,
368
+ message: "",
369
+ userId: senderId,
370
+ })
371
+ : undefined;
372
+ const pendingPost = buildNextPost(params.state, {
190
373
  channelId,
191
374
  message: text,
192
- rootId: readTrimmedString(params.body.rootId ?? params.body.root_id),
193
- state: params.state,
375
+ rootId,
194
376
  userId: senderId,
195
- });
377
+ }, rootPost?.id);
378
+ const { post } = pendingPost;
379
+ const user = { id: senderId, update_at: Date.now(), username: senderName };
380
+ if (!canRetainCommittedValues(params.state, [
381
+ [previousUser, user],
382
+ [previousChannel, channel],
383
+ [undefined, rootPost],
384
+ [undefined, post],
385
+ ])) {
386
+ return committedStateFullResponse("state");
387
+ }
388
+ const event = postEvent("posted", post, senderName, channel);
389
+ if (webSocketEventBytes(event) > params.state.maxWebSocketBufferedBytes) {
390
+ return jsonResponse({ error: "Inbound event is too large", ok: false }, 413);
391
+ }
392
+ const previousNextPost = params.state.nextPost;
393
+ if (previousUser?.username !== senderName) {
394
+ params.state.userIdsByUsername.delete(previousUser?.username ?? "");
395
+ }
396
+ params.state.userIdsByUsername.set(senderName, senderId);
397
+ setCommittedValue(params.state, params.state.users, senderId, user);
398
+ setCommittedValue(params.state, params.state.channels, channelId, channel);
399
+ if (rootPost) {
400
+ setCommittedValue(params.state, params.state.posts, rootPost.id, rootPost);
401
+ }
402
+ commitNextPost(params.state, pendingPost);
196
403
  const rollback = () => {
197
- params.state.posts.delete(post.id);
404
+ deleteCommittedValue(params.state, params.state.posts, post.id);
405
+ if (rootPost) {
406
+ deleteCommittedValue(params.state, params.state.posts, rootPost.id);
407
+ }
198
408
  params.state.nextPost = previousNextPost;
199
409
  if (previousUser) {
200
- params.state.users.set(senderId, previousUser);
410
+ setCommittedValue(params.state, params.state.users, senderId, previousUser);
411
+ params.state.userIdsByUsername.set(previousUser.username, senderId);
201
412
  }
202
413
  else {
203
- params.state.users.delete(senderId);
414
+ deleteCommittedValue(params.state, params.state.users, senderId);
415
+ }
416
+ if (previousUser?.username !== senderName) {
417
+ params.state.userIdsByUsername.delete(senderName);
204
418
  }
205
419
  if (previousChannel) {
206
- params.state.channels.set(channelId, previousChannel);
420
+ setCommittedValue(params.state, params.state.channels, channelId, previousChannel);
207
421
  }
208
422
  else {
209
- params.state.channels.delete(channelId);
423
+ deleteCommittedValue(params.state, params.state.channels, channelId);
210
424
  }
211
425
  };
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
426
  if (!broadcast(params.state, event)) {
218
427
  rollback();
219
428
  return pendingQueueFullResponse(params.state);
@@ -231,7 +440,8 @@ async function handleApi(params) {
231
440
  if (username instanceof Response) {
232
441
  return username;
233
442
  }
234
- const user = [...state.users.values()].find((entry) => entry.username === username);
443
+ const userId = state.userIdsByUsername.get(username.toLowerCase());
444
+ const user = userId ? state.users.get(userId) : undefined;
235
445
  return user ? jsonResponse(user) : mattermostError("User not found", 404);
236
446
  }
237
447
  const userMatch = /^\/users\/([^/]+)$/u.exec(apiPath);
@@ -245,7 +455,7 @@ async function handleApi(params) {
245
455
  return channel ? jsonResponse(channel) : mattermostError("Channel not found", 404);
246
456
  }
247
457
  if (method === "POST" && apiPath === "/channels/direct") {
248
- const userIds = Array.isArray(body) ? body.map(readTrimmedString) : [];
458
+ const userIds = Array.isArray(body) ? body.map(readMattermostId) : [];
249
459
  if (userIds.length !== 2 || userIds.some((value) => !value)) {
250
460
  return mattermostError("Two user IDs are required", 400);
251
461
  }
@@ -259,22 +469,38 @@ async function handleApi(params) {
259
469
  if (firstUserId !== state.botUserId && secondUserId !== state.botUserId) {
260
470
  return mattermostError("Authenticated user must belong to the direct channel", 403);
261
471
  }
262
- const channelId = mattermostId(`dm:${userIds.sort().join(":")}`);
263
- const channel = { display_name: "", id: channelId, name: channelId, type: "D" };
264
- state.channels.set(channelId, channel);
472
+ const participantIds = [...userIds].sort();
473
+ const channelName = participantIds.join("__");
474
+ const existingChannel = [...state.channels.values()].find((channel) => channel.type === "D" && channel.name === channelName);
475
+ if (existingChannel) {
476
+ return jsonResponse(existingChannel, 201);
477
+ }
478
+ if (state.channels.size >= state.maxCommittedChannels) {
479
+ return mattermostError("Too many retained channels", 503);
480
+ }
481
+ const channelId = nextDirectChannelId(state, `dm:${participantIds.join(":")}`);
482
+ const channel = { display_name: "", id: channelId, name: channelName, type: "D" };
483
+ if (!canRetainCommittedValues(state, [[undefined, channel]])) {
484
+ return mattermostError("Too much retained state", 503);
485
+ }
486
+ setCommittedValue(state, state.channels, channelId, channel);
265
487
  return jsonResponse(channel, 201);
266
488
  }
267
489
  if (!isJsonObject(body)) {
268
490
  return mattermostError("Request body must be a JSON object", 400);
269
491
  }
270
492
  if (method === "POST" && apiPath === "/users/me/typing") {
271
- const channelId = readTrimmedString(body.channel_id);
493
+ const channelId = readMattermostId(body.channel_id);
272
494
  if (!channelId) {
273
495
  return mattermostError("channel_id is required", 400);
274
496
  }
497
+ if (body.parent_id !== undefined && typeof body.parent_id !== "string") {
498
+ return mattermostError("parent_id must be a string", 400);
499
+ }
275
500
  if (!state.channels.has(channelId)) {
276
501
  return mattermostError("Channel not found", 404);
277
502
  }
503
+ const parentId = readMattermostId(body.parent_id);
278
504
  broadcast(state, {
279
505
  broadcast: eventBroadcast({
280
506
  channelId,
@@ -282,9 +508,7 @@ async function handleApi(params) {
282
508
  }),
283
509
  data: {
284
510
  channel_id: channelId,
285
- ...(readTrimmedString(body.parent_id)
286
- ? { parent_id: readTrimmedString(body.parent_id) }
287
- : {}),
511
+ ...(parentId ? { parent_id: parentId } : {}),
288
512
  user_id: state.botUserId,
289
513
  },
290
514
  event: "typing",
@@ -292,16 +516,19 @@ async function handleApi(params) {
292
516
  return jsonResponse({ status: "OK" });
293
517
  }
294
518
  if (method === "POST" && apiPath === "/posts") {
295
- const channelId = readTrimmedString(body.channel_id);
296
- const message = readTrimmedString(body.message);
519
+ const channelId = readMattermostId(body.channel_id);
520
+ const message = readMattermostMessage(body.message);
297
521
  if (!channelId || !message) {
298
522
  return mattermostError("channel_id and message are required", 400);
299
523
  }
524
+ const rootId = readMattermostId(body.root_id);
525
+ if (body.root_id !== undefined && typeof body.root_id !== "string") {
526
+ return mattermostError("root_id must be a string", 400);
527
+ }
300
528
  const channel = state.channels.get(channelId);
301
529
  if (!channel) {
302
530
  return mattermostError("Channel not found", 404);
303
531
  }
304
- const rootId = readTrimmedString(body.root_id);
305
532
  if (rootId) {
306
533
  const rootPost = state.posts.get(rootId);
307
534
  if (!rootPost) {
@@ -310,15 +537,25 @@ async function handleApi(params) {
310
537
  if (rootPost.channel_id !== channelId) {
311
538
  return mattermostError("Root post belongs to another channel", 400);
312
539
  }
540
+ if (rootPost.root_id) {
541
+ return mattermostError("Root post is itself a reply", 400);
542
+ }
313
543
  }
314
- const post = createPost({
544
+ if (state.posts.size >= state.maxCommittedPosts) {
545
+ return mattermostError("Too many retained posts", 503);
546
+ }
547
+ const pendingPost = buildNextPost(state, {
315
548
  channelId,
316
549
  message,
317
550
  rootId,
318
- state,
319
551
  userId: state.botUserId,
320
552
  });
321
- broadcast(state, postEvent("posted", post, state.botUsername, channel.type), false);
553
+ const { post } = pendingPost;
554
+ if (!commitNextPostIfCapacity(state, pendingPost)) {
555
+ return mattermostError("Too much retained state", 503);
556
+ }
557
+ const event = postEvent("posted", post, state.botUsername, channel);
558
+ broadcast(state, event, false);
322
559
  return jsonResponse(post, 201);
323
560
  }
324
561
  const postMatch = /^\/posts\/([^/]+)$/u.exec(apiPath);
@@ -330,9 +567,21 @@ async function handleApi(params) {
330
567
  if (post.user_id !== state.botUserId) {
331
568
  return mattermostError("You do not have permission to edit this post", 403);
332
569
  }
333
- const updated = { ...post, message: readTrimmedString(body.message) ?? post.message };
334
- state.posts.set(updated.id, updated);
335
- broadcast(state, postEvent("post_edited", updated, state.botUsername, state.channels.get(updated.channel_id)?.type ?? "O"), false);
570
+ const message = body.message === undefined ? post.message : readMattermostMessage(body.message);
571
+ if (!message) {
572
+ return mattermostError("message must be a string", 400);
573
+ }
574
+ const editedAt = Math.max(Date.now(), post.update_at + 1);
575
+ const updated = { ...post, edit_at: editedAt, message, update_at: editedAt };
576
+ if (!canRetainCommittedValues(state, [[post, updated]])) {
577
+ return mattermostError("Too much retained state", 503);
578
+ }
579
+ const event = postEvent("post_edited", updated, state.botUsername, state.channels.get(updated.channel_id));
580
+ if (webSocketEventBytes(event) > state.maxWebSocketBufferedBytes) {
581
+ return webSocketEventTooLargeResponse();
582
+ }
583
+ setCommittedValue(state, state.posts, updated.id, updated);
584
+ broadcast(state, event, false);
336
585
  return jsonResponse(updated);
337
586
  }
338
587
  if (postMatch?.[1] && method === "DELETE") {
@@ -343,8 +592,14 @@ async function handleApi(params) {
343
592
  if (post.user_id !== state.botUserId) {
344
593
  return mattermostError("You do not have permission to delete this post", 403);
345
594
  }
346
- state.posts.delete(post.id);
347
- broadcast(state, postEvent("post_deleted", post, state.botUsername, state.channels.get(post.channel_id)?.type ?? "O"), false);
595
+ const deletedAt = Math.max(Date.now(), post.update_at + 1);
596
+ const deletedPost = { ...post, delete_at: deletedAt, update_at: deletedAt };
597
+ const event = postEvent("post_deleted", deletedPost, state.botUsername, state.channels.get(deletedPost.channel_id));
598
+ if (webSocketEventBytes(event) > state.maxWebSocketBufferedBytes) {
599
+ return webSocketEventTooLargeResponse();
600
+ }
601
+ deleteCommittedValue(state, state.posts, post.id);
602
+ broadcast(state, event, false);
348
603
  return jsonResponse({ status: "OK" });
349
604
  }
350
605
  return mattermostError("Not found", 404);
@@ -352,6 +607,7 @@ async function handleApi(params) {
352
607
  async function handleRequest(request, state) {
353
608
  const url = new URL(request.url ?? "/", "http://localhost");
354
609
  const method = request.method ?? "GET";
610
+ const requestId = mattermostId(`request-${Date.now()}-${Math.random()}`);
355
611
  if (url.pathname === "/crabline/mattermost/inbound" && method === "POST") {
356
612
  if (!hasAdminToken(request, state.adminToken)) {
357
613
  drainRequestBody(request);
@@ -371,14 +627,29 @@ async function handleRequest(request, state) {
371
627
  });
372
628
  return handleAdminInbound({ body, state });
373
629
  }
374
- if (!url.pathname.startsWith("/api/v4/")) {
375
- return new Response("not found", { status: 404 });
630
+ if (url.pathname !== "/api/v4" && !url.pathname.startsWith("/api/v4/")) {
631
+ drainRequestBody(request);
632
+ return mattermostError("Sorry, we could not find the page.", 404, {
633
+ id: "api.context.404.app_error",
634
+ requestId,
635
+ });
376
636
  }
377
637
  if (!authorized(request, state.botToken)) {
378
638
  drainRequestBody(request);
379
- return mattermostError("Invalid or missing token", 401);
639
+ return mattermostError("Invalid or expired session, please login again.", 401, {
640
+ id: "api.context.session_expired.app_error",
641
+ requestId,
642
+ });
643
+ }
644
+ const bodylessMethod = method === "GET" || method === "DELETE";
645
+ if (bodylessMethod) {
646
+ drainRequestBody(request);
380
647
  }
381
- const body = method === "GET" || method === "DELETE" ? {} : await parseUnknownRequestBody(request);
648
+ const requiresJsonPost = method === "POST" && url.pathname === "/api/v4/posts" && !requestHasJsonMediaType(request);
649
+ if (requiresJsonPost) {
650
+ drainRequestBody(request);
651
+ }
652
+ const body = bodylessMethod || requiresJsonPost ? {} : await parseUnknownRequestBody(request);
382
653
  const event = {
383
654
  at: new Date().toISOString(),
384
655
  ...(typeof body === "object" && body !== null && Object.keys(body).length > 0 ? { body } : {}),
@@ -387,15 +658,21 @@ async function handleRequest(request, state) {
387
658
  query: queryRecord(url),
388
659
  type: "api",
389
660
  };
390
- const response = await handleApi({
391
- body,
392
- method,
393
- path: url.pathname.slice("/api/v4".length),
394
- state,
395
- });
661
+ const response = requiresJsonPost
662
+ ? mattermostError("Content-Type must be application/json.", 415, {
663
+ id: "api.context.unsupported_content_type.app_error",
664
+ requestId,
665
+ })
666
+ : await handleApi({
667
+ body,
668
+ method,
669
+ path: url.pathname.slice("/api/v4".length),
670
+ state,
671
+ });
396
672
  if (method === "POST" && url.pathname === "/api/v4/posts") {
397
673
  event.accepted = response.status === 201;
398
674
  }
675
+ event.accepted ??= response.ok;
399
676
  await appendEvent(state, event, response.ok && method !== "GET");
400
677
  return response;
401
678
  }
@@ -450,7 +727,8 @@ function attachWebSocketServer(params) {
450
727
  return;
451
728
  }
452
729
  if (message.action !== "authentication_challenge" ||
453
- message.data?.token !== params.state.botToken) {
730
+ typeof message.data?.token !== "string" ||
731
+ !constantTimeTokenEqual(message.data.token, params.state.botToken)) {
454
732
  sendControlMessage(params.state, client, {
455
733
  error: { id: "api.context.unauthorized", message: "Authentication failed" },
456
734
  seq_reply: seq,
@@ -474,16 +752,19 @@ function attachWebSocketServer(params) {
474
752
  event: "hello",
475
753
  });
476
754
  const pending = params.state.pendingEvents.splice(0);
755
+ params.state.pendingEventBytes = 0;
477
756
  for (const [index, event] of pending.entries()) {
478
757
  if (!sendEvent(params.state, client, event)) {
479
- params.state.pendingEvents.unshift(...pending.slice(index));
758
+ const remaining = pending.slice(index);
759
+ params.state.pendingEvents.unshift(...remaining);
760
+ params.state.pendingEventBytes = remaining.reduce((total, queuedEvent) => total + retainedValueBytes(queuedEvent), 0);
480
761
  break;
481
762
  }
482
763
  }
483
764
  return;
484
765
  }
485
766
  if (message.action === "user_typing") {
486
- const channelId = readTrimmedString(message.data?.channel_id);
767
+ const channelId = readMattermostId(message.data?.channel_id);
487
768
  if (!channelId || !params.state.channels.has(channelId)) {
488
769
  sendControlMessage(params.state, client, {
489
770
  error: {
@@ -502,7 +783,7 @@ function attachWebSocketServer(params) {
502
783
  }),
503
784
  data: {
504
785
  channel_id: channelId,
505
- parent_id: readTrimmedString(message.data?.parent_id) ?? "",
786
+ parent_id: readMattermostId(message.data?.parent_id) ?? "",
506
787
  user_id: params.state.botUserId,
507
788
  },
508
789
  event: "typing",
@@ -539,9 +820,18 @@ function attachWebSocketServer(params) {
539
820
  export async function startMattermostServer(params = {}) {
540
821
  const host = params.host ?? "127.0.0.1";
541
822
  const botUserId = params.botUserId ?? mattermostId("crabline-mattermost-bot");
542
- const botUsername = params.botUsername ?? "crabline_bot";
823
+ const botUsername = normalizeMattermostUsername(params.botUsername ?? "crabline_bot");
824
+ if (!botUsername) {
825
+ throw new Error("botUsername must be a valid Mattermost username.");
826
+ }
543
827
  const maxWebSocketMessageBytes = resolvePositiveLimit(params.maxWebSocketMessageBytes, "maxWebSocketMessageBytes", DEFAULT_MAX_WEBSOCKET_MESSAGE_BYTES);
544
828
  const maxUnauthenticatedWebSocketClients = resolvePositiveLimit(params.maxUnauthenticatedWebSocketClients, "maxUnauthenticatedWebSocketClients", DEFAULT_MAX_UNAUTHENTICATED_WEBSOCKET_CLIENTS);
829
+ const maxCommittedStateBytes = resolvePositiveLimit(params.maxCommittedStateBytes, "maxCommittedStateBytes", DEFAULT_MAX_COMMITTED_STATE_BYTES);
830
+ const botUser = { id: botUserId, update_at: Date.now(), username: botUsername };
831
+ const initialCommittedStateBytes = retainedValueBytes(botUser);
832
+ if (initialCommittedStateBytes > maxCommittedStateBytes) {
833
+ throw new Error("maxCommittedStateBytes cannot retain the configured bot user.");
834
+ }
545
835
  const state = {
546
836
  adminToken: params.adminToken ?? randomBytes(24).toString("base64url"),
547
837
  botToken: params.botToken ??
@@ -549,14 +839,22 @@ export async function startMattermostServer(params = {}) {
549
839
  botUserId,
550
840
  botUsername,
551
841
  channels: new Map(),
842
+ committedStateBytes: initialCommittedStateBytes,
843
+ maxCommittedChannels: resolvePositiveLimit(params.maxCommittedChannels, "maxCommittedChannels", DEFAULT_MAX_COMMITTED_CHANNELS),
844
+ maxCommittedPosts: resolvePositiveLimit(params.maxCommittedPosts, "maxCommittedPosts", DEFAULT_MAX_COMMITTED_POSTS),
845
+ maxCommittedStateBytes,
846
+ maxCommittedUsers: resolvePositiveLimit(params.maxCommittedUsers, "maxCommittedUsers", DEFAULT_MAX_COMMITTED_USERS),
847
+ maxPendingInboundBytes: resolvePositiveLimit(params.maxPendingInboundBytes, "maxPendingInboundBytes", DEFAULT_MAX_PENDING_INBOUND_BYTES),
552
848
  maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
553
849
  maxWebSocketBufferedBytes: resolvePositiveLimit(params.maxWebSocketBufferedBytes, "maxWebSocketBufferedBytes", DEFAULT_MAX_WEBSOCKET_BUFFERED_BYTES),
554
850
  nextPost: 1,
555
851
  onEvent: params.onEvent,
852
+ pendingEventBytes: 0,
556
853
  pendingEvents: [],
557
854
  posts: new Map(),
558
855
  recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "mattermost.jsonl"),
559
- users: new Map([[botUserId, { id: botUserId, update_at: Date.now(), username: botUsername }]]),
856
+ userIdsByUsername: new Map([[botUsername, botUserId]]),
857
+ users: new Map([[botUserId, botUser]]),
560
858
  websocketClients: new Map(),
561
859
  };
562
860
  const httpServer = await startHttpJsonServer({