@openclaw/crabline 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/README.md +47 -22
  2. package/assets/crabline-banner.svg +20 -0
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +25 -1
  5. package/dist/src/cli/program.js +579 -82
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +59 -6
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +116 -92
  10. package/dist/src/config/schema.js +179 -73
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.js +9 -2
  13. package/dist/src/core/errors.js.map +1 -1
  14. package/dist/src/core/exit-codes.js.map +1 -1
  15. package/dist/src/core/matcher.js +7 -6
  16. package/dist/src/core/matcher.js.map +1 -1
  17. package/dist/src/core/message-template.js.map +1 -1
  18. package/dist/src/core/nonces.d.ts +1 -0
  19. package/dist/src/core/nonces.js +4 -2
  20. package/dist/src/core/nonces.js.map +1 -1
  21. package/dist/src/core/reporters.d.ts +1 -0
  22. package/dist/src/core/reporters.js +42 -4
  23. package/dist/src/core/reporters.js.map +1 -1
  24. package/dist/src/core/run.d.ts +3 -0
  25. package/dist/src/core/run.js +415 -107
  26. package/dist/src/core/run.js.map +1 -1
  27. package/dist/src/core/safe-regex.d.ts +3 -0
  28. package/dist/src/core/safe-regex.js +21 -0
  29. package/dist/src/core/safe-regex.js.map +1 -0
  30. package/dist/src/index.d.ts +2 -2
  31. package/dist/src/index.js +1 -1
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/openclaw/artifact-generation.d.ts +43 -0
  34. package/dist/src/openclaw/artifact-generation.js +363 -0
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -0
  36. package/dist/src/openclaw/bridges/matrix.js +97 -12
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +32 -10
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/signal.js +55 -18
  41. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  42. package/dist/src/openclaw/bridges/slack.js +85 -7
  43. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  44. package/dist/src/openclaw/bridges/telegram.js +124 -30
  45. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  46. package/dist/src/openclaw/bridges/whatsapp.js +78 -17
  47. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  48. package/dist/src/openclaw/bridges/zalo.js +14 -5
  49. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  50. package/dist/src/openclaw/outbound-contract.d.ts +5 -0
  51. package/dist/src/openclaw/outbound-contract.js +40 -0
  52. package/dist/src/openclaw/outbound-contract.js.map +1 -0
  53. package/dist/src/openclaw/private-file.d.ts +28 -0
  54. package/dist/src/openclaw/private-file.js +465 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +21 -2
  57. package/dist/src/openclaw/shared.js +115 -18
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +63 -0
  60. package/dist/src/openclaw/smoke-lock.js +1004 -0
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -0
  62. package/dist/src/openclaw.d.ts +16 -8
  63. package/dist/src/openclaw.js +334 -52
  64. package/dist/src/openclaw.js.map +1 -1
  65. package/dist/src/providers/builtin/discord.d.ts +10 -1
  66. package/dist/src/providers/builtin/discord.js +144 -18
  67. package/dist/src/providers/builtin/discord.js.map +1 -1
  68. package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
  69. package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
  70. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
  71. package/dist/src/providers/builtin/feishu.d.ts +19 -2
  72. package/dist/src/providers/builtin/feishu.js +307 -25
  73. package/dist/src/providers/builtin/feishu.js.map +1 -1
  74. package/dist/src/providers/builtin/googlechat.d.ts +20 -1
  75. package/dist/src/providers/builtin/googlechat.js +178 -19
  76. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  77. package/dist/src/providers/builtin/imessage.d.ts +4 -0
  78. package/dist/src/providers/builtin/imessage.js +26 -11
  79. package/dist/src/providers/builtin/imessage.js.map +1 -1
  80. package/dist/src/providers/builtin/loopback.js +92 -30
  81. package/dist/src/providers/builtin/loopback.js.map +1 -1
  82. package/dist/src/providers/builtin/matrix.d.ts +11 -1
  83. package/dist/src/providers/builtin/matrix.js +53 -29
  84. package/dist/src/providers/builtin/matrix.js.map +1 -1
  85. package/dist/src/providers/builtin/mattermost.d.ts +10 -0
  86. package/dist/src/providers/builtin/mattermost.js +38 -15
  87. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  88. package/dist/src/providers/builtin/msteams.d.ts +17 -1
  89. package/dist/src/providers/builtin/msteams.js +113 -14
  90. package/dist/src/providers/builtin/msteams.js.map +1 -1
  91. package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
  92. package/dist/src/providers/builtin/native-local-mock.js +12 -35
  93. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  94. package/dist/src/providers/builtin/script.d.ts +11 -15
  95. package/dist/src/providers/builtin/script.js +566 -104
  96. package/dist/src/providers/builtin/script.js.map +1 -1
  97. package/dist/src/providers/builtin/slack.d.ts +6 -0
  98. package/dist/src/providers/builtin/slack.js +99 -40
  99. package/dist/src/providers/builtin/slack.js.map +1 -1
  100. package/dist/src/providers/builtin/telegram.d.ts +6 -0
  101. package/dist/src/providers/builtin/telegram.js +64 -43
  102. package/dist/src/providers/builtin/telegram.js.map +1 -1
  103. package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
  104. package/dist/src/providers/builtin/whatsapp.js +567 -30
  105. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  106. package/dist/src/providers/builtin/zalo.js +30 -19
  107. package/dist/src/providers/builtin/zalo.js.map +1 -1
  108. package/dist/src/providers/catalog.d.ts +3 -3
  109. package/dist/src/providers/catalog.js +3 -3
  110. package/dist/src/providers/catalog.js.map +1 -1
  111. package/dist/src/providers/local-mock.d.ts +11 -2
  112. package/dist/src/providers/local-mock.js +322 -96
  113. package/dist/src/providers/local-mock.js.map +1 -1
  114. package/dist/src/providers/native-ids.d.ts +1 -0
  115. package/dist/src/providers/native-ids.js +6 -1
  116. package/dist/src/providers/native-ids.js.map +1 -1
  117. package/dist/src/providers/recorder.d.ts +30 -2
  118. package/dist/src/providers/recorder.js +549 -70
  119. package/dist/src/providers/recorder.js.map +1 -1
  120. package/dist/src/providers/registry.d.ts +25 -1
  121. package/dist/src/providers/registry.js +280 -40
  122. package/dist/src/providers/registry.js.map +1 -1
  123. package/dist/src/providers/signed-jwt.d.ts +29 -0
  124. package/dist/src/providers/signed-jwt.js +202 -0
  125. package/dist/src/providers/signed-jwt.js.map +1 -0
  126. package/dist/src/providers/slack-ids.js.map +1 -1
  127. package/dist/src/providers/target-normalizers.d.ts +26 -0
  128. package/dist/src/providers/target-normalizers.js +276 -0
  129. package/dist/src/providers/target-normalizers.js.map +1 -0
  130. package/dist/src/providers/types.d.ts +5 -0
  131. package/dist/src/providers/types.js.map +1 -1
  132. package/dist/src/providers/webhook-server.d.ts +2 -0
  133. package/dist/src/providers/webhook-server.js +72 -25
  134. package/dist/src/providers/webhook-server.js.map +1 -1
  135. package/dist/src/servers/http.d.ts +23 -3
  136. package/dist/src/servers/http.js +213 -20
  137. package/dist/src/servers/http.js.map +1 -1
  138. package/dist/src/servers/index.js +20 -21
  139. package/dist/src/servers/index.js.map +1 -1
  140. package/dist/src/servers/matrix.js +465 -51
  141. package/dist/src/servers/matrix.js.map +1 -1
  142. package/dist/src/servers/mattermost.d.ts +5 -0
  143. package/dist/src/servers/mattermost.js +306 -58
  144. package/dist/src/servers/mattermost.js.map +1 -1
  145. package/dist/src/servers/pending-events.d.ts +2 -0
  146. package/dist/src/servers/pending-events.js +11 -0
  147. package/dist/src/servers/pending-events.js.map +1 -0
  148. package/dist/src/servers/recorder.d.ts +5 -0
  149. package/dist/src/servers/recorder.js +262 -3
  150. package/dist/src/servers/recorder.js.map +1 -1
  151. package/dist/src/servers/signal.d.ts +2 -0
  152. package/dist/src/servers/signal.js +444 -56
  153. package/dist/src/servers/signal.js.map +1 -1
  154. package/dist/src/servers/slack.d.ts +5 -0
  155. package/dist/src/servers/slack.js +515 -49
  156. package/dist/src/servers/slack.js.map +1 -1
  157. package/dist/src/servers/telegram.d.ts +111 -0
  158. package/dist/src/servers/telegram.js +830 -158
  159. package/dist/src/servers/telegram.js.map +1 -1
  160. package/dist/src/servers/webhook-target.d.ts +40 -0
  161. package/dist/src/servers/webhook-target.js +349 -0
  162. package/dist/src/servers/webhook-target.js.map +1 -0
  163. package/dist/src/servers/websocket.d.ts +2 -0
  164. package/dist/src/servers/websocket.js +29 -0
  165. package/dist/src/servers/websocket.js.map +1 -0
  166. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  167. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  168. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  169. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  170. package/dist/src/servers/whatsapp-jid.js +32 -0
  171. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  172. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  174. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  175. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  177. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  178. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  179. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  180. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  181. package/dist/src/servers/whatsapp.d.ts +12 -7
  182. package/dist/src/servers/whatsapp.js +269 -107
  183. package/dist/src/servers/whatsapp.js.map +1 -1
  184. package/dist/src/servers/zalo.d.ts +14 -0
  185. package/dist/src/servers/zalo.js +521 -88
  186. package/dist/src/servers/zalo.js.map +1 -1
  187. package/docs/channel-setup.md +197 -19
  188. package/fixtures/examples/crabline.example.yaml +10 -0
  189. package/package.json +20 -15
