@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.
- package/README.md +47 -22
- package/assets/crabline-banner.svg +20 -0
- package/dist/src/bin/crabline.js.map +1 -1
- package/dist/src/cli/program.d.ts +25 -1
- package/dist/src/cli/program.js +579 -82
- package/dist/src/cli/program.js.map +1 -1
- package/dist/src/config/load.js +59 -6
- package/dist/src/config/load.js.map +1 -1
- package/dist/src/config/schema.d.ts +116 -92
- package/dist/src/config/schema.js +179 -73
- package/dist/src/config/schema.js.map +1 -1
- package/dist/src/core/errors.js +9 -2
- package/dist/src/core/errors.js.map +1 -1
- package/dist/src/core/exit-codes.js.map +1 -1
- package/dist/src/core/matcher.js +7 -6
- package/dist/src/core/matcher.js.map +1 -1
- package/dist/src/core/message-template.js.map +1 -1
- package/dist/src/core/nonces.d.ts +1 -0
- package/dist/src/core/nonces.js +4 -2
- package/dist/src/core/nonces.js.map +1 -1
- package/dist/src/core/reporters.d.ts +1 -0
- package/dist/src/core/reporters.js +42 -4
- package/dist/src/core/reporters.js.map +1 -1
- package/dist/src/core/run.d.ts +3 -0
- package/dist/src/core/run.js +415 -107
- package/dist/src/core/run.js.map +1 -1
- package/dist/src/core/safe-regex.d.ts +3 -0
- package/dist/src/core/safe-regex.js +21 -0
- package/dist/src/core/safe-regex.js.map +1 -0
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/openclaw/artifact-generation.d.ts +43 -0
- package/dist/src/openclaw/artifact-generation.js +363 -0
- package/dist/src/openclaw/artifact-generation.js.map +1 -0
- package/dist/src/openclaw/bridges/matrix.js +97 -12
- package/dist/src/openclaw/bridges/matrix.js.map +1 -1
- package/dist/src/openclaw/bridges/mattermost.js +32 -10
- package/dist/src/openclaw/bridges/mattermost.js.map +1 -1
- package/dist/src/openclaw/bridges/signal.js +55 -18
- package/dist/src/openclaw/bridges/signal.js.map +1 -1
- package/dist/src/openclaw/bridges/slack.js +85 -7
- package/dist/src/openclaw/bridges/slack.js.map +1 -1
- package/dist/src/openclaw/bridges/telegram.js +124 -30
- package/dist/src/openclaw/bridges/telegram.js.map +1 -1
- package/dist/src/openclaw/bridges/whatsapp.js +78 -17
- package/dist/src/openclaw/bridges/whatsapp.js.map +1 -1
- package/dist/src/openclaw/bridges/zalo.js +14 -5
- package/dist/src/openclaw/bridges/zalo.js.map +1 -1
- package/dist/src/openclaw/outbound-contract.d.ts +5 -0
- package/dist/src/openclaw/outbound-contract.js +40 -0
- package/dist/src/openclaw/outbound-contract.js.map +1 -0
- package/dist/src/openclaw/private-file.d.ts +28 -0
- package/dist/src/openclaw/private-file.js +465 -0
- package/dist/src/openclaw/private-file.js.map +1 -0
- package/dist/src/openclaw/shared.d.ts +21 -2
- package/dist/src/openclaw/shared.js +115 -18
- package/dist/src/openclaw/shared.js.map +1 -1
- package/dist/src/openclaw/smoke-lock.d.ts +63 -0
- package/dist/src/openclaw/smoke-lock.js +1004 -0
- package/dist/src/openclaw/smoke-lock.js.map +1 -0
- package/dist/src/openclaw.d.ts +16 -8
- package/dist/src/openclaw.js +334 -52
- package/dist/src/openclaw.js.map +1 -1
- package/dist/src/providers/builtin/discord.d.ts +10 -1
- package/dist/src/providers/builtin/discord.js +144 -18
- package/dist/src/providers/builtin/discord.js.map +1 -1
- package/dist/src/providers/builtin/external-webhook-auth.d.ts +11 -0
- package/dist/src/providers/builtin/external-webhook-auth.js +10 -0
- package/dist/src/providers/builtin/external-webhook-auth.js.map +1 -0
- package/dist/src/providers/builtin/feishu.d.ts +19 -2
- package/dist/src/providers/builtin/feishu.js +307 -25
- package/dist/src/providers/builtin/feishu.js.map +1 -1
- package/dist/src/providers/builtin/googlechat.d.ts +20 -1
- package/dist/src/providers/builtin/googlechat.js +178 -19
- package/dist/src/providers/builtin/googlechat.js.map +1 -1
- package/dist/src/providers/builtin/imessage.d.ts +4 -0
- package/dist/src/providers/builtin/imessage.js +26 -11
- package/dist/src/providers/builtin/imessage.js.map +1 -1
- package/dist/src/providers/builtin/loopback.js +92 -30
- package/dist/src/providers/builtin/loopback.js.map +1 -1
- package/dist/src/providers/builtin/matrix.d.ts +11 -1
- package/dist/src/providers/builtin/matrix.js +53 -29
- package/dist/src/providers/builtin/matrix.js.map +1 -1
- package/dist/src/providers/builtin/mattermost.d.ts +10 -0
- package/dist/src/providers/builtin/mattermost.js +38 -15
- package/dist/src/providers/builtin/mattermost.js.map +1 -1
- package/dist/src/providers/builtin/msteams.d.ts +17 -1
- package/dist/src/providers/builtin/msteams.js +113 -14
- package/dist/src/providers/builtin/msteams.js.map +1 -1
- package/dist/src/providers/builtin/native-local-mock.d.ts +2 -9
- package/dist/src/providers/builtin/native-local-mock.js +12 -35
- package/dist/src/providers/builtin/native-local-mock.js.map +1 -1
- package/dist/src/providers/builtin/script.d.ts +11 -15
- package/dist/src/providers/builtin/script.js +566 -104
- package/dist/src/providers/builtin/script.js.map +1 -1
- package/dist/src/providers/builtin/slack.d.ts +6 -0
- package/dist/src/providers/builtin/slack.js +99 -40
- package/dist/src/providers/builtin/slack.js.map +1 -1
- package/dist/src/providers/builtin/telegram.d.ts +6 -0
- package/dist/src/providers/builtin/telegram.js +64 -43
- package/dist/src/providers/builtin/telegram.js.map +1 -1
- package/dist/src/providers/builtin/whatsapp.d.ts +28 -8
- package/dist/src/providers/builtin/whatsapp.js +567 -30
- package/dist/src/providers/builtin/whatsapp.js.map +1 -1
- package/dist/src/providers/builtin/zalo.js +30 -19
- package/dist/src/providers/builtin/zalo.js.map +1 -1
- package/dist/src/providers/catalog.d.ts +3 -3
- package/dist/src/providers/catalog.js +3 -3
- package/dist/src/providers/catalog.js.map +1 -1
- package/dist/src/providers/local-mock.d.ts +11 -2
- package/dist/src/providers/local-mock.js +322 -96
- package/dist/src/providers/local-mock.js.map +1 -1
- package/dist/src/providers/native-ids.d.ts +1 -0
- package/dist/src/providers/native-ids.js +6 -1
- package/dist/src/providers/native-ids.js.map +1 -1
- package/dist/src/providers/recorder.d.ts +30 -2
- package/dist/src/providers/recorder.js +549 -70
- package/dist/src/providers/recorder.js.map +1 -1
- package/dist/src/providers/registry.d.ts +25 -1
- package/dist/src/providers/registry.js +280 -40
- package/dist/src/providers/registry.js.map +1 -1
- package/dist/src/providers/signed-jwt.d.ts +29 -0
- package/dist/src/providers/signed-jwt.js +202 -0
- package/dist/src/providers/signed-jwt.js.map +1 -0
- package/dist/src/providers/slack-ids.js.map +1 -1
- package/dist/src/providers/target-normalizers.d.ts +26 -0
- package/dist/src/providers/target-normalizers.js +276 -0
- package/dist/src/providers/target-normalizers.js.map +1 -0
- package/dist/src/providers/types.d.ts +5 -0
- package/dist/src/providers/types.js.map +1 -1
- package/dist/src/providers/webhook-server.d.ts +2 -0
- package/dist/src/providers/webhook-server.js +72 -25
- package/dist/src/providers/webhook-server.js.map +1 -1
- package/dist/src/servers/http.d.ts +23 -3
- package/dist/src/servers/http.js +213 -20
- package/dist/src/servers/http.js.map +1 -1
- package/dist/src/servers/index.js +20 -21
- package/dist/src/servers/index.js.map +1 -1
- package/dist/src/servers/matrix.js +465 -51
- package/dist/src/servers/matrix.js.map +1 -1
- package/dist/src/servers/mattermost.d.ts +5 -0
- package/dist/src/servers/mattermost.js +306 -58
- package/dist/src/servers/mattermost.js.map +1 -1
- package/dist/src/servers/pending-events.d.ts +2 -0
- package/dist/src/servers/pending-events.js +11 -0
- package/dist/src/servers/pending-events.js.map +1 -0
- package/dist/src/servers/recorder.d.ts +5 -0
- package/dist/src/servers/recorder.js +262 -3
- package/dist/src/servers/recorder.js.map +1 -1
- package/dist/src/servers/signal.d.ts +2 -0
- package/dist/src/servers/signal.js +444 -56
- package/dist/src/servers/signal.js.map +1 -1
- package/dist/src/servers/slack.d.ts +5 -0
- package/dist/src/servers/slack.js +515 -49
- package/dist/src/servers/slack.js.map +1 -1
- package/dist/src/servers/telegram.d.ts +111 -0
- package/dist/src/servers/telegram.js +830 -158
- package/dist/src/servers/telegram.js.map +1 -1
- package/dist/src/servers/webhook-target.d.ts +40 -0
- package/dist/src/servers/webhook-target.js +349 -0
- package/dist/src/servers/webhook-target.js.map +1 -0
- package/dist/src/servers/websocket.d.ts +2 -0
- package/dist/src/servers/websocket.js +29 -0
- package/dist/src/servers/websocket.js.map +1 -0
- package/dist/src/servers/whatsapp-baileys-websocket.d.ts +65 -1
- package/dist/src/servers/whatsapp-baileys-websocket.js +723 -99
- package/dist/src/servers/whatsapp-baileys-websocket.js.map +1 -1
- package/dist/src/servers/whatsapp-jid.d.ts +5 -0
- package/dist/src/servers/whatsapp-jid.js +32 -0
- package/dist/src/servers/whatsapp-jid.js.map +1 -0
- package/dist/src/servers/whatsapp-wire/binary-node.d.ts +5 -0
- package/dist/src/servers/whatsapp-wire/binary-node.js +87 -20
- package/dist/src/servers/whatsapp-wire/binary-node.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/crypto.d.ts +6 -1
- package/dist/src/servers/whatsapp-wire/crypto.js +99 -45
- package/dist/src/servers/whatsapp-wire/crypto.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/handshake.js +39 -4
- package/dist/src/servers/whatsapp-wire/handshake.js.map +1 -1
- package/dist/src/servers/whatsapp-wire/signal.js.map +1 -1
- package/dist/src/servers/whatsapp.d.ts +12 -7
- package/dist/src/servers/whatsapp.js +269 -107
- package/dist/src/servers/whatsapp.js.map +1 -1
- package/dist/src/servers/zalo.d.ts +14 -0
- package/dist/src/servers/zalo.js +521 -88
- package/dist/src/servers/zalo.js.map +1 -1
- package/docs/channel-setup.md +197 -19
- package/fixtures/examples/crabline.example.yaml +10 -0
- package/package.json +20 -15
package/dist/src/servers/zalo.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
334
|
+
return queued;
|
|
43
335
|
}
|
|
44
336
|
if (timeoutSeconds <= 0) {
|
|
45
|
-
return
|
|
337
|
+
return zaloError("Request timeout", 408);
|
|
46
338
|
}
|
|
47
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
63
|
-
if (
|
|
64
|
-
state.updates.
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 (
|
|
81
|
-
return zaloError(`Webhook delivery failed with HTTP ${
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
|
|
502
|
+
finally {
|
|
503
|
+
releaseAdmission();
|
|
130
504
|
}
|
|
131
|
-
return zaloOk(update);
|
|
132
505
|
}
|
|
133
|
-
async function handleZaloMethod(request, state, url, method) {
|
|
134
|
-
const
|
|
135
|
-
|
|
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
|
|
584
|
+
return zaloError("url must be a valid HTTPS URL", 400);
|
|
196
585
|
}
|
|
197
|
-
if (
|
|
198
|
-
return zaloError("
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
},
|