@openclaw/crabline 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/README.md +52 -22
  2. package/assets/crabline-banner.svg +20 -0
  3. package/dist/src/bin/crabline.js.map +1 -1
  4. package/dist/src/cli/program.d.ts +25 -1
  5. package/dist/src/cli/program.js +579 -82
  6. package/dist/src/cli/program.js.map +1 -1
  7. package/dist/src/config/load.js +59 -6
  8. package/dist/src/config/load.js.map +1 -1
  9. package/dist/src/config/schema.d.ts +116 -92
  10. package/dist/src/config/schema.js +179 -73
  11. package/dist/src/config/schema.js.map +1 -1
  12. package/dist/src/core/errors.js +9 -2
  13. package/dist/src/core/errors.js.map +1 -1
  14. package/dist/src/core/exit-codes.js.map +1 -1
  15. package/dist/src/core/matcher.js +7 -6
  16. package/dist/src/core/matcher.js.map +1 -1
  17. package/dist/src/core/message-template.js.map +1 -1
  18. package/dist/src/core/nonces.d.ts +1 -0
  19. package/dist/src/core/nonces.js +4 -2
  20. package/dist/src/core/nonces.js.map +1 -1
  21. package/dist/src/core/reporters.d.ts +1 -0
  22. package/dist/src/core/reporters.js +42 -4
  23. package/dist/src/core/reporters.js.map +1 -1
  24. package/dist/src/core/run.d.ts +3 -0
  25. package/dist/src/core/run.js +415 -107
  26. package/dist/src/core/run.js.map +1 -1
  27. package/dist/src/core/safe-regex.d.ts +3 -0
  28. package/dist/src/core/safe-regex.js +21 -0
  29. package/dist/src/core/safe-regex.js.map +1 -0
  30. package/dist/src/index.d.ts +4 -2
  31. package/dist/src/index.js +1 -1
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/openclaw/artifact-generation.d.ts +38 -0
  34. package/dist/src/openclaw/artifact-generation.js +314 -0
  35. package/dist/src/openclaw/artifact-generation.js.map +1 -0
  36. package/dist/src/openclaw/bridges/matrix.js +97 -12
  37. package/dist/src/openclaw/bridges/matrix.js.map +1 -1
  38. package/dist/src/openclaw/bridges/mattermost.js +32 -10
  39. package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
  40. package/dist/src/openclaw/bridges/signal.js +55 -18
  41. package/dist/src/openclaw/bridges/signal.js.map +1 -1
  42. package/dist/src/openclaw/bridges/slack.js +85 -7
  43. package/dist/src/openclaw/bridges/slack.js.map +1 -1
  44. package/dist/src/openclaw/bridges/telegram.js +124 -30
  45. package/dist/src/openclaw/bridges/telegram.js.map +1 -1
  46. package/dist/src/openclaw/bridges/whatsapp.js +78 -17
  47. package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
  48. package/dist/src/openclaw/bridges/zalo.js +14 -5
  49. package/dist/src/openclaw/bridges/zalo.js.map +1 -1
  50. package/dist/src/openclaw/outbound-contract.d.ts +5 -0
  51. package/dist/src/openclaw/outbound-contract.js +40 -0
  52. package/dist/src/openclaw/outbound-contract.js.map +1 -0
  53. package/dist/src/openclaw/private-file.d.ts +26 -0
  54. package/dist/src/openclaw/private-file.js +411 -0
  55. package/dist/src/openclaw/private-file.js.map +1 -0
  56. package/dist/src/openclaw/shared.d.ts +23 -2
  57. package/dist/src/openclaw/shared.js +115 -18
  58. package/dist/src/openclaw/shared.js.map +1 -1
  59. package/dist/src/openclaw/smoke-lock.d.ts +63 -0
  60. package/dist/src/openclaw/smoke-lock.js +949 -0
  61. package/dist/src/openclaw/smoke-lock.js.map +1 -0
  62. package/dist/src/openclaw.d.ts +16 -8
  63. package/dist/src/openclaw.js +195 -52
  64. package/dist/src/openclaw.js.map +1 -1
  65. package/dist/src/providers/builtin/discord.d.ts +10 -1
  66. package/dist/src/providers/builtin/discord.js +144 -18
  67. package/dist/src/providers/builtin/discord.js.map +1 -1
  68. package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
  69. package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
  70. package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
  71. package/dist/src/providers/builtin/feishu.d.ts +19 -2
  72. package/dist/src/providers/builtin/feishu.js +307 -25
  73. package/dist/src/providers/builtin/feishu.js.map +1 -1
  74. package/dist/src/providers/builtin/googlechat.d.ts +20 -1
  75. package/dist/src/providers/builtin/googlechat.js +178 -19
  76. package/dist/src/providers/builtin/googlechat.js.map +1 -1
  77. package/dist/src/providers/builtin/imessage.d.ts +4 -0
  78. package/dist/src/providers/builtin/imessage.js +26 -11
  79. package/dist/src/providers/builtin/imessage.js.map +1 -1
  80. package/dist/src/providers/builtin/loopback.js +92 -30
  81. package/dist/src/providers/builtin/loopback.js.map +1 -1
  82. package/dist/src/providers/builtin/matrix.d.ts +11 -1
  83. package/dist/src/providers/builtin/matrix.js +53 -29
  84. package/dist/src/providers/builtin/matrix.js.map +1 -1
  85. package/dist/src/providers/builtin/mattermost.d.ts +10 -0
  86. package/dist/src/providers/builtin/mattermost.js +38 -15
  87. package/dist/src/providers/builtin/mattermost.js.map +1 -1
  88. package/dist/src/providers/builtin/msteams.d.ts +17 -1
  89. package/dist/src/providers/builtin/msteams.js +113 -14
  90. package/dist/src/providers/builtin/msteams.js.map +1 -1
  91. package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
  92. package/dist/src/providers/builtin/native-local-mock.js +12 -35
  93. package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
  94. package/dist/src/providers/builtin/script.d.ts +11 -15
  95. package/dist/src/providers/builtin/script.js +566 -104
  96. package/dist/src/providers/builtin/script.js.map +1 -1
  97. package/dist/src/providers/builtin/slack.d.ts +6 -0
  98. package/dist/src/providers/builtin/slack.js +99 -40
  99. package/dist/src/providers/builtin/slack.js.map +1 -1
  100. package/dist/src/providers/builtin/telegram.d.ts +6 -0
  101. package/dist/src/providers/builtin/telegram.js +64 -43
  102. package/dist/src/providers/builtin/telegram.js.map +1 -1
  103. package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
  104. package/dist/src/providers/builtin/whatsapp.js +567 -30
  105. package/dist/src/providers/builtin/whatsapp.js.map +1 -1
  106. package/dist/src/providers/builtin/zalo.js +30 -19
  107. package/dist/src/providers/builtin/zalo.js.map +1 -1
  108. package/dist/src/providers/catalog.d.ts +3 -3
  109. package/dist/src/providers/catalog.js +3 -3
  110. package/dist/src/providers/catalog.js.map +1 -1
  111. package/dist/src/providers/local-mock.d.ts +11 -2
  112. package/dist/src/providers/local-mock.js +322 -96
  113. package/dist/src/providers/local-mock.js.map +1 -1
  114. package/dist/src/providers/native-ids.d.ts +1 -0
  115. package/dist/src/providers/native-ids.js +6 -1
  116. package/dist/src/providers/native-ids.js.map +1 -1
  117. package/dist/src/providers/recorder.d.ts +30 -2
  118. package/dist/src/providers/recorder.js +539 -70
  119. package/dist/src/providers/recorder.js.map +1 -1
  120. package/dist/src/providers/registry.d.ts +25 -1
  121. package/dist/src/providers/registry.js +280 -40
  122. package/dist/src/providers/registry.js.map +1 -1
  123. package/dist/src/providers/signed-jwt.d.ts +29 -0
  124. package/dist/src/providers/signed-jwt.js +202 -0
  125. package/dist/src/providers/signed-jwt.js.map +1 -0
  126. package/dist/src/providers/slack-ids.js.map +1 -1
  127. package/dist/src/providers/target-normalizers.d.ts +26 -0
  128. package/dist/src/providers/target-normalizers.js +276 -0
  129. package/dist/src/providers/target-normalizers.js.map +1 -0
  130. package/dist/src/providers/types.d.ts +5 -0
  131. package/dist/src/providers/types.js.map +1 -1
  132. package/dist/src/providers/webhook-server.d.ts +2 -0
  133. package/dist/src/providers/webhook-server.js +72 -25
  134. package/dist/src/providers/webhook-server.js.map +1 -1
  135. package/dist/src/servers/http.d.ts +23 -3
  136. package/dist/src/servers/http.js +213 -20
  137. package/dist/src/servers/http.js.map +1 -1
  138. package/dist/src/servers/index.js +20 -21
  139. package/dist/src/servers/index.js.map +1 -1
  140. package/dist/src/servers/matrix.d.ts +2 -0
  141. package/dist/src/servers/matrix.js +466 -52
  142. package/dist/src/servers/matrix.js.map +1 -1
  143. package/dist/src/servers/mattermost.d.ts +7 -0
  144. package/dist/src/servers/mattermost.js +307 -59
  145. package/dist/src/servers/mattermost.js.map +1 -1
  146. package/dist/src/servers/pending-events.d.ts +2 -0
  147. package/dist/src/servers/pending-events.js +11 -0
  148. package/dist/src/servers/pending-events.js.map +1 -0
  149. package/dist/src/servers/recorder.d.ts +12 -0
  150. package/dist/src/servers/recorder.js +51 -0
  151. package/dist/src/servers/recorder.js.map +1 -0
  152. package/dist/src/servers/signal.d.ts +4 -0
  153. package/dist/src/servers/signal.js +445 -57
  154. package/dist/src/servers/signal.js.map +1 -1
  155. package/dist/src/servers/slack.d.ts +7 -0
  156. package/dist/src/servers/slack.js +516 -50
  157. package/dist/src/servers/slack.js.map +1 -1
  158. package/dist/src/servers/telegram.d.ts +113 -0
  159. package/dist/src/servers/telegram.js +814 -155
  160. package/dist/src/servers/telegram.js.map +1 -1
  161. package/dist/src/servers/webhook-target.d.ts +40 -0
  162. package/dist/src/servers/webhook-target.js +349 -0
  163. package/dist/src/servers/webhook-target.js.map +1 -0
  164. package/dist/src/servers/websocket.d.ts +2 -0
  165. package/dist/src/servers/websocket.js +29 -0
  166. package/dist/src/servers/websocket.js.map +1 -0
  167. package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
  168. package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
  169. package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
  170. package/dist/src/servers/whatsapp-jid.d.ts +5 -0
  171. package/dist/src/servers/whatsapp-jid.js +32 -0
  172. package/dist/src/servers/whatsapp-jid.js.map +1 -0
  173. package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
  174. package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
  175. package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
  176. package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
  177. package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
  178. package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
  179. package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
  180. package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
  181. package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
  182. package/dist/src/servers/whatsapp.d.ts +14 -7
  183. package/dist/src/servers/whatsapp.js +272 -110
  184. package/dist/src/servers/whatsapp.js.map +1 -1
  185. package/dist/src/servers/zalo.d.ts +16 -0
  186. package/dist/src/servers/zalo.js +524 -91
  187. package/dist/src/servers/zalo.js.map +1 -1
  188. package/docs/channel-setup.md +197 -19
  189. package/fixtures/examples/crabline.example.yaml +10 -0
  190. package/package.json +20 -15
