@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/CHANGELOG.md +18 -0
- package/index.ts +2 -4
- package/package.json +3 -2
- package/src/accounts.ts +7 -37
- package/src/actions.ts +2 -2
- package/src/channel.directory.test.ts +1 -1
- package/src/channel.sendpayload.test.ts +102 -0
- package/src/channel.ts +98 -127
- package/src/config-schema.test.ts +30 -0
- package/src/config-schema.ts +4 -3
- package/src/group-access.ts +2 -2
- package/src/monitor.ts +85 -78
- package/src/monitor.webhook.test.ts +159 -36
- package/src/monitor.webhook.ts +98 -94
- package/src/onboarding.status.test.ts +24 -0
- package/src/onboarding.ts +117 -93
- package/src/probe.ts +1 -1
- package/src/runtime.ts +1 -1
- package/src/secret-input.ts +13 -0
- package/src/send.ts +29 -24
- package/src/status-issues.ts +1 -1
- package/src/token.test.ts +58 -0
- package/src/token.ts +64 -29
- package/src/types.ts +4 -2
package/src/monitor.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
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 {
|
|
4
8
|
createScopedPairingAccess,
|
|
5
9
|
createReplyPrefixOptions,
|
|
6
|
-
|
|
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 } =
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
});
|
package/src/monitor.webhook.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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(
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
}
|