@openclaw/zalo 2026.2.24 → 2026.3.1
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/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/accounts.ts +11 -3
- package/src/monitor.ts +24 -7
- package/src/monitor.webhook.test.ts +65 -2
- package/src/monitor.webhook.ts +59 -69
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2026.3.1
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- Version alignment with core OpenClaw release numbers.
|
|
8
|
+
|
|
9
|
+
## 2026.2.26
|
|
10
|
+
|
|
11
|
+
### Changes
|
|
12
|
+
|
|
13
|
+
- Version alignment with core OpenClaw release numbers.
|
|
14
|
+
|
|
15
|
+
## 2026.2.25
|
|
16
|
+
|
|
17
|
+
### Changes
|
|
18
|
+
|
|
19
|
+
- Version alignment with core OpenClaw release numbers.
|
|
20
|
+
|
|
3
21
|
## 2026.2.24
|
|
4
22
|
|
|
5
23
|
### Changes
|
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
normalizeAccountId,
|
|
5
|
+
normalizeOptionalAccountId,
|
|
6
|
+
} from "openclaw/plugin-sdk/account-id";
|
|
3
7
|
import { resolveZaloToken } from "./token.js";
|
|
4
8
|
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
|
|
5
9
|
|
|
@@ -23,8 +27,12 @@ export function listZaloAccountIds(cfg: OpenClawConfig): string[] {
|
|
|
23
27
|
|
|
24
28
|
export function resolveDefaultZaloAccountId(cfg: OpenClawConfig): string {
|
|
25
29
|
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
const preferred = normalizeOptionalAccountId(zaloConfig?.defaultAccount);
|
|
31
|
+
if (
|
|
32
|
+
preferred &&
|
|
33
|
+
listZaloAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
|
|
34
|
+
) {
|
|
35
|
+
return preferred;
|
|
28
36
|
}
|
|
29
37
|
const ids = listZaloAccountIds(cfg);
|
|
30
38
|
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
package/src/monitor.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
|
|
3
3
|
import {
|
|
4
|
+
createScopedPairingAccess,
|
|
4
5
|
createReplyPrefixOptions,
|
|
5
6
|
resolveSenderCommandAuthorization,
|
|
6
7
|
resolveOutboundMediaUrls,
|
|
@@ -27,6 +28,9 @@ import {
|
|
|
27
28
|
resolveZaloRuntimeGroupPolicy,
|
|
28
29
|
} from "./group-access.js";
|
|
29
30
|
import {
|
|
31
|
+
clearZaloWebhookSecurityStateForTest,
|
|
32
|
+
getZaloWebhookRateLimitStateSizeForTest,
|
|
33
|
+
getZaloWebhookStatusCounterSizeForTest,
|
|
30
34
|
handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
|
|
31
35
|
registerZaloWebhookTarget as registerZaloWebhookTargetInternal,
|
|
32
36
|
type ZaloWebhookTarget,
|
|
@@ -72,6 +76,12 @@ export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void
|
|
|
72
76
|
return registerZaloWebhookTargetInternal(target);
|
|
73
77
|
}
|
|
74
78
|
|
|
79
|
+
export {
|
|
80
|
+
clearZaloWebhookSecurityStateForTest,
|
|
81
|
+
getZaloWebhookRateLimitStateSizeForTest,
|
|
82
|
+
getZaloWebhookStatusCounterSizeForTest,
|
|
83
|
+
};
|
|
84
|
+
|
|
75
85
|
export async function handleZaloWebhookRequest(
|
|
76
86
|
req: IncomingMessage,
|
|
77
87
|
res: ServerResponse,
|
|
@@ -142,7 +152,7 @@ function startPollingLoop(params: {
|
|
|
142
152
|
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
|
143
153
|
// no updates
|
|
144
154
|
} else if (!isStopped() && !abortSignal.aborted) {
|
|
145
|
-
|
|
155
|
+
runtime.error?.(`[${account.accountId}] Zalo polling error: ${String(err)}`);
|
|
146
156
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
147
157
|
}
|
|
148
158
|
}
|
|
@@ -189,10 +199,12 @@ async function processUpdate(
|
|
|
189
199
|
);
|
|
190
200
|
break;
|
|
191
201
|
case "message.sticker.received":
|
|
192
|
-
|
|
202
|
+
logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`);
|
|
193
203
|
break;
|
|
194
204
|
case "message.unsupported.received":
|
|
195
|
-
|
|
205
|
+
logVerbose(
|
|
206
|
+
core,
|
|
207
|
+
runtime,
|
|
196
208
|
`[${account.accountId}] Received unsupported message type from ${message.from.id}`,
|
|
197
209
|
);
|
|
198
210
|
break;
|
|
@@ -258,7 +270,7 @@ async function handleImageMessage(
|
|
|
258
270
|
mediaPath = saved.path;
|
|
259
271
|
mediaType = saved.contentType;
|
|
260
272
|
} catch (err) {
|
|
261
|
-
|
|
273
|
+
runtime.error?.(`[${account.accountId}] Failed to download Zalo image: ${String(err)}`);
|
|
262
274
|
}
|
|
263
275
|
}
|
|
264
276
|
|
|
@@ -303,6 +315,11 @@ async function processMessageWithPipeline(params: {
|
|
|
303
315
|
statusSink,
|
|
304
316
|
fetcher,
|
|
305
317
|
} = params;
|
|
318
|
+
const pairing = createScopedPairingAccess({
|
|
319
|
+
core,
|
|
320
|
+
channel: "zalo",
|
|
321
|
+
accountId: account.accountId,
|
|
322
|
+
});
|
|
306
323
|
const { from, chat, message_id, date } = message;
|
|
307
324
|
|
|
308
325
|
const isGroup = chat.chat_type === "GROUP";
|
|
@@ -355,9 +372,10 @@ async function processMessageWithPipeline(params: {
|
|
|
355
372
|
isGroup,
|
|
356
373
|
dmPolicy,
|
|
357
374
|
configuredAllowFrom: configAllowFrom,
|
|
375
|
+
configuredGroupAllowFrom: groupAllowFrom,
|
|
358
376
|
senderId,
|
|
359
377
|
isSenderAllowed: isZaloSenderAllowed,
|
|
360
|
-
readAllowFromStore:
|
|
378
|
+
readAllowFromStore: pairing.readAllowFromStore,
|
|
361
379
|
shouldComputeCommandAuthorized: (body, cfg) =>
|
|
362
380
|
core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
|
|
363
381
|
resolveCommandAuthorizedFromAuthorizers: (params) =>
|
|
@@ -375,8 +393,7 @@ async function processMessageWithPipeline(params: {
|
|
|
375
393
|
|
|
376
394
|
if (!allowed) {
|
|
377
395
|
if (dmPolicy === "pairing") {
|
|
378
|
-
const { code, created } = await
|
|
379
|
-
channel: "zalo",
|
|
396
|
+
const { code, created } = await pairing.upsertPairingRequest({
|
|
380
397
|
id: senderId,
|
|
381
398
|
meta: { name: senderName ?? undefined },
|
|
382
399
|
});
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { createServer, type RequestListener } from "node:http";
|
|
2
2
|
import type { AddressInfo } from "node:net";
|
|
3
3
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
4
|
-
import { describe, expect, it, vi } from "vitest";
|
|
5
|
-
import {
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
clearZaloWebhookSecurityStateForTest,
|
|
7
|
+
getZaloWebhookRateLimitStateSizeForTest,
|
|
8
|
+
getZaloWebhookStatusCounterSizeForTest,
|
|
9
|
+
handleZaloWebhookRequest,
|
|
10
|
+
registerZaloWebhookTarget,
|
|
11
|
+
} from "./monitor.js";
|
|
6
12
|
import type { ResolvedZaloAccount } from "./types.js";
|
|
7
13
|
|
|
8
14
|
async function withServer(handler: RequestListener, fn: (baseUrl: string) => Promise<void>) {
|
|
@@ -56,6 +62,10 @@ function registerTarget(params: {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
describe("handleZaloWebhookRequest", () => {
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
clearZaloWebhookSecurityStateForTest();
|
|
67
|
+
});
|
|
68
|
+
|
|
59
69
|
it("returns 400 for non-object payloads", async () => {
|
|
60
70
|
const unregister = registerTarget({ path: "/hook" });
|
|
61
71
|
|
|
@@ -196,4 +206,57 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
196
206
|
unregister();
|
|
197
207
|
}
|
|
198
208
|
});
|
|
209
|
+
|
|
210
|
+
it("does not grow status counters when query strings churn on unauthorized requests", async () => {
|
|
211
|
+
const unregister = registerTarget({ path: "/hook-query-status" });
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
215
|
+
for (let i = 0; i < 200; i += 1) {
|
|
216
|
+
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: {
|
|
219
|
+
"x-bot-api-secret-token": "invalid-token",
|
|
220
|
+
"content-type": "application/json",
|
|
221
|
+
},
|
|
222
|
+
body: "{}",
|
|
223
|
+
});
|
|
224
|
+
expect(response.status).toBe(401);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
expect(getZaloWebhookStatusCounterSizeForTest()).toBe(1);
|
|
228
|
+
});
|
|
229
|
+
} finally {
|
|
230
|
+
unregister();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("rate limits authenticated requests even when query strings churn", async () => {
|
|
235
|
+
const unregister = registerTarget({ path: "/hook-query-rate" });
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
239
|
+
let saw429 = false;
|
|
240
|
+
for (let i = 0; i < 130; i += 1) {
|
|
241
|
+
const response = await fetch(`${baseUrl}/hook-query-rate?nonce=${i}`, {
|
|
242
|
+
method: "POST",
|
|
243
|
+
headers: {
|
|
244
|
+
"x-bot-api-secret-token": "secret",
|
|
245
|
+
"content-type": "application/json",
|
|
246
|
+
},
|
|
247
|
+
body: "{}",
|
|
248
|
+
});
|
|
249
|
+
if (response.status === 429) {
|
|
250
|
+
saw429 = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
expect(saw429).toBe(true);
|
|
256
|
+
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
|
257
|
+
});
|
|
258
|
+
} finally {
|
|
259
|
+
unregister();
|
|
260
|
+
}
|
|
261
|
+
});
|
|
199
262
|
});
|
package/src/monitor.webhook.ts
CHANGED
|
@@ -3,23 +3,21 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
3
3
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
4
|
import {
|
|
5
5
|
createDedupeCache,
|
|
6
|
-
|
|
6
|
+
createFixedWindowRateLimiter,
|
|
7
|
+
createWebhookAnomalyTracker,
|
|
8
|
+
readJsonWebhookBodyOrReject,
|
|
9
|
+
applyBasicWebhookRequestGuards,
|
|
7
10
|
registerWebhookTarget,
|
|
8
|
-
rejectNonPostWebhookRequest,
|
|
9
|
-
requestBodyErrorToText,
|
|
10
11
|
resolveSingleWebhookTarget,
|
|
11
12
|
resolveWebhookTargets,
|
|
13
|
+
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
|
14
|
+
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
12
15
|
} from "openclaw/plugin-sdk";
|
|
13
16
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
14
17
|
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
|
15
18
|
import type { ZaloRuntimeEnv } from "./monitor.js";
|
|
16
19
|
|
|
17
|
-
type WebhookRateLimitState = { count: number; windowStartMs: number };
|
|
18
|
-
|
|
19
|
-
const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
20
|
-
const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
|
|
21
20
|
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
|
|
22
|
-
const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
|
|
23
21
|
|
|
24
22
|
export type ZaloWebhookTarget = {
|
|
25
23
|
token: string;
|
|
@@ -40,20 +38,32 @@ export type ZaloWebhookProcessUpdate = (params: {
|
|
|
40
38
|
}) => Promise<void>;
|
|
41
39
|
|
|
42
40
|
const webhookTargets = new Map<string, ZaloWebhookTarget[]>();
|
|
43
|
-
const
|
|
41
|
+
const webhookRateLimiter = createFixedWindowRateLimiter({
|
|
42
|
+
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
|
|
43
|
+
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
|
|
44
|
+
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
|
|
45
|
+
});
|
|
44
46
|
const recentWebhookEvents = createDedupeCache({
|
|
45
47
|
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
|
|
46
48
|
maxSize: 5000,
|
|
47
49
|
});
|
|
48
|
-
const
|
|
50
|
+
const webhookAnomalyTracker = createWebhookAnomalyTracker({
|
|
51
|
+
maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
|
|
52
|
+
ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs,
|
|
53
|
+
logEvery: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery,
|
|
54
|
+
});
|
|
49
55
|
|
|
50
|
-
function
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return
|
|
56
|
+
export function clearZaloWebhookSecurityStateForTest(): void {
|
|
57
|
+
webhookRateLimiter.clear();
|
|
58
|
+
webhookAnomalyTracker.clear();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getZaloWebhookRateLimitStateSizeForTest(): number {
|
|
62
|
+
return webhookRateLimiter.size();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getZaloWebhookStatusCounterSizeForTest(): number {
|
|
66
|
+
return webhookAnomalyTracker.size();
|
|
57
67
|
}
|
|
58
68
|
|
|
59
69
|
function timingSafeEquals(left: string, right: string): boolean {
|
|
@@ -73,20 +83,6 @@ function timingSafeEquals(left: string, right: string): boolean {
|
|
|
73
83
|
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
function isWebhookRateLimited(key: string, nowMs: number): boolean {
|
|
77
|
-
const state = webhookRateLimits.get(key);
|
|
78
|
-
if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
|
|
79
|
-
webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
state.count += 1;
|
|
84
|
-
if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
86
|
function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
|
|
91
87
|
const messageId = update.message?.message_id;
|
|
92
88
|
if (!messageId) {
|
|
@@ -101,17 +97,13 @@ function recordWebhookStatus(
|
|
|
101
97
|
path: string,
|
|
102
98
|
statusCode: number,
|
|
103
99
|
): void {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
runtime?.log?.(
|
|
112
|
-
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
|
|
113
|
-
);
|
|
114
|
-
}
|
|
100
|
+
webhookAnomalyTracker.record({
|
|
101
|
+
key: `${path}:${statusCode}`,
|
|
102
|
+
statusCode,
|
|
103
|
+
log: runtime?.log,
|
|
104
|
+
message: (count) =>
|
|
105
|
+
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(count)}`,
|
|
106
|
+
});
|
|
115
107
|
}
|
|
116
108
|
|
|
117
109
|
export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
|
|
@@ -127,9 +119,15 @@ export async function handleZaloWebhookRequest(
|
|
|
127
119
|
if (!resolved) {
|
|
128
120
|
return false;
|
|
129
121
|
}
|
|
130
|
-
const { targets } = resolved;
|
|
122
|
+
const { targets, path } = resolved;
|
|
131
123
|
|
|
132
|
-
if (
|
|
124
|
+
if (
|
|
125
|
+
!applyBasicWebhookRequestGuards({
|
|
126
|
+
req,
|
|
127
|
+
res,
|
|
128
|
+
allowMethods: ["POST"],
|
|
129
|
+
})
|
|
130
|
+
) {
|
|
133
131
|
return true;
|
|
134
132
|
}
|
|
135
133
|
|
|
@@ -140,55 +138,47 @@ export async function handleZaloWebhookRequest(
|
|
|
140
138
|
if (matchedTarget.kind === "none") {
|
|
141
139
|
res.statusCode = 401;
|
|
142
140
|
res.end("unauthorized");
|
|
143
|
-
recordWebhookStatus(targets[0]?.runtime,
|
|
141
|
+
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
144
142
|
return true;
|
|
145
143
|
}
|
|
146
144
|
if (matchedTarget.kind === "ambiguous") {
|
|
147
145
|
res.statusCode = 401;
|
|
148
146
|
res.end("ambiguous webhook target");
|
|
149
|
-
recordWebhookStatus(targets[0]?.runtime,
|
|
147
|
+
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
150
148
|
return true;
|
|
151
149
|
}
|
|
152
150
|
const target = matchedTarget.target;
|
|
153
|
-
const path = req.url ?? "<unknown>";
|
|
154
151
|
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
155
152
|
const nowMs = Date.now();
|
|
156
153
|
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
154
|
+
if (
|
|
155
|
+
!applyBasicWebhookRequestGuards({
|
|
156
|
+
req,
|
|
157
|
+
res,
|
|
158
|
+
rateLimiter: webhookRateLimiter,
|
|
159
|
+
rateLimitKey,
|
|
160
|
+
nowMs,
|
|
161
|
+
requireJsonContentType: true,
|
|
162
|
+
})
|
|
163
|
+
) {
|
|
160
164
|
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
161
165
|
return true;
|
|
162
166
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
res
|
|
166
|
-
res.end("Unsupported Media Type");
|
|
167
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
const body = await readJsonBodyWithLimit(req, {
|
|
167
|
+
const body = await readJsonWebhookBodyOrReject({
|
|
168
|
+
req,
|
|
169
|
+
res,
|
|
172
170
|
maxBytes: 1024 * 1024,
|
|
173
171
|
timeoutMs: 30_000,
|
|
174
172
|
emptyObjectOnEmpty: false,
|
|
173
|
+
invalidJsonMessage: "Bad Request",
|
|
175
174
|
});
|
|
176
175
|
if (!body.ok) {
|
|
177
|
-
res.statusCode =
|
|
178
|
-
body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
|
|
179
|
-
const message =
|
|
180
|
-
body.code === "PAYLOAD_TOO_LARGE"
|
|
181
|
-
? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
|
|
182
|
-
: body.code === "REQUEST_BODY_TIMEOUT"
|
|
183
|
-
? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
|
|
184
|
-
: "Bad Request";
|
|
185
|
-
res.end(message);
|
|
186
176
|
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
187
177
|
return true;
|
|
188
178
|
}
|
|
179
|
+
const raw = body.value;
|
|
189
180
|
|
|
190
181
|
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
|
|
191
|
-
const raw = body.value;
|
|
192
182
|
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
193
183
|
const update: ZaloUpdate | undefined =
|
|
194
184
|
record && record.ok === true && record.result
|