@openclaw/zalo 2026.3.2 → 2026.3.8-beta.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 +24 -0
- package/index.ts +2 -2
- package/package.json +3 -2
- package/src/accounts.ts +5 -37
- package/src/actions.ts +2 -2
- package/src/api.test.ts +63 -0
- package/src/api.ts +36 -6
- package/src/channel.directory.test.ts +1 -1
- package/src/channel.sendpayload.test.ts +1 -1
- package/src/channel.startup.test.ts +100 -0
- package/src/channel.ts +107 -163
- package/src/config-schema.ts +8 -9
- package/src/group-access.ts +2 -2
- package/src/monitor.lifecycle.test.ts +213 -0
- package/src/monitor.ts +185 -71
- package/src/monitor.webhook.test.ts +40 -32
- package/src/monitor.webhook.ts +77 -92
- package/src/onboarding.status.test.ts +1 -1
- package/src/onboarding.ts +38 -39
- package/src/probe.ts +1 -1
- package/src/runtime.ts +5 -13
- package/src/secret-input.ts +8 -14
- package/src/send.ts +29 -24
- package/src/status-issues.ts +1 -1
- package/src/token.ts +24 -30
- package/src/types.ts +1 -1
package/src/monitor.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
MarkdownTableMode,
|
|
4
|
+
OpenClawConfig,
|
|
5
|
+
OutboundReplyPayload,
|
|
6
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
3
7
|
import {
|
|
8
|
+
createTypingCallbacks,
|
|
4
9
|
createScopedPairingAccess,
|
|
5
10
|
createReplyPrefixOptions,
|
|
11
|
+
issuePairingChallenge,
|
|
12
|
+
logTypingFailure,
|
|
6
13
|
resolveDirectDmAuthorizationOutcome,
|
|
7
14
|
resolveSenderCommandAuthorizationWithRuntime,
|
|
8
15
|
resolveOutboundMediaUrls,
|
|
@@ -10,13 +17,16 @@ import {
|
|
|
10
17
|
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
|
11
18
|
sendMediaWithLeadingCaption,
|
|
12
19
|
resolveWebhookPath,
|
|
20
|
+
waitForAbortSignal,
|
|
13
21
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
14
|
-
} from "openclaw/plugin-sdk";
|
|
22
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
15
23
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
16
24
|
import {
|
|
17
25
|
ZaloApiError,
|
|
18
26
|
deleteWebhook,
|
|
27
|
+
getWebhookInfo,
|
|
19
28
|
getUpdates,
|
|
29
|
+
sendChatAction,
|
|
20
30
|
sendMessage,
|
|
21
31
|
sendPhoto,
|
|
22
32
|
setWebhook,
|
|
@@ -59,15 +69,34 @@ export type ZaloMonitorOptions = {
|
|
|
59
69
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
60
70
|
};
|
|
61
71
|
|
|
62
|
-
export type ZaloMonitorResult = {
|
|
63
|
-
stop: () => void;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
72
|
const ZALO_TEXT_LIMIT = 2000;
|
|
67
73
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
74
|
+
const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
|
|
75
|
+
const ZALO_TYPING_TIMEOUT_MS = 5_000;
|
|
68
76
|
|
|
69
77
|
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
|
70
78
|
|
|
79
|
+
function formatZaloError(error: unknown): string {
|
|
80
|
+
if (error instanceof Error) {
|
|
81
|
+
return error.stack ?? `${error.name}: ${error.message}`;
|
|
82
|
+
}
|
|
83
|
+
return String(error);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function describeWebhookTarget(rawUrl: string): string {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(rawUrl);
|
|
89
|
+
return `${parsed.origin}${parsed.pathname}`;
|
|
90
|
+
} catch {
|
|
91
|
+
return rawUrl;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeWebhookUrl(url: string | undefined): string | undefined {
|
|
96
|
+
const trimmed = url?.trim();
|
|
97
|
+
return trimmed ? trimmed : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
71
100
|
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
|
72
101
|
if (core.logging.shouldLogVerbose()) {
|
|
73
102
|
runtime.log?.(`[zalo] ${message}`);
|
|
@@ -146,6 +175,8 @@ function startPollingLoop(params: {
|
|
|
146
175
|
} = params;
|
|
147
176
|
const pollTimeout = 30;
|
|
148
177
|
|
|
178
|
+
runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
|
|
179
|
+
|
|
149
180
|
const poll = async () => {
|
|
150
181
|
if (isStopped() || abortSignal.aborted) {
|
|
151
182
|
return;
|
|
@@ -171,7 +202,7 @@ function startPollingLoop(params: {
|
|
|
171
202
|
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
|
172
203
|
// no updates
|
|
173
204
|
} else if (!isStopped() && !abortSignal.aborted) {
|
|
174
|
-
runtime.error?.(`[${account.accountId}] Zalo polling error: ${
|
|
205
|
+
runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
|
|
175
206
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
176
207
|
}
|
|
177
208
|
}
|
|
@@ -410,31 +441,30 @@ async function processMessageWithPipeline(params: {
|
|
|
410
441
|
}
|
|
411
442
|
if (directDmOutcome === "unauthorized") {
|
|
412
443
|
if (dmPolicy === "pairing") {
|
|
413
|
-
|
|
414
|
-
|
|
444
|
+
await issuePairingChallenge({
|
|
445
|
+
channel: "zalo",
|
|
446
|
+
senderId,
|
|
447
|
+
senderIdLine: `Your Zalo user id: ${senderId}`,
|
|
415
448
|
meta: { name: senderName ?? undefined },
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
449
|
+
upsertPairingRequest: pairing.upsertPairingRequest,
|
|
450
|
+
onCreated: () => {
|
|
451
|
+
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
|
|
452
|
+
},
|
|
453
|
+
sendPairingReply: async (text) => {
|
|
421
454
|
await sendMessage(
|
|
422
455
|
token,
|
|
423
456
|
{
|
|
424
457
|
chat_id: chatId,
|
|
425
|
-
text
|
|
426
|
-
channel: "zalo",
|
|
427
|
-
idLine: `Your Zalo user id: ${senderId}`,
|
|
428
|
-
code,
|
|
429
|
-
}),
|
|
458
|
+
text,
|
|
430
459
|
},
|
|
431
460
|
fetcher,
|
|
432
461
|
);
|
|
433
462
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
434
|
-
}
|
|
463
|
+
},
|
|
464
|
+
onReplyError: (err) => {
|
|
435
465
|
logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
466
|
+
},
|
|
467
|
+
});
|
|
438
468
|
} else {
|
|
439
469
|
logVerbose(
|
|
440
470
|
core,
|
|
@@ -518,12 +548,35 @@ async function processMessageWithPipeline(params: {
|
|
|
518
548
|
channel: "zalo",
|
|
519
549
|
accountId: account.accountId,
|
|
520
550
|
});
|
|
551
|
+
const typingCallbacks = createTypingCallbacks({
|
|
552
|
+
start: async () => {
|
|
553
|
+
await sendChatAction(
|
|
554
|
+
token,
|
|
555
|
+
{
|
|
556
|
+
chat_id: chatId,
|
|
557
|
+
action: "typing",
|
|
558
|
+
},
|
|
559
|
+
fetcher,
|
|
560
|
+
ZALO_TYPING_TIMEOUT_MS,
|
|
561
|
+
);
|
|
562
|
+
},
|
|
563
|
+
onStartError: (err) => {
|
|
564
|
+
logTypingFailure({
|
|
565
|
+
log: (message) => logVerbose(core, runtime, message),
|
|
566
|
+
channel: "zalo",
|
|
567
|
+
action: "start",
|
|
568
|
+
target: chatId,
|
|
569
|
+
error: err,
|
|
570
|
+
});
|
|
571
|
+
},
|
|
572
|
+
});
|
|
521
573
|
|
|
522
574
|
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
523
575
|
ctx: ctxPayload,
|
|
524
576
|
cfg: config,
|
|
525
577
|
dispatcherOptions: {
|
|
526
578
|
...prefixOptions,
|
|
579
|
+
typingCallbacks,
|
|
527
580
|
deliver: async (payload) => {
|
|
528
581
|
await deliverZaloReply({
|
|
529
582
|
payload,
|
|
@@ -563,7 +616,6 @@ async function deliverZaloReply(params: {
|
|
|
563
616
|
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
|
564
617
|
const tableMode = params.tableMode ?? "code";
|
|
565
618
|
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
|
566
|
-
|
|
567
619
|
const sentMedia = await sendMediaWithLeadingCaption({
|
|
568
620
|
mediaUrls: resolveOutboundMediaUrls(payload),
|
|
569
621
|
caption: text,
|
|
@@ -593,7 +645,7 @@ async function deliverZaloReply(params: {
|
|
|
593
645
|
}
|
|
594
646
|
}
|
|
595
647
|
|
|
596
|
-
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
|
648
|
+
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
|
|
597
649
|
const {
|
|
598
650
|
token,
|
|
599
651
|
account,
|
|
@@ -611,78 +663,140 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
|
|
611
663
|
const core = getZaloRuntime();
|
|
612
664
|
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
|
613
665
|
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
|
|
666
|
+
const mode = useWebhook ? "webhook" : "polling";
|
|
614
667
|
|
|
615
668
|
let stopped = false;
|
|
616
669
|
const stopHandlers: Array<() => void> = [];
|
|
670
|
+
let cleanupWebhook: (() => Promise<void>) | undefined;
|
|
617
671
|
|
|
618
672
|
const stop = () => {
|
|
673
|
+
if (stopped) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
619
676
|
stopped = true;
|
|
620
677
|
for (const handler of stopHandlers) {
|
|
621
678
|
handler();
|
|
622
679
|
}
|
|
623
680
|
};
|
|
624
681
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
682
|
+
runtime.log?.(
|
|
683
|
+
`[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
if (useWebhook) {
|
|
688
|
+
if (!webhookUrl || !webhookSecret) {
|
|
689
|
+
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
|
|
690
|
+
}
|
|
691
|
+
if (!webhookUrl.startsWith("https://")) {
|
|
692
|
+
throw new Error("Zalo webhook URL must use HTTPS");
|
|
693
|
+
}
|
|
694
|
+
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
|
|
695
|
+
throw new Error("Zalo webhook secret must be 8-256 characters");
|
|
696
|
+
}
|
|
635
697
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
698
|
+
const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
|
|
699
|
+
if (!path) {
|
|
700
|
+
throw new Error("Zalo webhookPath could not be derived");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
runtime.log?.(
|
|
704
|
+
`[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
|
|
705
|
+
);
|
|
706
|
+
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
|
|
707
|
+
let webhookCleanupPromise: Promise<void> | undefined;
|
|
708
|
+
cleanupWebhook = async () => {
|
|
709
|
+
if (!webhookCleanupPromise) {
|
|
710
|
+
webhookCleanupPromise = (async () => {
|
|
711
|
+
runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
|
|
712
|
+
try {
|
|
713
|
+
await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
|
|
714
|
+
runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
|
|
715
|
+
} catch (err) {
|
|
716
|
+
const detail =
|
|
717
|
+
err instanceof Error && err.name === "AbortError"
|
|
718
|
+
? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms`
|
|
719
|
+
: formatZaloError(err);
|
|
720
|
+
runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
|
|
721
|
+
}
|
|
722
|
+
})();
|
|
723
|
+
}
|
|
724
|
+
await webhookCleanupPromise;
|
|
725
|
+
};
|
|
726
|
+
runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
|
|
727
|
+
|
|
728
|
+
const unregister = registerZaloWebhookTarget({
|
|
729
|
+
token,
|
|
730
|
+
account,
|
|
731
|
+
config,
|
|
732
|
+
runtime,
|
|
733
|
+
core,
|
|
734
|
+
path,
|
|
735
|
+
secret: webhookSecret,
|
|
736
|
+
statusSink: (patch) => statusSink?.(patch),
|
|
737
|
+
mediaMaxMb: effectiveMediaMaxMb,
|
|
738
|
+
fetcher,
|
|
739
|
+
});
|
|
740
|
+
stopHandlers.push(unregister);
|
|
741
|
+
await waitForAbortSignal(abortSignal);
|
|
742
|
+
return;
|
|
639
743
|
}
|
|
640
744
|
|
|
641
|
-
|
|
745
|
+
runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
|
|
746
|
+
try {
|
|
747
|
+
try {
|
|
748
|
+
const currentWebhookUrl = normalizeWebhookUrl(
|
|
749
|
+
(await getWebhookInfo(token, fetcher)).result?.url,
|
|
750
|
+
);
|
|
751
|
+
if (!currentWebhookUrl) {
|
|
752
|
+
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
|
|
753
|
+
} else {
|
|
754
|
+
runtime.log?.(
|
|
755
|
+
`[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
|
|
756
|
+
);
|
|
757
|
+
await deleteWebhook(token, fetcher);
|
|
758
|
+
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
|
|
759
|
+
}
|
|
760
|
+
} catch (err) {
|
|
761
|
+
if (err instanceof ZaloApiError && err.errorCode === 404) {
|
|
762
|
+
// Some Zalo environments do not expose webhook inspection for polling bots.
|
|
763
|
+
runtime.log?.(
|
|
764
|
+
`[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
|
|
765
|
+
);
|
|
766
|
+
} else {
|
|
767
|
+
throw err;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
} catch (err) {
|
|
771
|
+
runtime.error?.(
|
|
772
|
+
`[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
642
775
|
|
|
643
|
-
|
|
776
|
+
startPollingLoop({
|
|
644
777
|
token,
|
|
645
778
|
account,
|
|
646
779
|
config,
|
|
647
780
|
runtime,
|
|
648
781
|
core,
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
statusSink: (patch) => statusSink?.(patch),
|
|
782
|
+
abortSignal,
|
|
783
|
+
isStopped: () => stopped,
|
|
652
784
|
mediaMaxMb: effectiveMediaMaxMb,
|
|
785
|
+
statusSink,
|
|
653
786
|
fetcher,
|
|
654
787
|
});
|
|
655
|
-
stopHandlers.push(unregister);
|
|
656
|
-
abortSignal.addEventListener(
|
|
657
|
-
"abort",
|
|
658
|
-
() => {
|
|
659
|
-
void deleteWebhook(token, fetcher).catch(() => {});
|
|
660
|
-
},
|
|
661
|
-
{ once: true },
|
|
662
|
-
);
|
|
663
|
-
return { stop };
|
|
664
|
-
}
|
|
665
788
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
789
|
+
await waitForAbortSignal(abortSignal);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
runtime.error?.(
|
|
792
|
+
`[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`,
|
|
793
|
+
);
|
|
794
|
+
throw err;
|
|
795
|
+
} finally {
|
|
796
|
+
await cleanupWebhook?.();
|
|
797
|
+
stop();
|
|
798
|
+
runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
|
|
670
799
|
}
|
|
671
|
-
|
|
672
|
-
startPollingLoop({
|
|
673
|
-
token,
|
|
674
|
-
account,
|
|
675
|
-
config,
|
|
676
|
-
runtime,
|
|
677
|
-
core,
|
|
678
|
-
abortSignal,
|
|
679
|
-
isStopped: () => stopped,
|
|
680
|
-
mediaMaxMb: effectiveMediaMaxMb,
|
|
681
|
-
statusSink,
|
|
682
|
-
fetcher,
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
return { stop };
|
|
686
800
|
}
|
|
687
801
|
|
|
688
802
|
export const __testing = {
|
|
@@ -1,6 +1,6 @@
|
|
|
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
5
|
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
|
|
6
6
|
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
|
|
@@ -94,6 +94,33 @@ function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCrea
|
|
|
94
94
|
return { core, readAllowFromStore, upsertPairingRequest };
|
|
95
95
|
}
|
|
96
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
|
+
|
|
97
124
|
describe("handleZaloWebhookRequest", () => {
|
|
98
125
|
afterEach(() => {
|
|
99
126
|
clearZaloWebhookSecurityStateForTest();
|
|
@@ -239,21 +266,11 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
239
266
|
|
|
240
267
|
try {
|
|
241
268
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
"x-bot-api-secret-token": "secret",
|
|
248
|
-
"content-type": "application/json",
|
|
249
|
-
},
|
|
250
|
-
body: "{}",
|
|
251
|
-
});
|
|
252
|
-
if (response.status === 429) {
|
|
253
|
-
saw429 = true;
|
|
254
|
-
break;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
269
|
+
const saw429 = await postUntilRateLimited({
|
|
270
|
+
baseUrl,
|
|
271
|
+
path: "/hook-rate",
|
|
272
|
+
secret: "secret", // pragma: allowlist secret
|
|
273
|
+
});
|
|
257
274
|
|
|
258
275
|
expect(saw429).toBe(true);
|
|
259
276
|
});
|
|
@@ -270,7 +287,7 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
270
287
|
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
|
|
271
288
|
method: "POST",
|
|
272
289
|
headers: {
|
|
273
|
-
"x-bot-api-secret-token": "invalid-token",
|
|
290
|
+
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
|
274
291
|
"content-type": "application/json",
|
|
275
292
|
},
|
|
276
293
|
body: "{}",
|
|
@@ -290,21 +307,12 @@ describe("handleZaloWebhookRequest", () => {
|
|
|
290
307
|
|
|
291
308
|
try {
|
|
292
309
|
await withServer(webhookRequestHandler, async (baseUrl) => {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
"content-type": "application/json",
|
|
300
|
-
},
|
|
301
|
-
body: "{}",
|
|
302
|
-
});
|
|
303
|
-
if (response.status === 429) {
|
|
304
|
-
saw429 = true;
|
|
305
|
-
break;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
310
|
+
const saw429 = await postUntilRateLimited({
|
|
311
|
+
baseUrl,
|
|
312
|
+
path: "/hook-query-rate",
|
|
313
|
+
secret: "secret", // pragma: allowlist secret
|
|
314
|
+
withNonceQuery: true,
|
|
315
|
+
});
|
|
308
316
|
|
|
309
317
|
expect(saw429).toBe(true);
|
|
310
318
|
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
package/src/monitor.webhook.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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,
|
|
@@ -11,11 +11,11 @@ import {
|
|
|
11
11
|
type RegisterWebhookTargetOptions,
|
|
12
12
|
type RegisterWebhookPluginRouteOptions,
|
|
13
13
|
registerWebhookTarget,
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
resolveWebhookTargetWithAuthOrRejectSync,
|
|
15
|
+
withResolvedWebhookRequestPipeline,
|
|
16
16
|
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
|
17
17
|
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
18
|
-
} from "openclaw/plugin-sdk";
|
|
18
|
+
} from "openclaw/plugin-sdk/zalo";
|
|
19
19
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
20
20
|
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
|
21
21
|
import type { ZaloRuntimeEnv } from "./monitor.js";
|
|
@@ -134,95 +134,80 @@ export async function handleZaloWebhookRequest(
|
|
|
134
134
|
res: ServerResponse,
|
|
135
135
|
processUpdate: ZaloWebhookProcessUpdate,
|
|
136
136
|
): Promise<boolean> {
|
|
137
|
-
|
|
138
|
-
if (!resolved) {
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
const { targets, path } = resolved;
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
!applyBasicWebhookRequestGuards({
|
|
145
|
-
req,
|
|
146
|
-
res,
|
|
147
|
-
allowMethods: ["POST"],
|
|
148
|
-
})
|
|
149
|
-
) {
|
|
150
|
-
return true;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
|
154
|
-
const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
|
|
155
|
-
timingSafeEquals(entry.secret, headerToken),
|
|
156
|
-
);
|
|
157
|
-
if (matchedTarget.kind === "none") {
|
|
158
|
-
res.statusCode = 401;
|
|
159
|
-
res.end("unauthorized");
|
|
160
|
-
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
if (matchedTarget.kind === "ambiguous") {
|
|
164
|
-
res.statusCode = 401;
|
|
165
|
-
res.end("ambiguous webhook target");
|
|
166
|
-
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
|
167
|
-
return true;
|
|
168
|
-
}
|
|
169
|
-
const target = matchedTarget.target;
|
|
170
|
-
const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
|
|
171
|
-
const nowMs = Date.now();
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
!applyBasicWebhookRequestGuards({
|
|
175
|
-
req,
|
|
176
|
-
res,
|
|
177
|
-
rateLimiter: webhookRateLimiter,
|
|
178
|
-
rateLimitKey,
|
|
179
|
-
nowMs,
|
|
180
|
-
requireJsonContentType: true,
|
|
181
|
-
})
|
|
182
|
-
) {
|
|
183
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
184
|
-
return true;
|
|
185
|
-
}
|
|
186
|
-
const body = await readJsonWebhookBodyOrReject({
|
|
137
|
+
return await withResolvedWebhookRequestPipeline({
|
|
187
138
|
req,
|
|
188
139
|
res,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
},
|
|
193
212
|
});
|
|
194
|
-
if (!body.ok) {
|
|
195
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
const raw = body.value;
|
|
199
|
-
|
|
200
|
-
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
|
|
201
|
-
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
202
|
-
const update: ZaloUpdate | undefined =
|
|
203
|
-
record && record.ok === true && record.result
|
|
204
|
-
? (record.result as ZaloUpdate)
|
|
205
|
-
: ((record as ZaloUpdate | null) ?? undefined);
|
|
206
|
-
|
|
207
|
-
if (!update?.event_name) {
|
|
208
|
-
res.statusCode = 400;
|
|
209
|
-
res.end("Bad Request");
|
|
210
|
-
recordWebhookStatus(target.runtime, path, res.statusCode);
|
|
211
|
-
return true;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (isReplayEvent(update, nowMs)) {
|
|
215
|
-
res.statusCode = 200;
|
|
216
|
-
res.end("ok");
|
|
217
|
-
return true;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
target.statusSink?.({ lastInboundAt: Date.now() });
|
|
221
|
-
processUpdate({ update, target }).catch((err) => {
|
|
222
|
-
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
res.statusCode = 200;
|
|
226
|
-
res.end("ok");
|
|
227
|
-
return true;
|
|
228
213
|
}
|