@openclaw/zalo 2026.1.29

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 ADDED
@@ -0,0 +1,760 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
4
+
5
+ import type { ResolvedZaloAccount } from "./accounts.js";
6
+ import {
7
+ ZaloApiError,
8
+ deleteWebhook,
9
+ getUpdates,
10
+ sendMessage,
11
+ sendPhoto,
12
+ setWebhook,
13
+ type ZaloFetch,
14
+ type ZaloMessage,
15
+ type ZaloUpdate,
16
+ } from "./api.js";
17
+ import { resolveZaloProxyFetch } from "./proxy.js";
18
+ import { getZaloRuntime } from "./runtime.js";
19
+
20
+ export type ZaloRuntimeEnv = {
21
+ log?: (message: string) => void;
22
+ error?: (message: string) => void;
23
+ };
24
+
25
+ export type ZaloMonitorOptions = {
26
+ token: string;
27
+ account: ResolvedZaloAccount;
28
+ config: OpenClawConfig;
29
+ runtime: ZaloRuntimeEnv;
30
+ abortSignal: AbortSignal;
31
+ useWebhook?: boolean;
32
+ webhookUrl?: string;
33
+ webhookSecret?: string;
34
+ webhookPath?: string;
35
+ fetcher?: ZaloFetch;
36
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
37
+ };
38
+
39
+ export type ZaloMonitorResult = {
40
+ stop: () => void;
41
+ };
42
+
43
+ const ZALO_TEXT_LIMIT = 2000;
44
+ const DEFAULT_MEDIA_MAX_MB = 5;
45
+
46
+ type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
47
+
48
+ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
49
+ if (core.logging.shouldLogVerbose()) {
50
+ runtime.log?.(`[zalo] ${message}`);
51
+ }
52
+ }
53
+
54
+ function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
55
+ if (allowFrom.includes("*")) return true;
56
+ const normalizedSenderId = senderId.toLowerCase();
57
+ return allowFrom.some((entry) => {
58
+ const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, "");
59
+ return normalized === normalizedSenderId;
60
+ });
61
+ }
62
+
63
+ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
64
+ const chunks: Buffer[] = [];
65
+ let total = 0;
66
+ return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
67
+ req.on("data", (chunk: Buffer) => {
68
+ total += chunk.length;
69
+ if (total > maxBytes) {
70
+ resolve({ ok: false, error: "payload too large" });
71
+ req.destroy();
72
+ return;
73
+ }
74
+ chunks.push(chunk);
75
+ });
76
+ req.on("end", () => {
77
+ try {
78
+ const raw = Buffer.concat(chunks).toString("utf8");
79
+ if (!raw.trim()) {
80
+ resolve({ ok: false, error: "empty payload" });
81
+ return;
82
+ }
83
+ resolve({ ok: true, value: JSON.parse(raw) as unknown });
84
+ } catch (err) {
85
+ resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
86
+ }
87
+ });
88
+ req.on("error", (err) => {
89
+ resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
90
+ });
91
+ });
92
+ }
93
+
94
+ type WebhookTarget = {
95
+ token: string;
96
+ account: ResolvedZaloAccount;
97
+ config: OpenClawConfig;
98
+ runtime: ZaloRuntimeEnv;
99
+ core: ZaloCoreRuntime;
100
+ secret: string;
101
+ path: string;
102
+ mediaMaxMb: number;
103
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
104
+ fetcher?: ZaloFetch;
105
+ };
106
+
107
+ const webhookTargets = new Map<string, WebhookTarget[]>();
108
+
109
+ function normalizeWebhookPath(raw: string): string {
110
+ const trimmed = raw.trim();
111
+ if (!trimmed) return "/";
112
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
113
+ if (withSlash.length > 1 && withSlash.endsWith("/")) {
114
+ return withSlash.slice(0, -1);
115
+ }
116
+ return withSlash;
117
+ }
118
+
119
+ function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
120
+ const trimmedPath = webhookPath?.trim();
121
+ if (trimmedPath) return normalizeWebhookPath(trimmedPath);
122
+ if (webhookUrl?.trim()) {
123
+ try {
124
+ const parsed = new URL(webhookUrl);
125
+ return normalizeWebhookPath(parsed.pathname || "/");
126
+ } catch {
127
+ return null;
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+
133
+ export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
134
+ const key = normalizeWebhookPath(target.path);
135
+ const normalizedTarget = { ...target, path: key };
136
+ const existing = webhookTargets.get(key) ?? [];
137
+ const next = [...existing, normalizedTarget];
138
+ webhookTargets.set(key, next);
139
+ return () => {
140
+ const updated = (webhookTargets.get(key) ?? []).filter(
141
+ (entry) => entry !== normalizedTarget,
142
+ );
143
+ if (updated.length > 0) {
144
+ webhookTargets.set(key, updated);
145
+ } else {
146
+ webhookTargets.delete(key);
147
+ }
148
+ };
149
+ }
150
+
151
+ export async function handleZaloWebhookRequest(
152
+ req: IncomingMessage,
153
+ res: ServerResponse,
154
+ ): Promise<boolean> {
155
+ const url = new URL(req.url ?? "/", "http://localhost");
156
+ const path = normalizeWebhookPath(url.pathname);
157
+ const targets = webhookTargets.get(path);
158
+ if (!targets || targets.length === 0) return false;
159
+
160
+ if (req.method !== "POST") {
161
+ res.statusCode = 405;
162
+ res.setHeader("Allow", "POST");
163
+ res.end("Method Not Allowed");
164
+ return true;
165
+ }
166
+
167
+ const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
168
+ const target = targets.find((entry) => entry.secret === headerToken);
169
+ if (!target) {
170
+ res.statusCode = 401;
171
+ res.end("unauthorized");
172
+ return true;
173
+ }
174
+
175
+ const body = await readJsonBody(req, 1024 * 1024);
176
+ if (!body.ok) {
177
+ res.statusCode = body.error === "payload too large" ? 413 : 400;
178
+ res.end(body.error ?? "invalid payload");
179
+ return true;
180
+ }
181
+
182
+ // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
183
+ const raw = body.value;
184
+ const record =
185
+ raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
186
+ const update: ZaloUpdate | undefined =
187
+ record && record.ok === true && record.result
188
+ ? (record.result as ZaloUpdate)
189
+ : (record as ZaloUpdate | null) ?? undefined;
190
+
191
+ if (!update?.event_name) {
192
+ res.statusCode = 400;
193
+ res.end("invalid payload");
194
+ return true;
195
+ }
196
+
197
+ target.statusSink?.({ lastInboundAt: Date.now() });
198
+ processUpdate(
199
+ update,
200
+ target.token,
201
+ target.account,
202
+ target.config,
203
+ target.runtime,
204
+ target.core,
205
+ target.mediaMaxMb,
206
+ target.statusSink,
207
+ target.fetcher,
208
+ ).catch((err) => {
209
+ target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
210
+ });
211
+
212
+ res.statusCode = 200;
213
+ res.end("ok");
214
+ return true;
215
+ }
216
+
217
+ function startPollingLoop(params: {
218
+ token: string;
219
+ account: ResolvedZaloAccount;
220
+ config: OpenClawConfig;
221
+ runtime: ZaloRuntimeEnv;
222
+ core: ZaloCoreRuntime;
223
+ abortSignal: AbortSignal;
224
+ isStopped: () => boolean;
225
+ mediaMaxMb: number;
226
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
227
+ fetcher?: ZaloFetch;
228
+ }) {
229
+ const {
230
+ token,
231
+ account,
232
+ config,
233
+ runtime,
234
+ core,
235
+ abortSignal,
236
+ isStopped,
237
+ mediaMaxMb,
238
+ statusSink,
239
+ fetcher,
240
+ } = params;
241
+ const pollTimeout = 30;
242
+
243
+ const poll = async () => {
244
+ if (isStopped() || abortSignal.aborted) return;
245
+
246
+ try {
247
+ const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
248
+ if (response.ok && response.result) {
249
+ statusSink?.({ lastInboundAt: Date.now() });
250
+ await processUpdate(
251
+ response.result,
252
+ token,
253
+ account,
254
+ config,
255
+ runtime,
256
+ core,
257
+ mediaMaxMb,
258
+ statusSink,
259
+ fetcher,
260
+ );
261
+ }
262
+ } catch (err) {
263
+ if (err instanceof ZaloApiError && err.isPollingTimeout) {
264
+ // no updates
265
+ } else if (!isStopped() && !abortSignal.aborted) {
266
+ console.error(`[${account.accountId}] Zalo polling error:`, err);
267
+ await new Promise((resolve) => setTimeout(resolve, 5000));
268
+ }
269
+ }
270
+
271
+ if (!isStopped() && !abortSignal.aborted) {
272
+ setImmediate(poll);
273
+ }
274
+ };
275
+
276
+ void poll();
277
+ }
278
+
279
+ async function processUpdate(
280
+ update: ZaloUpdate,
281
+ token: string,
282
+ account: ResolvedZaloAccount,
283
+ config: OpenClawConfig,
284
+ runtime: ZaloRuntimeEnv,
285
+ core: ZaloCoreRuntime,
286
+ mediaMaxMb: number,
287
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
288
+ fetcher?: ZaloFetch,
289
+ ): Promise<void> {
290
+ const { event_name, message } = update;
291
+ if (!message) return;
292
+
293
+ switch (event_name) {
294
+ case "message.text.received":
295
+ await handleTextMessage(
296
+ message,
297
+ token,
298
+ account,
299
+ config,
300
+ runtime,
301
+ core,
302
+ statusSink,
303
+ fetcher,
304
+ );
305
+ break;
306
+ case "message.image.received":
307
+ await handleImageMessage(
308
+ message,
309
+ token,
310
+ account,
311
+ config,
312
+ runtime,
313
+ core,
314
+ mediaMaxMb,
315
+ statusSink,
316
+ fetcher,
317
+ );
318
+ break;
319
+ case "message.sticker.received":
320
+ console.log(`[${account.accountId}] Received sticker from ${message.from.id}`);
321
+ break;
322
+ case "message.unsupported.received":
323
+ console.log(
324
+ `[${account.accountId}] Received unsupported message type from ${message.from.id}`,
325
+ );
326
+ break;
327
+ }
328
+ }
329
+
330
+ async function handleTextMessage(
331
+ message: ZaloMessage,
332
+ token: string,
333
+ account: ResolvedZaloAccount,
334
+ config: OpenClawConfig,
335
+ runtime: ZaloRuntimeEnv,
336
+ core: ZaloCoreRuntime,
337
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
338
+ fetcher?: ZaloFetch,
339
+ ): Promise<void> {
340
+ const { text } = message;
341
+ if (!text?.trim()) return;
342
+
343
+ await processMessageWithPipeline({
344
+ message,
345
+ token,
346
+ account,
347
+ config,
348
+ runtime,
349
+ core,
350
+ text,
351
+ mediaPath: undefined,
352
+ mediaType: undefined,
353
+ statusSink,
354
+ fetcher,
355
+ });
356
+ }
357
+
358
+ async function handleImageMessage(
359
+ message: ZaloMessage,
360
+ token: string,
361
+ account: ResolvedZaloAccount,
362
+ config: OpenClawConfig,
363
+ runtime: ZaloRuntimeEnv,
364
+ core: ZaloCoreRuntime,
365
+ mediaMaxMb: number,
366
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
367
+ fetcher?: ZaloFetch,
368
+ ): Promise<void> {
369
+ const { photo, caption } = message;
370
+
371
+ let mediaPath: string | undefined;
372
+ let mediaType: string | undefined;
373
+
374
+ if (photo) {
375
+ try {
376
+ const maxBytes = mediaMaxMb * 1024 * 1024;
377
+ const fetched = await core.channel.media.fetchRemoteMedia({ url: photo });
378
+ const saved = await core.channel.media.saveMediaBuffer(
379
+ fetched.buffer,
380
+ fetched.contentType,
381
+ "inbound",
382
+ maxBytes,
383
+ );
384
+ mediaPath = saved.path;
385
+ mediaType = saved.contentType;
386
+ } catch (err) {
387
+ console.error(`[${account.accountId}] Failed to download Zalo image:`, err);
388
+ }
389
+ }
390
+
391
+ await processMessageWithPipeline({
392
+ message,
393
+ token,
394
+ account,
395
+ config,
396
+ runtime,
397
+ core,
398
+ text: caption,
399
+ mediaPath,
400
+ mediaType,
401
+ statusSink,
402
+ fetcher,
403
+ });
404
+ }
405
+
406
+ async function processMessageWithPipeline(params: {
407
+ message: ZaloMessage;
408
+ token: string;
409
+ account: ResolvedZaloAccount;
410
+ config: OpenClawConfig;
411
+ runtime: ZaloRuntimeEnv;
412
+ core: ZaloCoreRuntime;
413
+ text?: string;
414
+ mediaPath?: string;
415
+ mediaType?: string;
416
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
417
+ fetcher?: ZaloFetch;
418
+ }): Promise<void> {
419
+ const {
420
+ message,
421
+ token,
422
+ account,
423
+ config,
424
+ runtime,
425
+ core,
426
+ text,
427
+ mediaPath,
428
+ mediaType,
429
+ statusSink,
430
+ fetcher,
431
+ } = params;
432
+ const { from, chat, message_id, date } = message;
433
+
434
+ const isGroup = chat.chat_type === "GROUP";
435
+ const chatId = chat.id;
436
+ const senderId = from.id;
437
+ const senderName = from.name;
438
+
439
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
440
+ const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
441
+ const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
442
+ const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
443
+ rawBody,
444
+ config,
445
+ );
446
+ const storeAllowFrom =
447
+ !isGroup && (dmPolicy !== "open" || shouldComputeAuth)
448
+ ? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
449
+ : [];
450
+ const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
451
+ const useAccessGroups = config.commands?.useAccessGroups !== false;
452
+ const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
453
+ const commandAuthorized = shouldComputeAuth
454
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
455
+ useAccessGroups,
456
+ authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
457
+ })
458
+ : undefined;
459
+
460
+ if (!isGroup) {
461
+ if (dmPolicy === "disabled") {
462
+ logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
463
+ return;
464
+ }
465
+
466
+ if (dmPolicy !== "open") {
467
+ const allowed = senderAllowedForCommands;
468
+
469
+ if (!allowed) {
470
+ if (dmPolicy === "pairing") {
471
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
472
+ channel: "zalo",
473
+ id: senderId,
474
+ meta: { name: senderName ?? undefined },
475
+ });
476
+
477
+ if (created) {
478
+ logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
479
+ try {
480
+ await sendMessage(
481
+ token,
482
+ {
483
+ chat_id: chatId,
484
+ text: core.channel.pairing.buildPairingReply({
485
+ channel: "zalo",
486
+ idLine: `Your Zalo user id: ${senderId}`,
487
+ code,
488
+ }),
489
+ },
490
+ fetcher,
491
+ );
492
+ statusSink?.({ lastOutboundAt: Date.now() });
493
+ } catch (err) {
494
+ logVerbose(
495
+ core,
496
+ runtime,
497
+ `zalo pairing reply failed for ${senderId}: ${String(err)}`,
498
+ );
499
+ }
500
+ }
501
+ } else {
502
+ logVerbose(
503
+ core,
504
+ runtime,
505
+ `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
506
+ );
507
+ }
508
+ return;
509
+ }
510
+ }
511
+ }
512
+
513
+ const route = core.channel.routing.resolveAgentRoute({
514
+ cfg: config,
515
+ channel: "zalo",
516
+ accountId: account.accountId,
517
+ peer: {
518
+ kind: isGroup ? "group" : "dm",
519
+ id: chatId,
520
+ },
521
+ });
522
+
523
+ if (
524
+ isGroup &&
525
+ core.channel.commands.isControlCommandMessage(rawBody, config) &&
526
+ commandAuthorized !== true
527
+ ) {
528
+ logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
529
+ return;
530
+ }
531
+
532
+ const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
533
+ const storePath = core.channel.session.resolveStorePath(config.session?.store, {
534
+ agentId: route.agentId,
535
+ });
536
+ const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
537
+ const previousTimestamp = core.channel.session.readSessionUpdatedAt({
538
+ storePath,
539
+ sessionKey: route.sessionKey,
540
+ });
541
+ const body = core.channel.reply.formatAgentEnvelope({
542
+ channel: "Zalo",
543
+ from: fromLabel,
544
+ timestamp: date ? date * 1000 : undefined,
545
+ previousTimestamp,
546
+ envelope: envelopeOptions,
547
+ body: rawBody,
548
+ });
549
+
550
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
551
+ Body: body,
552
+ RawBody: rawBody,
553
+ CommandBody: rawBody,
554
+ From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
555
+ To: `zalo:${chatId}`,
556
+ SessionKey: route.sessionKey,
557
+ AccountId: route.accountId,
558
+ ChatType: isGroup ? "group" : "direct",
559
+ ConversationLabel: fromLabel,
560
+ SenderName: senderName || undefined,
561
+ SenderId: senderId,
562
+ CommandAuthorized: commandAuthorized,
563
+ Provider: "zalo",
564
+ Surface: "zalo",
565
+ MessageSid: message_id,
566
+ MediaPath: mediaPath,
567
+ MediaType: mediaType,
568
+ MediaUrl: mediaPath,
569
+ OriginatingChannel: "zalo",
570
+ OriginatingTo: `zalo:${chatId}`,
571
+ });
572
+
573
+ await core.channel.session.recordInboundSession({
574
+ storePath,
575
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
576
+ ctx: ctxPayload,
577
+ onRecordError: (err) => {
578
+ runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
579
+ },
580
+ });
581
+
582
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
583
+ cfg: config,
584
+ channel: "zalo",
585
+ accountId: account.accountId,
586
+ });
587
+
588
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
589
+ ctx: ctxPayload,
590
+ cfg: config,
591
+ dispatcherOptions: {
592
+ deliver: async (payload) => {
593
+ await deliverZaloReply({
594
+ payload,
595
+ token,
596
+ chatId,
597
+ runtime,
598
+ core,
599
+ config,
600
+ accountId: account.accountId,
601
+ statusSink,
602
+ fetcher,
603
+ tableMode,
604
+ });
605
+ },
606
+ onError: (err, info) => {
607
+ runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
608
+ },
609
+ },
610
+ });
611
+ }
612
+
613
+ async function deliverZaloReply(params: {
614
+ payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
615
+ token: string;
616
+ chatId: string;
617
+ runtime: ZaloRuntimeEnv;
618
+ core: ZaloCoreRuntime;
619
+ config: OpenClawConfig;
620
+ accountId?: string;
621
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
622
+ fetcher?: ZaloFetch;
623
+ tableMode?: MarkdownTableMode;
624
+ }): Promise<void> {
625
+ const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
626
+ const tableMode = params.tableMode ?? "code";
627
+ const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
628
+
629
+ const mediaList = payload.mediaUrls?.length
630
+ ? payload.mediaUrls
631
+ : payload.mediaUrl
632
+ ? [payload.mediaUrl]
633
+ : [];
634
+
635
+ if (mediaList.length > 0) {
636
+ let first = true;
637
+ for (const mediaUrl of mediaList) {
638
+ const caption = first ? text : undefined;
639
+ first = false;
640
+ try {
641
+ await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
642
+ statusSink?.({ lastOutboundAt: Date.now() });
643
+ } catch (err) {
644
+ runtime.error?.(`Zalo photo send failed: ${String(err)}`);
645
+ }
646
+ }
647
+ return;
648
+ }
649
+
650
+ if (text) {
651
+ const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
652
+ const chunks = core.channel.text.chunkMarkdownTextWithMode(
653
+ text,
654
+ ZALO_TEXT_LIMIT,
655
+ chunkMode,
656
+ );
657
+ for (const chunk of chunks) {
658
+ try {
659
+ await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
660
+ statusSink?.({ lastOutboundAt: Date.now() });
661
+ } catch (err) {
662
+ runtime.error?.(`Zalo message send failed: ${String(err)}`);
663
+ }
664
+ }
665
+ }
666
+ }
667
+
668
+ export async function monitorZaloProvider(
669
+ options: ZaloMonitorOptions,
670
+ ): Promise<ZaloMonitorResult> {
671
+ const {
672
+ token,
673
+ account,
674
+ config,
675
+ runtime,
676
+ abortSignal,
677
+ useWebhook,
678
+ webhookUrl,
679
+ webhookSecret,
680
+ webhookPath,
681
+ statusSink,
682
+ fetcher: fetcherOverride,
683
+ } = options;
684
+
685
+ const core = getZaloRuntime();
686
+ const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
687
+ const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
688
+
689
+ let stopped = false;
690
+ const stopHandlers: Array<() => void> = [];
691
+
692
+ const stop = () => {
693
+ stopped = true;
694
+ for (const handler of stopHandlers) {
695
+ handler();
696
+ }
697
+ };
698
+
699
+ if (useWebhook) {
700
+ if (!webhookUrl || !webhookSecret) {
701
+ throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
702
+ }
703
+ if (!webhookUrl.startsWith("https://")) {
704
+ throw new Error("Zalo webhook URL must use HTTPS");
705
+ }
706
+ if (webhookSecret.length < 8 || webhookSecret.length > 256) {
707
+ throw new Error("Zalo webhook secret must be 8-256 characters");
708
+ }
709
+
710
+ const path = resolveWebhookPath(webhookPath, webhookUrl);
711
+ if (!path) {
712
+ throw new Error("Zalo webhookPath could not be derived");
713
+ }
714
+
715
+ await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
716
+
717
+ const unregister = registerZaloWebhookTarget({
718
+ token,
719
+ account,
720
+ config,
721
+ runtime,
722
+ core,
723
+ path,
724
+ secret: webhookSecret,
725
+ statusSink: (patch) => statusSink?.(patch),
726
+ mediaMaxMb: effectiveMediaMaxMb,
727
+ fetcher,
728
+ });
729
+ stopHandlers.push(unregister);
730
+ abortSignal.addEventListener(
731
+ "abort",
732
+ () => {
733
+ void deleteWebhook(token, fetcher).catch(() => {});
734
+ },
735
+ { once: true },
736
+ );
737
+ return { stop };
738
+ }
739
+
740
+ try {
741
+ await deleteWebhook(token, fetcher);
742
+ } catch {
743
+ // ignore
744
+ }
745
+
746
+ startPollingLoop({
747
+ token,
748
+ account,
749
+ config,
750
+ runtime,
751
+ core,
752
+ abortSignal,
753
+ isStopped: () => stopped,
754
+ mediaMaxMb: effectiveMediaMaxMb,
755
+ statusSink,
756
+ fetcher,
757
+ });
758
+
759
+ return { stop };
760
+ }