@@ -1,7 +1,14 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { validateHeaderValue, } from "node:http";
2
3
  import path from "node:path";
3
- 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";
4
5
  import { recordServerEvent } from "./recorder.js";
6
+ import { resolveMaxPendingInboundEvents } from "./pending-events.js";
7
+ import { postWebhookRequest, validateWebhookTarget, } from "./webhook-target.js";
8
+ const DEFAULT_WEBHOOK_DELIVERY_TIMEOUT_MS = 5_000;
9
+ const MAX_ACTIVE_ZALO_WEBHOOK_VALIDATIONS = 8;
10
+ const MAX_WEBHOOK_DELIVERY_TIMEOUT_MS = 30_000;
11
+ const MAX_ZALO_WEBHOOK_SECRET_BYTES = 256;
5
12
  async function appendEvent(state, event) {
6
13
  await recordServerEvent({ event, onEvent: state.onEvent, recorderPath: state.recorderPath });
7
14
  }
@@ -20,126 +27,513 @@ function readChatType(value) {
20
27
  function requestParams(url, body) {
21
28
  return { ...Object.fromEntries(url.searchParams.entries()), ...body };
22
29
  }
30
+ const SENSITIVE_PARAM_NAMES = new Set([
31
+ "accesskey",
32
+ "accesstoken",
33
+ "apikey",
34
+ "auth",
35
+ "authorization",
36
+ "authtoken",
37
+ "bearertoken",
38
+ "clientsecret",
39
+ "consumersecret",
40
+ "credential",
41
+ "credentials",
42
+ "idtoken",
43
+ "key",
44
+ "oauthtoken",
45
+ "password",
46
+ "passwd",
47
+ "privatekey",
48
+ "refreshtoken",
49
+ "secret",
50
+ "secretkey",
51
+ "secrettoken",
52
+ "sessiontoken",
53
+ "signature",
54
+ "signingsecret",
55
+ "token",
56
+ "webhooksecret",
57
+ ]);
58
+ function isSensitiveParam(name) {
59
+ const canonicalName = name
60
+ .normalize("NFKC")
61
+ .replace(/[^A-Za-z0-9]/gu, "")
62
+ .toLowerCase();
63
+ return (SENSITIVE_PARAM_NAMES.has(canonicalName) ||
64
+ /(?:^|[_-])(?:access[_-]?token|api[_-]?key|authorization|key|password|secret|signature|token)(?:$|[_-])/iu.test(name));
65
+ }
23
66
  function redactParams(params) {
24
67
  return Object.fromEntries(Object.entries(params).map(([key, value]) => [
25
68
  key,
26
- key === "secret_token" ? "<redacted>" : value,
69
+ isSensitiveParam(key)
70
+ ? "<redacted>"
71
+ : key === "url" && typeof value === "string"
72
+ ? redactUrlCredentials(value)
73
+ : value,
27
74
  ]));
28
75
  }
76
+ function redactSensitiveSearchParams(searchParams) {
77
+ let redacted = false;
78
+ for (const key of searchParams.keys()) {
79
+ if (isSensitiveParam(key)) {
80
+ searchParams.set(key, "<redacted>");
81
+ redacted = true;
82
+ }
83
+ }
84
+ return redacted;
85
+ }
86
+ function redactMalformedUrlCredentials(value) {
87
+ const credentialRedacted = value.replace(/^(\s*)((?:[a-z][a-z0-9+.-]*:)?\/\/)[^/?#]*@/iu, "$1$2<redacted>@");
88
+ const queryStart = credentialRedacted.indexOf("?");
89
+ const fragmentStart = credentialRedacted.indexOf("#", queryStart + 1);
90
+ if (queryStart < 0 || (fragmentStart >= 0 && fragmentStart < queryStart)) {
91
+ return credentialRedacted;
92
+ }
93
+ const queryEnd = fragmentStart >= 0 ? fragmentStart : credentialRedacted.length;
94
+ const searchParams = new URLSearchParams(credentialRedacted.slice(queryStart + 1, queryEnd));
95
+ if (!redactSensitiveSearchParams(searchParams)) {
96
+ return credentialRedacted;
97
+ }
98
+ const search = searchParams.toString().replaceAll("%3Credacted%3E", "<redacted>");
99
+ return `${credentialRedacted.slice(0, queryStart)}?${search}${credentialRedacted.slice(queryEnd)}`;
100
+ }
101
+ function redactUrlCredentials(value) {
102
+ let url;
103
+ try {
104
+ url = new URL(value);
105
+ }
106
+ catch {
107
+ return redactMalformedUrlCredentials(value);
108
+ }
109
+ const hasCredentials = Boolean(url.username || url.password);
110
+ const redactedQuery = redactSensitiveSearchParams(url.searchParams);
111
+ if (!hasCredentials && !redactedQuery) {
112
+ return value;
113
+ }
114
+ const search = url.search.replaceAll("%3Credacted%3E", "<redacted>");
115
+ return `${url.protocol}//${hasCredentials ? "<redacted>@" : ""}${url.host}${url.pathname}${search}${url.hash}`;
116
+ }
29
117
  function requireParam(body, name) {
30
118
  return readTrimmedString(body[name]) ?? zaloError(`${name} is required`, 400);
31
119
  }
32
120
  function firstError(...values) {
33
121
  return values.find((value) => value instanceof Response);
34
122
  }
35
- function nextUpdate(state) {
36
- const update = state.updates.shift();
37
- return update ? zaloOk(update) : undefined;
123
+ function webhookDeliveryTimeoutMs(value) {
124
+ if (value === undefined || !Number.isFinite(value)) {
125
+ return DEFAULT_WEBHOOK_DELIVERY_TIMEOUT_MS;
126
+ }
127
+ return Math.max(1, Math.min(Math.floor(value), MAX_WEBHOOK_DELIVERY_TIMEOUT_MS));
128
+ }
129
+ async function validateWebhookUrl(url, state, signal) {
130
+ const target = await validateWebhookTarget({
131
+ allowLoopbackHttp: state.allowLoopbackHttpWebhook,
132
+ restrictPrivateAddresses: state.restrictWebhookTargets,
133
+ signal,
134
+ url,
135
+ });
136
+ if ("error" in target) {
137
+ return target.error === "unresolvable"
138
+ ? zaloError("url host could not be resolved", 400)
139
+ : target.error === "private-address"
140
+ ? zaloError("url must not target a private or link-local address", 400)
141
+ : zaloError("url must use HTTPS", 400);
142
+ }
143
+ return target;
144
+ }
145
+ function validateZaloWebhookSecret(secretToken) {
146
+ if (Buffer.byteLength(secretToken) > MAX_ZALO_WEBHOOK_SECRET_BYTES) {
147
+ return zaloError(`secret_token must not exceed ${MAX_ZALO_WEBHOOK_SECRET_BYTES} bytes`, 400);
148
+ }
149
+ try {
150
+ validateHeaderValue("x-bot-api-secret-token", secretToken);
151
+ }
152
+ catch {
153
+ return zaloError("secret_token contains invalid HTTP header characters", 400);
154
+ }
155
+ return undefined;
156
+ }
157
+ async function validateWebhookUrlWithDeadline(url, state, deadlineAt) {
158
+ const remainingMs = deadlineAt - Date.now();
159
+ if (remainingMs <= 0) {
160
+ throw webhookTimeoutError(state.webhookDeliveryTimeoutMs);
161
+ }
162
+ const validation = new AbortController();
163
+ state.activeWebhookValidations.add(validation);
164
+ const timer = setTimeout(() => validation.abort(webhookTimeoutError(state.webhookDeliveryTimeoutMs)), remainingMs);
165
+ timer.unref();
166
+ try {
167
+ return await validateWebhookUrl(url, state, validation.signal);
168
+ }
169
+ finally {
170
+ clearTimeout(timer);
171
+ state.activeWebhookValidations.delete(validation);
172
+ }
173
+ }
174
+ function webhookTimeoutError(timeoutMs) {
175
+ return new DOMException(`Webhook delivery timed out after ${timeoutMs}ms`, "TimeoutError");
176
+ }
177
+ /** @internal */
178
+ export async function postZaloWebhook(params) {
179
+ const activeRequests = params.activeRequests ?? new Set();
180
+ const deadlineAt = Date.now() + params.timeoutMs;
181
+ const addresses = params.addresses && params.addresses.length > 0 ? params.addresses : [undefined];
182
+ let lastError;
183
+ for (const [index, address] of addresses.entries()) {
184
+ if (params.shouldCancel?.()) {
185
+ throw new Error("Webhook delivery cancelled.");
186
+ }
187
+ const remainingMs = deadlineAt - Date.now();
188
+ if (remainingMs <= 0) {
189
+ throw webhookTimeoutError(params.timeoutMs);
190
+ }
191
+ const attemptsRemaining = addresses.length - index;
192
+ const attemptTimeoutMs = Math.max(1, Math.floor(remainingMs / attemptsRemaining));
193
+ try {
194
+ return await postWebhookRequest({
195
+ activeRequests,
196
+ address,
197
+ body: params.body,
198
+ headerEntries: [["x-bot-api-secret-token", params.verificationValue]],
199
+ timeoutMs: attemptTimeoutMs,
200
+ url: params.url,
201
+ });
202
+ }
203
+ catch (error) {
204
+ if (params.shouldCancel?.()) {
205
+ throw error;
206
+ }
207
+ lastError = error;
208
+ if (error instanceof DOMException &&
209
+ error.name === "TimeoutError" &&
210
+ attemptsRemaining === 1) {
211
+ throw webhookTimeoutError(params.timeoutMs);
212
+ }
213
+ }
214
+ }
215
+ throw lastError;
216
+ }
217
+ function nextUpdate(request, response, state) {
218
+ if (request.aborted || request.socket.destroyed || response.destroyed) {
219
+ return undefined;
220
+ }
221
+ const update = state.updates[0];
222
+ if (!update) {
223
+ return undefined;
224
+ }
225
+ const order = pollingUpdateOrder(state, update);
226
+ if (state.failedUpdateOrders.has(order) &&
227
+ [...state.reservedUpdateOrders].some((reservedOrder) => reservedOrder < order)) {
228
+ return undefined;
229
+ }
230
+ state.updates.shift();
231
+ reservePollingUpdate(state, update);
232
+ return reservedUpdateResponse(state, update);
233
+ }
234
+ function pollingUpdateOrder(state, update) {
235
+ const existing = state.updateOrders.get(update);
236
+ if (existing !== undefined) {
237
+ return existing;
238
+ }
239
+ const order = state.nextUpdateOrder++;
240
+ state.updateOrders.set(update, order);
241
+ return order;
242
+ }
243
+ function queuePollingUpdate(state, update) {
244
+ const order = pollingUpdateOrder(state, update);
245
+ const insertionIndex = state.updates.findIndex((queued) => pollingUpdateOrder(state, queued) > order);
246
+ if (insertionIndex < 0) {
247
+ state.updates.push(update);
248
+ }
249
+ else {
250
+ state.updates.splice(insertionIndex, 0, update);
251
+ }
252
+ }
253
+ function reservePollingUpdate(state, update) {
254
+ const order = pollingUpdateOrder(state, update);
255
+ state.failedUpdateOrders.delete(order);
256
+ state.reservedUpdateOrders.add(order);
257
+ }
258
+ function flushPendingPollingUpdate(state) {
259
+ const pending = state.pendingRequest;
260
+ const update = state.updates[0];
261
+ if (!pending || !update) {
262
+ return;
263
+ }
264
+ if (!isPendingUpdateRequestLive(pending)) {
265
+ settlePendingUpdate(state, pending, { kind: "disconnect" });
266
+ return;
267
+ }
268
+ const order = pollingUpdateOrder(state, update);
269
+ if (state.failedUpdateOrders.has(order) &&
270
+ [...state.reservedUpdateOrders].some((reservedOrder) => reservedOrder < order)) {
271
+ return;
272
+ }
273
+ state.updates.shift();
274
+ reservePollingUpdate(state, update);
275
+ settlePendingUpdate(state, pending, { kind: "update", update });
276
+ }
277
+ function reservedUpdateResponse(state, update) {
278
+ let settled = false;
279
+ const release = () => {
280
+ if (settled) {
281
+ return false;
282
+ }
283
+ settled = true;
284
+ state.reservedUpdateOrders.delete(pollingUpdateOrder(state, update));
285
+ return true;
286
+ };
287
+ return {
288
+ onWriteFailure() {
289
+ if (!release()) {
290
+ return;
291
+ }
292
+ state.failedUpdateOrders.add(pollingUpdateOrder(state, update));
293
+ queuePollingUpdate(state, update);
294
+ flushPendingPollingUpdate(state);
295
+ },
296
+ onWriteSuccess() {
297
+ if (release()) {
298
+ flushPendingPollingUpdate(state);
299
+ }
300
+ },
301
+ response: zaloOk(update),
302
+ };
303
+ }
304
+ function settlePendingUpdate(state, pending, result) {
305
+ if (!pending.active) {
306
+ return false;
307
+ }
308
+ pending.active = false;
309
+ if (state.pendingRequest === pending) {
310
+ state.pendingRequest = undefined;
311
+ }
312
+ if (pending.timeout) {
313
+ clearTimeout(pending.timeout);
314
+ }
315
+ pending.request.socket.off("close", pending.onDisconnect);
316
+ pending.request.off("aborted", pending.onDisconnect);
317
+ pending.response.off("close", pending.onDisconnect);
318
+ pending.resolve(result);
319
+ return true;
320
+ }
321
+ function isPendingUpdateRequestLive(pending) {
322
+ return (!pending.request.aborted && !pending.request.socket.destroyed && !pending.response.destroyed);
38
323
  }
39
- function waitForUpdate(state, timeoutSeconds) {
40
- const queued = nextUpdate(state);
324
+ async function waitForUpdate(request, response, state, timeoutSeconds) {
325
+ if (request.aborted || request.socket.destroyed || response.destroyed) {
326
+ return zaloError("Client closed request", 499);
327
+ }
328
+ const previous = state.pendingRequest;
329
+ if (previous) {
330
+ settlePendingUpdate(state, previous, { kind: "conflict" });
331
+ }
332
+ const queued = nextUpdate(request, response, state);
41
333
  if (queued) {
42
- return Promise.resolve(queued);
334
+ return queued;
43
335
  }
44
336
  if (timeoutSeconds <= 0) {
45
- return Promise.resolve(zaloError("Request timeout", 408));
337
+ return zaloError("Request timeout", 408);
46
338
  }
47
- return new Promise((resolve) => {
339
+ const result = await new Promise((resolve) => {
48
340
  const pending = {
341
+ active: true,
342
+ onDisconnect: () => {
343
+ settlePendingUpdate(state, pending, { kind: "disconnect" });
344
+ },
345
+ request,
346
+ response,
49
347
  resolve,
50
- timeout: setTimeout(() => {
51
- const index = state.pendingRequests.indexOf(pending);
52
- if (index >= 0) {
53
- state.pendingRequests.splice(index, 1);
54
- }
55
- resolve(zaloError("Request timeout", 408));
56
- }, timeoutSeconds * 1000),
348
+ timeout: undefined,
57
349
  };
58
- state.pendingRequests.push(pending);
350
+ state.pendingRequest = pending;
351
+ request.once("aborted", pending.onDisconnect);
352
+ request.socket.once("close", pending.onDisconnect);
353
+ response.once("close", pending.onDisconnect);
354
+ pending.timeout = setTimeout(() => {
355
+ settlePendingUpdate(state, pending, { kind: "timeout" });
356
+ }, timeoutSeconds * 1000);
357
+ pending.timeout.unref();
358
+ if (request.socket.destroyed || response.destroyed) {
359
+ pending.onDisconnect();
360
+ }
59
361
  });
362
+ switch (result.kind) {
363
+ case "conflict":
364
+ return zaloError("Conflict: terminated by other getUpdates request", 409);
365
+ case "disconnect":
366
+ return zaloError("Client closed request", 499);
367
+ case "shutdown":
368
+ return zaloError("Server shutting down", 503);
369
+ case "timeout":
370
+ return zaloError("Request timeout", 408);
371
+ case "update":
372
+ return reservedUpdateResponse(state, result.update);
373
+ }
60
374
  }
61
375
  function deliverPollingUpdate(state, update) {
62
- const pending = state.pendingRequests.shift();
63
- if (!pending) {
64
- state.updates.push(update);
65
- return;
376
+ pollingUpdateOrder(state, update);
377
+ if (state.updates.length > 0) {
378
+ if (state.updates.length + state.reservedUpdateOrders.size >= state.maxPendingInboundEvents) {
379
+ return false;
380
+ }
381
+ queuePollingUpdate(state, update);
382
+ flushPendingPollingUpdate(state);
383
+ return true;
384
+ }
385
+ const pending = state.pendingRequest;
386
+ if (pending) {
387
+ if (isPendingUpdateRequestLive(pending)) {
388
+ reservePollingUpdate(state, update);
389
+ settlePendingUpdate(state, pending, { kind: "update", update });
390
+ return true;
391
+ }
392
+ settlePendingUpdate(state, pending, { kind: "disconnect" });
393
+ }
394
+ if (state.updates.length + state.reservedUpdateOrders.size >= state.maxPendingInboundEvents) {
395
+ return false;
66
396
  }
67
- clearTimeout(pending.timeout);
68
- pending.resolve(zaloOk(update));
397
+ queuePollingUpdate(state, update);
398
+ return true;
69
399
  }
70
- async function deliverWebhookUpdate(webhook, update) {
400
+ async function deliverWebhookUpdate(state, webhook, update) {
401
+ const deadlineAt = Date.now() + state.webhookDeliveryTimeoutMs;
402
+ const url = new URL(webhook.url);
403
+ let target;
71
404
  try {
72
- const response = await fetch(webhook.url, {
73
- body: JSON.stringify({ ok: true, result: update }),
74
- headers: {
75
- "content-type": "application/json",
76
- "x-bot-api-secret-token": webhook.secretToken,
77
- },
78
- method: "POST",
405
+ target = await validateWebhookUrlWithDeadline(url, state, deadlineAt);
406
+ }
407
+ catch (error) {
408
+ if (error instanceof DOMException && error.name === "TimeoutError") {
409
+ return zaloError(`Webhook delivery timed out after ${state.webhookDeliveryTimeoutMs}ms`, 502);
410
+ }
411
+ throw error;
412
+ }
413
+ if (target instanceof Response) {
414
+ return target;
415
+ }
416
+ try {
417
+ const remainingMs = deadlineAt - Date.now();
418
+ if (remainingMs <= 0) {
419
+ throw webhookTimeoutError(state.webhookDeliveryTimeoutMs);
420
+ }
421
+ const status = await postZaloWebhook({
422
+ activeRequests: state.activeWebhookRequests,
423
+ addresses: target.addresses,
424
+ body: JSON.stringify(update),
425
+ shouldCancel: () => state.closing,
426
+ timeoutMs: remainingMs,
427
+ url,
428
+ verificationValue: webhook.secretToken,
79
429
  });
80
- if (!response.ok) {
81
- return zaloError(`Webhook delivery failed with HTTP ${response.status}`, 502);
430
+ if (status < 200 || status >= 300) {
431
+ return zaloError(`Webhook delivery failed with HTTP ${status}`, 502);
82
432
  }
83
433
  }
84
434
  catch (error) {
435
+ if (error instanceof DOMException && error.name === "TimeoutError") {
436
+ return zaloError(`Webhook delivery timed out after ${state.webhookDeliveryTimeoutMs}ms`, 502);
437
+ }
85
438
  return zaloError(`Webhook delivery failed: ${error instanceof Error ? error.message : String(error)}`, 502);
86
439
  }
87
440
  return undefined;
88
441
  }
89
442
  async function handleAdminInbound(request, state, url) {
90
443
  if (!hasAdminToken(request, state.adminToken)) {
444
+ drainRequestBody(request);
91
445
  return adminAuthError();
92
446
  }
93
- const body = await parseRequestBody(request);
447
+ const parsedBody = await parseUnknownRequestBody(request);
448
+ if (!isJsonObject(parsedBody)) {
449
+ return zaloError("Bad Request: can't parse JSON object", 400);
450
+ }
451
+ const body = parsedBody;
94
452
  const chatId = requireParam(body, "chatId");
95
453
  const senderId = requireParam(body, "senderId");
96
454
  const text = requireParam(body, "text");
97
455
  if (chatId instanceof Response || senderId instanceof Response || text instanceof Response) {
98
456
  return [chatId, senderId, text].find((value) => value instanceof Response);
99
457
  }
100
- const update = {
101
- event_name: "message.text.received",
102
- message: {
103
- chat: { chat_type: readChatType(body.chatType), id: chatId },
104
- date: Date.now(),
105
- from: {
106
- display_name: readTrimmedString(body.senderName) ?? senderId,
107
- id: senderId,
108
- is_bot: false,
109
- },
110
- message_id: messageId(state),
111
- text,
112
- },
113
- };
114
- await appendEvent(state, {
115
- at: new Date().toISOString(),
116
- body,
117
- method: request.method ?? "POST",
118
- path: url.pathname,
119
- query: queryRecord(url),
120
- type: "admin",
458
+ let releaseAdmission;
459
+ const previousAdmission = state.inboundAdmission;
460
+ state.inboundAdmission = new Promise((resolve) => {
461
+ releaseAdmission = resolve;
121
462
  });
122
- if (state.webhook?.url) {
123
- const error = await deliverWebhookUpdate(state.webhook, update);
124
- if (error) {
125
- return error;
463
+ await previousAdmission;
464
+ try {
465
+ if (!state.webhook?.url &&
466
+ state.updates.length + state.reservedUpdateOrders.size >= state.maxPendingInboundEvents) {
467
+ return zaloError(`Pending inbound queue is full (${state.maxPendingInboundEvents} updates)`, 429);
468
+ }
469
+ const update = {
470
+ event_name: "message.text.received",
471
+ message: {
472
+ chat: { chat_type: readChatType(body.chatType), id: chatId },
473
+ date: Date.now(),
474
+ from: {
475
+ display_name: readTrimmedString(body.senderName) ?? senderId,
476
+ id: senderId,
477
+ is_bot: false,
478
+ },
479
+ message_id: messageId(state),
480
+ text,
481
+ },
482
+ };
483
+ await appendEvent(state, {
484
+ at: new Date().toISOString(),
485
+ body,
486
+ method: request.method ?? "POST",
487
+ path: url.pathname,
488
+ query: queryRecord(url),
489
+ type: "admin",
490
+ });
491
+ if (state.webhook?.url) {
492
+ const error = await deliverWebhookUpdate(state, state.webhook, update);
493
+ if (error) {
494
+ return error;
495
+ }
496
+ }
497
+ else if (!deliverPollingUpdate(state, update)) {
498
+ return zaloError(`Pending inbound queue is full (${state.maxPendingInboundEvents} updates)`, 429);
126
499
  }
500
+ return zaloOk(update);
127
501
  }
128
- else {
129
- deliverPollingUpdate(state, update);
502
+ finally {
503
+ releaseAdmission();
130
504
  }
131
- return zaloOk(update);
132
505
  }
133
- async function handleZaloMethod(request, state, url, method) {
134
- const body = requestParams(url, await parseRequestBody(request));
135
- await appendEvent(state, {
506
+ async function handleZaloMethod(request, response, state, url, method) {
507
+ const parsedBody = await parseUnknownRequestBody(request);
508
+ if (!isJsonObject(parsedBody)) {
509
+ return zaloError("Bad Request: can't parse JSON object", 400);
510
+ }
511
+ const body = requestParams(url, parsedBody);
512
+ const event = {
136
513
  at: new Date().toISOString(),
137
514
  ...(Object.keys(body).length > 0 ? { body: redactParams(body) } : {}),
138
515
  method: request.method ?? "GET",
139
516
  path: `/bot<redacted>/${method}`,
140
517
  query: redactParams(queryRecord(url)),
141
518
  type: "api",
142
- });
519
+ };
520
+ if (method === "sendMessage" || method === "sendPhoto") {
521
+ const chatId = requireParam(body, "chat_id");
522
+ const content = requireParam(body, method === "sendMessage" ? "text" : "photo");
523
+ const error = firstError(chatId, content);
524
+ const sendResponse = error ?? zaloOk({ date: Date.now(), message_id: messageId(state) });
525
+ event.accepted = sendResponse.ok;
526
+ try {
527
+ await appendEvent(state, event);
528
+ }
529
+ catch (appendError) {
530
+ if (!event.accepted) {
531
+ throw appendError;
532
+ }
533
+ }
534
+ return sendResponse;
535
+ }
536
+ await appendEvent(state, event);
143
537
  if (method === "getMe") {
144
538
  return zaloOk({
145
539
  account_name: state.botName,
@@ -154,16 +548,7 @@ async function handleZaloMethod(request, state, url, method) {
154
548
  }
155
549
  const parsedTimeout = Number(readTrimmedString(body.timeout) ?? "30");
156
550
  const timeout = Number.isFinite(parsedTimeout) ? Math.max(0, Math.min(parsedTimeout, 50)) : 30;
157
- return await waitForUpdate(state, timeout);
158
- }
159
- if (method === "sendMessage" || method === "sendPhoto") {
160
- const chatId = requireParam(body, "chat_id");
161
- const content = requireParam(body, method === "sendMessage" ? "text" : "photo");
162
- const error = firstError(chatId, content);
163
- if (error) {
164
- return error;
165
- }
166
- return zaloOk({ date: Date.now(), message_id: messageId(state) });
551
+ return await waitForUpdate(request, response, state, timeout);
167
552
  }
168
553
  if (method === "sendChatAction") {
169
554
  const chatId = requireParam(body, "chat_id");
@@ -187,18 +572,37 @@ async function handleZaloMethod(request, state, url, method) {
187
572
  if (typeof webhookUrl !== "string" || typeof secretToken !== "string") {
188
573
  return zaloError("Invalid webhook parameters", 400);
189
574
  }
575
+ const secretError = validateZaloWebhookSecret(secretToken);
576
+ if (secretError) {
577
+ return secretError;
578
+ }
190
579
  let parsedUrl;
191
580
  try {
192
581
  parsedUrl = new URL(webhookUrl);
193
582
  }
194
583
  catch {
195
- return zaloError("url must be a valid HTTP or HTTPS URL", 400);
584
+ return zaloError("url must be a valid HTTPS URL", 400);
196
585
  }
197
- if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
198
- return zaloError("url must be a valid HTTP or HTTPS URL", 400);
586
+ if (state.activeWebhookValidations.size >= MAX_ACTIVE_ZALO_WEBHOOK_VALIDATIONS) {
587
+ return zaloError("Too many webhook validations", 429);
588
+ }
589
+ let target;
590
+ try {
591
+ target = await validateWebhookUrlWithDeadline(parsedUrl, state, Date.now() + state.webhookDeliveryTimeoutMs);
592
+ }
593
+ catch {
594
+ return zaloError("url host could not be resolved", 400);
595
+ }
596
+ if (target instanceof Response) {
597
+ return target;
598
+ }
599
+ if (state.reservedUpdateOrders.size > 0) {
600
+ return zaloError("Polling deliveries are still in progress", 409);
199
601
  }
200
- state.updates.length = 0;
201
602
  const webhook = { secretToken, updatedAt: Date.now(), url: parsedUrl.href };
603
+ if (state.pendingRequest) {
604
+ settlePendingUpdate(state, state.pendingRequest, { kind: "conflict" });
605
+ }
202
606
  state.webhook = webhook;
203
607
  return zaloOk({ updated_at: webhook.updatedAt, url: webhook.url });
204
608
  }
@@ -215,19 +619,32 @@ async function handleZaloMethod(request, state, url, method) {
215
619
  export async function startZaloServer(params = {}) {
216
620
  const host = params.host ?? "127.0.0.1";
217
621
  const state = {
622
+ activeWebhookRequests: new Set(),
623
+ activeWebhookValidations: new Set(),
218
624
  adminToken: params.adminToken ?? randomBytes(24).toString("hex"),
625
+ allowLoopbackHttpWebhook: isLoopbackHost(host),
219
626
  botId: params.botId ?? "1459232241454765289",
220
627
  botName: params.botName ?? "bot.crabline",
221
- botToken: params.botToken ?? "crabline-zalo-bot-token",
628
+ botToken: params.botToken ??
629
+ (isLoopbackHost(host) ? "crabline-zalo-bot-token" : randomBytes(32).toString("base64url")),
630
+ closing: false,
631
+ failedUpdateOrders: new Set(),
632
+ inboundAdmission: Promise.resolve(),
633
+ maxPendingInboundEvents: resolveMaxPendingInboundEvents(params.maxPendingInboundEvents),
222
634
  nextMessage: 1,
635
+ nextUpdateOrder: 1,
223
636
  onEvent: params.onEvent,
224
- pendingRequests: [],
637
+ pendingRequest: undefined,
225
638
  recorderPath: params.recorderPath ?? path.resolve(".crabline", "servers", "zalo.jsonl"),
639
+ reservedUpdateOrders: new Set(),
640
+ restrictWebhookTargets: true,
641
+ updateOrders: new WeakMap(),
226
642
  updates: [],
227
643
  webhook: undefined,
644
+ webhookDeliveryTimeoutMs: webhookDeliveryTimeoutMs(params.webhookDeliveryTimeoutMs),
228
645
  };
229
646
  const httpServer = await startHttpJsonServer({
230
- handle: async (request) => {
647
+ handle: async (request, response) => {
231
648
  const url = new URL(request.url ?? "/", `http://${request.headers.host ?? host}`);
232
649
  if (request.method === "POST" && url.pathname === "/crabline/zalo/inbound") {
233
650
  return await handleAdminInbound(request, state, url);
@@ -237,9 +654,19 @@ export async function startZaloServer(params = {}) {
237
654
  return zaloError("Not found", 404);
238
655
  }
239
656
  if (match[1] !== state.botToken) {
657
+ drainRequestBody(request);
240
658
  return zaloError("Unauthorized", 401);
241
659
  }
242
- return await handleZaloMethod(request, state, url, match[2] ?? "");
660
+ return await handleZaloMethod(request, response, state, url, match[2] ?? "");
661
+ },
662
+ handleError: (error) => {
663
+ if (error instanceof InvalidJsonBodyError) {
664
+ return zaloError("Bad Request: can't parse JSON object", 400);
665
+ }
666
+ if (error instanceof RequestBodyTooLargeError) {
667
+ return zaloError("Request Entity Too Large", 413);
668
+ }
669
+ return undefined;
243
670
  },
244
671
  host,
245
672
  port: params.port ?? 0,
@@ -264,9 +691,15 @@ export async function startZaloServer(params = {}) {
264
691
  };
265
692
  return {
266
693
  async close() {
267
- for (const pending of state.pendingRequests.splice(0)) {
268
- clearTimeout(pending.timeout);
269
- pending.resolve(zaloError("Server shutting down", 503));
694
+ state.closing = true;
695
+ for (const request of state.activeWebhookRequests) {
696
+ request.destroy(new Error("Zalo server is shutting down."));
697
+ }
698
+ for (const validation of state.activeWebhookValidations) {
699
+ validation.abort(new Error("Zalo server is shutting down."));
700
+ }
701
+ if (state.pendingRequest) {
702
+ settlePendingUpdate(state, state.pendingRequest, { kind: "shutdown" });
270
703
  }
271
704
  await httpServer.close();
272
705
  },