@openclaw/zalo 2026.3.1 → 2026.3.7

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/src/monitor.ts CHANGED
@@ -1,15 +1,22 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
2
+ import type {
3
+ MarkdownTableMode,
4
+ OpenClawConfig,
5
+ OutboundReplyPayload,
6
+ } from "openclaw/plugin-sdk/zalo";
3
7
  import {
4
8
  createScopedPairingAccess,
5
9
  createReplyPrefixOptions,
6
- resolveSenderCommandAuthorization,
10
+ issuePairingChallenge,
11
+ resolveDirectDmAuthorizationOutcome,
12
+ resolveSenderCommandAuthorizationWithRuntime,
7
13
  resolveOutboundMediaUrls,
8
14
  resolveDefaultGroupPolicy,
15
+ resolveInboundRouteEnvelopeBuilderWithRuntime,
9
16
  sendMediaWithLeadingCaption,
10
17
  resolveWebhookPath,
11
18
  warnMissingProviderGroupPolicyFallbackOnce,
12
- } from "openclaw/plugin-sdk";
19
+ } from "openclaw/plugin-sdk/zalo";
13
20
  import type { ResolvedZaloAccount } from "./accounts.js";
14
21
  import {
15
22
  ZaloApiError,
@@ -73,7 +80,24 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
73
80
  }
74
81
 
75
82
  export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
76
- return registerZaloWebhookTargetInternal(target);
83
+ return registerZaloWebhookTargetInternal(target, {
84
+ route: {
85
+ auth: "plugin",
86
+ match: "exact",
87
+ pluginId: "zalo",
88
+ source: "zalo-webhook",
89
+ accountId: target.account.accountId,
90
+ log: target.runtime.log,
91
+ handler: async (req, res) => {
92
+ const handled = await handleZaloWebhookRequest(req, res);
93
+ if (!handled && !res.headersSent) {
94
+ res.statusCode = 404;
95
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
96
+ res.end("Not Found");
97
+ }
98
+ },
99
+ },
100
+ });
77
101
  }
78
102
 