@@ -1,10 +1,16 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import fs from "node:fs/promises";
2
+ import { validateHeaderValue, } from "node:http";
3
3
  import path from "node:path";
4
- import { adminAuthError, hasAdminToken, jsonResponse, parseRequestBody, queryRecord, readTrimmedString, startHttpJsonServer, } from "./http.js";
4
+ import { adminAuthError, drainRequestBody, hasAdminToken, InvalidJsonBodyError, isJsonObject, isLoopbackHost, jsonResponse, parseUnknownRequestBody, queryRecord, readTrimmedString, RequestBodyTooLargeError, startHttpJsonServer, } from "./http.js";
5
+ import { 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
- await fs.mkdir(path.dirname(state.recorderPath), { recursive: true });
7
- await fs.appendFile(state.recorderPath, `${JSON.stringify(event)}\n`, "utf8");
13
+ await recordServerEvent({ event, onEvent: state.onEvent, recorderPath: state.recorderPath });
8
14
  }
9
15
  function zaloOk(result) {
10
16
  return jsonResponse(result === undefined ? { ok: true } : { ok: true, result });
@@ -21,126 +27,513 @@ function readChatType(value) {
21
27
  function requestParams(url, body) {
22
28
  return { ...Object.fromEntries(url.searchParams.entries()), ...body };
23
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
+ }
24
66
  function redactParams(params) {
25
67
  return Object.fromEntries(Object.entries(params).map(([key, value]) => [
26
68
  key,
27
- key === "secret_token" ? "<redacted>" : value,
69
+ isSensitiveParam(key)
70
+ ? "<redacted>"
71
+ : key === "url" && typeof value === "string"
72
+ ? redactUrlCredentials(value)
73
+ : value,
28
74
  ]));
29
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
+ }
30
117
  function requireParam(body, name) {
31
118
  return readTrimmedString(body[name]) ?? zaloError(`${name} is required`, 400);
32
119
  }
33
120
  function firstError(...values) {
34
121
  return values.find((value) => value instanceof Response);
35
122
  }
36
- function nextUpdate(state) {
37
- const update = state.updates.shift();
38
- 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);
39
323
  }
40
- function waitForUpdate(state, timeoutSeconds) {
41
- 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);
42
333
  if (queued) {
43
- return Promise.resolve(queued);
334
+ return queued;
44
335
  }
45
336
  if (timeoutSeconds <= 0) {
46
- return Promise.resolve(zaloError("Request timeout", 408));
337
+ return zaloError("Request timeout", 408);
47
338
  }
48
- return new Promise((resolve) => {
339
+ const result = await new Promise((resolve) => {
49
340
  const pending = {
341
+ active: true,
342
+ onDisconnect: () => {
343
+ settlePendingUpdate(state, pending, { kind: "disconnect" });
344
+ },
345
+ request,
346
+ response,
50
347
  resolve,
51
- timeout: setTimeout(() => {
52
- const index = state.pendingRequests.indexOf(pending);
53
- if (index >= 0) {
54
- state.pendingRequests.splice(index, 1);
55
- }
56
- resolve(zaloError("Request timeout", 408));
57
- }, timeoutSeconds * 1000),
348
+ timeout: undefined,
58
349
  };
59
- 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
+ }
60
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
+ }
61
374
  }
62
375
  function deliverPollingUpdate(state, update) {
63
- const pending = state.pendingRequests.shift();
64
- if (!pending) {
65
- state.updates.push(update);
66
- 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;
67
396
  }
68
- clearTimeout(pending.timeout);
69
- pending.resolve(zaloOk(update));
397
+ queuePollingUpdate(state, update);
398
+ return true;
70
399
  }
71
- 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;
72
404
  try {
73
- const response = await fetch(webhook.url, {
74
- body: JSON.stringify({ ok: true, result: update }),
75
- headers: {
76
- "content-type": "application/json",
77
- "x-bot-api-secret-token": webhook.secretToken,
78
- },
79
- 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,
80
429
  });
81
- if (!response.ok) {
82
- 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);
83
432
  }
84
433
  }
85
434
  catch (error) {
435
+ if (error instanceof DOMException && error.name === "TimeoutError") {
436
+ return zaloError(`Webhook delivery timed out after ${state.webhookDeliveryTimeoutMs}ms`, 502);
437
+ }
86
438
  return zaloError(`Webhook delivery failed: ${error instanceof Error ? error.message : String(error)}`, 502);
87
439
  }
88
440
  return undefined;
89
441
  }