79
103
  export {
@@ -366,82 +390,75 @@ async function processMessageWithPipeline(params: {
366
390
  }
367
391
 
368
392
  const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
369
- const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
370
- cfg: config,
371
- rawBody,
393
+ const { senderAllowedForCommands, commandAuthorized } =
394
+ await resolveSenderCommandAuthorizationWithRuntime({
395
+ cfg: config,
396
+ rawBody,
397
+ isGroup,
398
+ dmPolicy,
399
+ configuredAllowFrom: configAllowFrom,
400
+ configuredGroupAllowFrom: groupAllowFrom,
401
+ senderId,
402
+ isSenderAllowed: isZaloSenderAllowed,
403
+ readAllowFromStore: pairing.readAllowFromStore,
404
+ runtime: core.channel.commands,
405
+ });
406
+
407
+ const directDmOutcome = resolveDirectDmAuthorizationOutcome({
372
408
  isGroup,
373
409
  dmPolicy,
374
- configuredAllowFrom: configAllowFrom,
375
- configuredGroupAllowFrom: groupAllowFrom,
376
- senderId,
377
- isSenderAllowed: isZaloSenderAllowed,
378
- readAllowFromStore: pairing.readAllowFromStore,
379
- shouldComputeCommandAuthorized: (body, cfg) =>
380
- core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
381
- resolveCommandAuthorizedFromAuthorizers: (params) =>
382
- core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params),
410
+ senderAllowedForCommands,
383
411
  });
384
-
385
- if (!isGroup) {
386
- if (dmPolicy === "disabled") {
387
- logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
388
- return;
389
- }
390
-
391
- if (dmPolicy !== "open") {
392
- const allowed = senderAllowedForCommands;
393
-
394
- if (!allowed) {
395
- if (dmPolicy === "pairing") {
396
- const { code, created } = await pairing.upsertPairingRequest({
397
- id: senderId,
398
- meta: { name: senderName ?? undefined },
399
- });
400
-
401
- if (created) {
402
- logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
403
- try {
404
- await sendMessage(
405
- token,
406
- {
407
- chat_id: chatId,
408
- text: core.channel.pairing.buildPairingReply({
409
- channel: "zalo",
410
- idLine: `Your Zalo user id: ${senderId}`,
411
- code,
412
- }),
413
- },
414
- fetcher,
415
- );
416
- statusSink?.({ lastOutboundAt: Date.now() });
417
- } catch (err) {
418
- logVerbose(
419
- core,
420
- runtime,
421
- `zalo pairing reply failed for ${senderId}: ${String(err)}`,
422
- );
423
- }
424
- }
425
- } else {
426
- logVerbose(
427
- core,
428
- runtime,
429
- `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
412
+ if (directDmOutcome === "disabled") {
413
+ logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
414
+ return;
415
+ }
416
+ if (directDmOutcome === "unauthorized") {
417
+ if (dmPolicy === "pairing") {
418
+ await issuePairingChallenge({
419
+ channel: "zalo",
420
+ senderId,
421
+ senderIdLine: `Your Zalo user id: ${senderId}`,
422
+ meta: { name: senderName ?? undefined },
423
+ upsertPairingRequest: pairing.upsertPairingRequest,
424
+ onCreated: () => {
425
+ logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
426
+ },
427
+ sendPairingReply: async (text) => {
428
+ await sendMessage(
429
+ token,
430
+ {
431
+ chat_id: chatId,
432
+ text,
433
+ },
434
+ fetcher,
430
435
  );
431
- }
432
- return;
433
- }
436
+ statusSink?.({ lastOutboundAt: Date.now() });
437
+ },
438
+ onReplyError: (err) => {
439
+ logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
440
+ },
441
+ });
442
+ } else {
443
+ logVerbose(
444
+ core,
445
+ runtime,
446
+ `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
447
+ );
434
448
  }
449
+ return;
435
450
  }
436
451
 
437
- const route = core.channel.routing.resolveAgentRoute({
452
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
438
453
  cfg: config,
439
454
  channel: "zalo",
440
455
  accountId: account.accountId,
441
456
  peer: {
442
- kind: isGroup ? "group" : "direct",
457
+ kind: isGroup ? ("group" as const) : ("direct" as const),
443
458
  id: chatId,
444
459
  },
460
+ runtime: core.channel,
461
+ sessionStore: config.session?.store,
445
462
  });
446
463
 
447
464
  if (
@@ -454,20 +471,10 @@ async function processMessageWithPipeline(params: {
454
471
  }
455
472
 
456
473
  const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
457
- const storePath = core.channel.session.resolveStorePath(config.session?.store, {
458
- agentId: route.agentId,
459
- });
460
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
461
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
462
- storePath,
463
- sessionKey: route.sessionKey,
464
- });
465
- const body = core.channel.reply.formatAgentEnvelope({
474
+ const { storePath, body } = buildEnvelope({
466
475
  channel: "Zalo",
467
476
  from: fromLabel,
468
477
  timestamp: date ? date * 1000 : undefined,
469
- previousTimestamp,
470
- envelope: envelopeOptions,
471
478
  body: rawBody,
472
479
  });
473
480
 
@@ -1,7 +1,9 @@
1
1
  import { createServer, type RequestListener } from "node:http";
2
2
  import type { AddressInfo } from "node:net";
3
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/zalo";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
6
+ import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
5
7
  import {
6
8
  clearZaloWebhookSecurityStateForTest,
7
9
  getZaloWebhookRateLimitStateSizeForTest,
@@ -47,13 +49,16 @@ function registerTarget(params: {
47
49
  path: string;
48
50
  secret?: string;
49
51
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
52
+ account?: ResolvedZaloAccount;
53
+ config?: OpenClawConfig;
54
+ core?: PluginRuntime;
50
55
  }): () => void {
51
56
  return registerZaloWebhookTarget({
52
57
  token: "tok",
53
- account: DEFAULT_ACCOUNT,
54
- config: {} as OpenClawConfig,
58
+ account: params.account ?? DEFAULT_ACCOUNT,
59
+ config: params.config ?? ({} as OpenClawConfig),
55
60
  runtime: {},
56
- core: {} as PluginRuntime,
61
+ core: params.core ?? ({} as PluginRuntime),
57
62
  secret: params.secret ?? "secret",
58
63
  path: params.path,
59
64
  mediaMaxMb: 5,
@@ -61,9 +66,86 @@ function registerTarget(params: {
61
66
  });
62
67
  }
63
68
 
69
+ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCreated?: boolean }): {
70
+ core: PluginRuntime;
71
+ readAllowFromStore: ReturnType<typeof vi.fn>;
72
+ upsertPairingRequest: ReturnType<typeof vi.fn>;
73
+ } {
74
+ const readAllowFromStore = vi.fn().mockResolvedValue(params?.storeAllowFrom ?? []);
75
+ const upsertPairingRequest = vi
76
+ .fn()
77
+ .mockResolvedValue({ code: "PAIRCODE", created: params?.pairingCreated ?? false });
78
+ const core = {
79
+ logging: {
80
+ shouldLogVerbose: () => false,
81
+ },
82
+ channel: {
83
+ pairing: {
84
+ readAllowFromStore,
85
+ upsertPairingRequest,
86
+ buildPairingReply: vi.fn(() => "Pairing code: PAIRCODE"),
87
+ },
88
+ commands: {
89
+ shouldComputeCommandAuthorized: vi.fn(() => false),
90
+ resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
91
+ },
92
+ },
93
+ } as unknown as PluginRuntime;
94
+ return { core, readAllowFromStore, upsertPairingRequest };
95
+ }
96
+
97
+ async function postUntilRateLimited(params: {
98
+ baseUrl: string;
99
+ path: string;
100
+ secret: string;
101
+ withNonceQuery?: boolean;
102
+ attempts?: number;
103
+ }): Promise<boolean> {
104
+ const attempts = params.attempts ?? 130;
105
+ for (let i = 0; i < attempts; i += 1) {
106
+ const url = params.withNonceQuery
107
+ ? `${params.baseUrl}${params.path}?nonce=${i}`
108
+ : `${params.baseUrl}${params.path}`;
109
+ const response = await fetch(url, {
110
+ method: "POST",
111
+ headers: {
112
+ "x-bot-api-secret-token": params.secret,
113
+ "content-type": "application/json",
114
+ },
115
+ body: "{}",
116
+ });
117
+ if (response.status === 429) {
118
+ return true;
119
+ }
120
+ }
121
+ return false;
122
+ }
123
+
64
124
  describe("handleZaloWebhookRequest", () => {
65
125
  afterEach(() => {
66
126
  clearZaloWebhookSecurityStateForTest();
127
+ setActivePluginRegistry(createEmptyPluginRegistry());
128
+ });
129
+
130
+ it("registers and unregisters plugin HTTP route at path boundaries", () => {
131
+ const registry = createEmptyPluginRegistry();
132
+ setActivePluginRegistry(registry);
133
+ const unregisterA = registerTarget({ path: "/hook" });
134
+ const unregisterB = registerTarget({ path: "/hook" });
135
+
136
+ expect(registry.httpRoutes).toHaveLength(1);
137
+ expect(registry.httpRoutes[0]).toEqual(
138
+ expect.objectContaining({
139
+ pluginId: "zalo",
140
+ path: "/hook",
141
+ source: "zalo-webhook",
142
+ }),
143
+ );
144
+
145
+ unregisterA();
146
+ expect(registry.httpRoutes).toHaveLength(1);
147
+ unregisterB();
148
+ expect(registry.httpRoutes).toHaveLength(0);
67
149
  });
68
150
 
69
151
  it("returns 400 for non-object payloads", async () => {
@@ -184,21 +266,11 @@ describe("handleZaloWebhookRequest", () => {
184
266
 
185
267
  try {
186
268
  await withServer(webhookRequestHandler, async (baseUrl) => {
187
- let saw429 = false;
188
- for (let i = 0; i < 130; i += 1) {
189
- const response = await fetch(`${baseUrl}/hook-rate`, {
190
- method: "POST",
191
- headers: {
192
- "x-bot-api-secret-token": "secret",
193
- "content-type": "application/json",
194
- },
195
- body: "{}",
196
- });
197
- if (response.status === 429) {
198
- saw429 = true;
199
- break;
200
- }
201
- }
269
+ const saw429 = await postUntilRateLimited({
270
+ baseUrl,
271
+ path: "/hook-rate",
272
+ secret: "secret", // pragma: allowlist secret
273
+ });
202
274
 
203
275
  expect(saw429).toBe(true);
204
276
  });
@@ -206,7 +278,6 @@ describe("handleZaloWebhookRequest", () => {
206
278
  unregister();
207
279
  }
208
280
  });
209
-
210
281
  it("does not grow status counters when query strings churn on unauthorized requests", async () => {
211
282
  const unregister = registerTarget({ path: "/hook-query-status" });
212
283
 
@@ -216,7 +287,7 @@ describe("handleZaloWebhookRequest", () => {
216
287
  const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
217
288
  method: "POST",
218
289
  headers: {
219
- "x-bot-api-secret-token": "invalid-token",
290
+ "x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
220
291
  "content-type": "application/json",
221
292
  },
222
293
  body: "{}",
@@ -236,21 +307,12 @@ describe("handleZaloWebhookRequest", () => {
236
307
 
237
308
  try {
238
309
  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
- }
310
+ const saw429 = await postUntilRateLimited({
311
+ baseUrl,
312
+ path: "/hook-query-rate",
313
+ secret: "secret", // pragma: allowlist secret
314
+ withNonceQuery: true,
315
+ });
254
316
 
255
317
  expect(saw429).toBe(true);
256
318
  expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
@@ -259,4 +321,65 @@ describe("handleZaloWebhookRequest", () => {
259
321
  unregister();
260
322
  }
261
323
  });
324
+
325
+ it("scopes DM pairing store reads and writes to accountId", async () => {
326
+ const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
327
+ pairingCreated: false,
328
+ });
329
+ const account: ResolvedZaloAccount = {
330
+ ...DEFAULT_ACCOUNT,
331
+ accountId: "work",
332
+ config: {
333
+ dmPolicy: "pairing",
334
+ allowFrom: [],
335
+ },
336
+ };
337
+ const unregister = registerTarget({
338
+ path: "/hook-account-scope",
339
+ account,
340
+ core,
341
+ });
342
+
343
+ const payload = {
344
+ event_name: "message.text.received",
345
+ message: {
346
+ from: { id: "123", name: "Attacker" },
347
+ chat: { id: "dm-work", chat_type: "PRIVATE" },
348
+ message_id: "msg-work-1",
349
+ date: Math.floor(Date.now() / 1000),
350
+ text: "hello",
351
+ },
352
+ };
353
+
354
+ try {
355
+ await withServer(webhookRequestHandler, async (baseUrl) => {
356
+ const response = await fetch(`${baseUrl}/hook-account-scope`, {
357
+ method: "POST",
358
+ headers: {
359
+ "x-bot-api-secret-token": "secret",
360
+ "content-type": "application/json",
361
+ },
362
+ body: JSON.stringify(payload),
363
+ });
364
+
365
+ expect(response.status).toBe(200);
366
+ });
367
+ } finally {
368
+ unregister();
369
+ }
370
+
371
+ expect(readAllowFromStore).toHaveBeenCalledWith(
372
+ expect.objectContaining({
373
+ channel: "zalo",
374
+ accountId: "work",
375
+ }),
376
+ );
377
+ expect(upsertPairingRequest).toHaveBeenCalledWith(
378
+ expect.objectContaining({
379
+ channel: "zalo",
380
+ id: "123",
381
+ accountId: "work",
382
+ }),
383
+ );
384
+ });
262
385
  });
@@ -1,18 +1,21 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
2
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
4
4
  import {
5
5
  createDedupeCache,
6
6
  createFixedWindowRateLimiter,
7
7
  createWebhookAnomalyTracker,
8
8
  readJsonWebhookBodyOrReject,
9
9
  applyBasicWebhookRequestGuards,
10
+ registerWebhookTargetWithPluginRoute,
11
+ type RegisterWebhookTargetOptions,
12
+ type RegisterWebhookPluginRouteOptions,
10
13
  registerWebhookTarget,
11
- resolveSingleWebhookTarget,
12
- resolveWebhookTargets,
14
+ resolveWebhookTargetWithAuthOrRejectSync,
15
+ withResolvedWebhookRequestPipeline,
13
16
  WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
14
17
  WEBHOOK_RATE_LIMIT_DEFAULTS,
15
- } from "openclaw/plugin-sdk";
18
+ } from "openclaw/plugin-sdk/zalo";
16
19
  import type { ResolvedZaloAccount } from "./accounts.js";
17
20
  import type { ZaloFetch, ZaloUpdate } from "./api.js";
18
21
  import type { ZaloRuntimeEnv } from "./monitor.js";
@@ -106,8 +109,24 @@ function recordWebhookStatus(
106
109
  });
107
110
  }
108
111
 
109
- export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
110
- return registerWebhookTarget(webhookTargets, target).unregister;
112
+ export function registerZaloWebhookTarget(
113
+ target: ZaloWebhookTarget,
114
+ opts?: {
115
+ route?: RegisterWebhookPluginRouteOptions;
116
+ } & Pick<
117
+ RegisterWebhookTargetOptions<ZaloWebhookTarget>,
118
+ "onFirstPathTarget" | "onLastPathTargetRemoved"
119
+ >,
120
+ ): () => void {
121
+ if (opts?.route) {
122
+ return registerWebhookTargetWithPluginRoute({
123
+ targetsByPath: webhookTargets,
124
+ target,
125
+ route: opts.route,
126
+ onLastPathTargetRemoved: opts.onLastPathTargetRemoved,
127
+ }).unregister;
128
+ }
129
+ return registerWebhookTarget(webhookTargets, target, opts).unregister;
111
130
  }
112
131
 
113
132
  export async function handleZaloWebhookRequest(
@@ -115,95 +134,80 @@ export async function handleZaloWebhookRequest(
115
134
  res: ServerResponse,
116
135
  processUpdate: ZaloWebhookProcessUpdate,
117
136
  ): Promise<boolean> {
118
- const resolved = resolveWebhookTargets(req, webhookTargets);
119
- if (!resolved) {
120
- return false;
121
- }
122
- const { targets, path } = resolved;
123
-
124
- if (
125
- !applyBasicWebhookRequestGuards({
126
- req,
127
- res,
128
- allowMethods: ["POST"],
129
- })
130
- ) {
131
- return true;
132
- }
133
-
134
- const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
135
- const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
136
- timingSafeEquals(entry.secret, headerToken),
137
- );
138
- if (matchedTarget.kind === "none") {
139
- res.statusCode = 401;
140
- res.end("unauthorized");
141
- recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
142
- return true;
143
- }
144
- if (matchedTarget.kind === "ambiguous") {
145
- res.statusCode = 401;
146
- res.end("ambiguous webhook target");
147
- recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
148
- return true;
149
- }
150
- const target = matchedTarget.target;
151
- const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
152
- const nowMs = Date.now();
153
-
154
- if (
155
- !applyBasicWebhookRequestGuards({
156
- req,
157
- res,
158
- rateLimiter: webhookRateLimiter,
159
- rateLimitKey,
160
- nowMs,
161
- requireJsonContentType: true,
162
- })
163
- ) {
164
- recordWebhookStatus(target.runtime, path, res.statusCode);
165
- return true;
166
- }
167
- const body = await readJsonWebhookBodyOrReject({
137
+ return await withResolvedWebhookRequestPipeline({
168
138
  req,
169
139
  res,
170
- maxBytes: 1024 * 1024,
171
- timeoutMs: 30_000,
172
- emptyObjectOnEmpty: false,
173
- invalidJsonMessage: "Bad Request",
140
+ targetsByPath: webhookTargets,
141
+ allowMethods: ["POST"],
142
+ handle: async ({ targets, path }) => {
143
+ const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
144
+ const target = resolveWebhookTargetWithAuthOrRejectSync({
145
+ targets,
146
+ res,
147
+ isMatch: (entry) => timingSafeEquals(entry.secret, headerToken),
148
+ });
149
+ if (!target) {
150
+ recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
151
+ return true;
152
+ }
153
+ const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
154
+ const nowMs = Date.now();
155
+
156
+ if (
157
+ !applyBasicWebhookRequestGuards({
158
+ req,
159
+ res,
160
+ rateLimiter: webhookRateLimiter,
161
+ rateLimitKey,
162
+ nowMs,
163
+ requireJsonContentType: true,
164
+ })
165
+ ) {
166
+ recordWebhookStatus(target.runtime, path, res.statusCode);
167
+ return true;
168
+ }
169
+ const body = await readJsonWebhookBodyOrReject({
170
+ req,
171
+ res,
172
+ maxBytes: 1024 * 1024,
173
+ timeoutMs: 30_000,
174
+ emptyObjectOnEmpty: false,
175
+ invalidJsonMessage: "Bad Request",
176
+ });
177
+ if (!body.ok) {
178
+ recordWebhookStatus(target.runtime, path, res.statusCode);
179
+ return true;
180
+ }
181
+ const raw = body.value;
182
+
183
+ // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
184
+ const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
185
+ const update: ZaloUpdate | undefined =
186
+ record && record.ok === true && record.result
187
+ ? (record.result as ZaloUpdate)
188
+ : ((record as ZaloUpdate | null) ?? undefined);
189
+
190
+ if (!update?.event_name) {
191
+ res.statusCode = 400;
192
+ res.end("Bad Request");
193
+ recordWebhookStatus(target.runtime, path, res.statusCode);
194
+ return true;
195
+ }
196
+
197
+ if (isReplayEvent(update, nowMs)) {
198
+ res.statusCode = 200;
199
+ res.end("ok");
200
+ return true;
201
+ }
202
+
203
+ target.statusSink?.({ lastInboundAt: Date.now() });
204
+ processUpdate({ update, target }).catch((err) => {
205
+ target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
206
+ });
207
+
208
+ res.statusCode = 200;
209
+ res.end("ok");
210
+ return true;
211
+ },
174
212
  });
175
- if (!body.ok) {
176
- recordWebhookStatus(target.runtime, path, res.statusCode);
177
- return true;
178
- }
179
- const raw = body.value;
180
-
181
- // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
182
- const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
183
- const update: ZaloUpdate | undefined =
184
- record && record.ok === true && record.result
185
- ? (record.result as ZaloUpdate)
186
- : ((record as ZaloUpdate | null) ?? undefined);
187
-
188
- if (!update?.event_name) {
189
- res.statusCode = 400;
190
- res.end("Bad Request");
191
- recordWebhookStatus(target.runtime, path, res.statusCode);
192
- return true;
193
- }
194
-
195
- if (isReplayEvent(update, nowMs)) {
196
- res.statusCode = 200;
197
- res.end("ok");
198
- return true;
199
- }
200
-
201
- target.statusSink?.({ lastInboundAt: Date.now() });
202
- processUpdate({ update, target }).catch((err) => {
203
- target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
204
- });
205
-
206
- res.statusCode = 200;
207
- res.end("ok");
208
- return true;
209
213
  }