90
442
  async function handleAdminInbound(request, state, url) {
91
443
  if (!hasAdminToken(request, state.adminToken)) {
444
+ drainRequestBody(request);
92
445
  return adminAuthError();
93
446
  }
94
- 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;
95
452
  const chatId = requireParam(body, "chatId");
96
453
  const senderId = requireParam(body, "senderId");
97
454
  const text = requireParam(body, "text");
98
455
  if (chatId instanceof Response || senderId instanceof Response || text instanceof Response) {
99
456
  return [chatId, senderId, text].find((value) => value instanceof Response);
100
457
  }
101
- const update = {
102
- event_name: "message.text.received",
103
- message: {
104
- chat: { chat_type: readChatType(body.chatType), id: chatId },
105
- date: Date.now(),
106
- from: {
107
- display_name: readTrimmedString(body.senderName) ?? senderId,
108
- id: senderId,
109
- is_bot: false,
110
- },
111
- message_id: messageId(state),
112
- text,
113
- },
114
- };
115
- await appendEvent(state, {
116
- at: new Date().toISOString(),
117
- body,
118
- method: request.method ?? "POST",
119
- path: url.pathname,
120
- query: queryRecord(url),
121
- type: "admin",
458
+ let releaseAdmission;
459
+ const previousAdmission = state.inboundAdmission;
460
+ state.inboundAdmission = new Promise((resolve) => {
461
+ releaseAdmission = resolve;
122
462
  });
123
- if (state.webhook?.url) {
124
- const error = await deliverWebhookUpdate(state.webhook, update);
125
- if (error) {
126
- 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);
127
499
  }
500
+ return zaloOk(update);
128
501
  }
129
- else {
130
- deliverPollingUpdate(state, update);
502
+ finally {
503
+ releaseAdmission();
131
504
  }
132
- return zaloOk(update);
133
505
  }
134
- async function handleZaloMethod(request, state, url, method) {
135
- const body = requestParams(url, await parseRequestBody(request));
136
- 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 = {
137
513
  at: new Date().toISOString(),
138
514
  ...(Object.keys(body).length > 0 ? { body: redactParams(body) } : {}),
139
515
  method: request.method ?? "GET",
140
516
  path: `/bot<redacted>/${method}`,
141
517
  query: redactParams(queryRecord(url)),
142
518
  type: "api",
143
- });
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);
144
537
  if (method === "getMe") {
145
538
  return zaloOk({
146
539
  account_name: state.botName,
@@ -155,16 +548,7 @@ async function handleZaloMethod(request, state, url, method) {
155
548
  }
156
549
  const parsedTimeout = Number(readTrimmedString(body.timeout) ?? "30");
157
550
  const timeout = Number.isFinite(parsedTimeout) ? Math.max(0, Math.min(parsedTimeout, 50)) : 30;
158
- return await waitForUpdate(state, timeout);
159
- }
160
- if (method === "sendMessage" || method === "sendPhoto") {
161
- const chatId = requireParam(body, "chat_id");
162
- const content = requireParam(body, method === "sendMessage" ? "text" : "photo");
163
- const error = firstError(chatId, content);
164
- if (error) {
165
- return error;
166
- }
167
- return zaloOk({ date: Date.now(), message_id: messageId(state) });
551
+ return await waitForUpdate(request, response, state, timeout);
168
552
  }
169
553
  if (method === "sendChatAction") {
170
554
  const chatId = requireParam(body, "chat_id");
@@ -188,18 +572,37 @@ async function handleZaloMethod(request, state, url, method) {
188
572
  if (typeof webhookUrl !== "string" || typeof secretToken !== "string") {
189
573
  return zaloError("Invalid webhook parameters", 400);
190
574
  }
575
+ const secretError = validateZaloWebhookSecret(secretToken);
576
+ if (secretError) {
577
+ return secretError;
578
+ }
191
579
  let parsedUrl;
192
580
  try {
193
581
  parsedUrl = new URL(webhookUrl);
194
582
  }
195
583
  catch {
196
- return zaloError("url must be a valid HTTP or HTTPS URL", 400);
584
+ return zaloError("url must be a valid HTTPS URL", 400);
197
585
  }
198
- if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
199
- 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);
200
601
  }
201
- state.updates.length = 0;
202
602
  const webhook = { secretToken, updatedAt: Date.now(), url: parsedUrl.href };
603
+ if (state.pendingRequest) {
604
+ settlePendingUpdate(state, state.pendingRequest, { kind: "conflict" });
605
+ }
203
606
  state.webhook = webhook;
204
607
  return zaloOk({ updated_at: webhook.updatedAt, url: webhook.url });
205
608
  }
@@ -216,18 +619,32 @@ async function handleZaloMethod(request, state, url, method) {
216
619
  export async function startZaloServer(params = {}) {
217
620
  const host = params.host ?? "127.0.0.1";
218
621
  const state = {
622
+ activeWebhookRequests: new Set(),
623
+ activeWebhookValidations: new Set(),
219
624
  adminToken: params.adminToken ?? randomBytes(24).toString("hex"),
625
+ allowLoopbackHttpWebhook: isLoopbackHost(host),
220
626
  botId: params.botId ?? "1459232241454765289",
221
627
  botName: params.botName ?? "bot.crabline",
222
- 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),
223
634
  nextMessage: 1,
224
- pendingRequests: [],
635
+ nextUpdateOrder: 1,
636
+ onEvent: params.onEvent,
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
  },