@openclaw/zalo 2026.2.23 → 2026.2.24

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.24
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.2.22
4
10
 
5
11
  ### Changes
package/package.json CHANGED
@@ -1,14 +1,11 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.2.23",
3
+ "version": "2026.2.24",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "undici": "7.22.0"
8
8
  },
9
- "devDependencies": {
10
- "openclaw": "workspace:*"
11
- },
12
9
  "openclaw": {
13
10
  "extensions": [
14
11
  "./index.ts"
package/src/channel.ts CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  migrateBaseNameToDefaultAccount,
17
17
  normalizeAccountId,
18
18
  PAIRING_APPROVED_MESSAGE,
19
+ resolveDefaultGroupPolicy,
20
+ resolveOpenProviderRuntimeGroupPolicy,
19
21
  resolveChannelAccountConfigBasePath,
20
22
  setAccountEnabledInConfigSection,
21
23
  } from "openclaw/plugin-sdk";
@@ -56,7 +58,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined {
56
58
  export const zaloDock: ChannelDock = {
57
59
  id: "zalo",
58
60
  capabilities: {
59
- chatTypes: ["direct"],
61
+ chatTypes: ["direct", "group"],
60
62
  media: true,
61
63
  blockStreaming: true,
62
64
  },
@@ -82,7 +84,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
82
84
  meta,
83
85
  onboarding: zaloOnboardingAdapter,
84
86
  capabilities: {
85
- chatTypes: ["direct"],
87
+ chatTypes: ["direct", "group"],
86
88
  media: true,
87
89
  reactions: false,
88
90
  threads: false,
@@ -143,6 +145,31 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
143
145
  normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
144
146
  };
145
147
  },
148
+ collectWarnings: ({ account, cfg }) => {
149
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
150
+ const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
151
+ providerConfigPresent: cfg.channels?.zalo !== undefined,
152
+ groupPolicy: account.config.groupPolicy,
153
+ defaultGroupPolicy,
154
+ });
155
+ if (groupPolicy !== "open") {
156
+ return [];
157
+ }
158
+ const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) =>
159
+ String(entry),
160
+ );
161
+ const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
162
+ const effectiveAllowFrom =
163
+ explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
164
+ if (effectiveAllowFrom.length > 0) {
165
+ return [
166
+ `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`,
167
+ ];
168
+ }
169
+ return [
170
+ `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`,
171
+ ];
172
+ },
146
173
  },
147
174
  groups: {
148
175
  resolveRequireMention: () => true,
@@ -14,6 +14,8 @@ const zaloAccountSchema = z.object({
14
14
  webhookPath: z.string().optional(),
15
15
  dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
16
16
  allowFrom: z.array(allowFromEntry).optional(),
17
+ groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
18
+ groupAllowFrom: z.array(allowFromEntry).optional(),
17
19
  mediaMaxMb: z.number().optional(),
18
20
  proxy: z.string().optional(),
19
21
  responsePrefix: z.string().optional(),
@@ -0,0 +1,48 @@
1
+ import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk";
2
+ import {
3
+ evaluateSenderGroupAccess,
4
+ isNormalizedSenderAllowed,
5
+ resolveOpenProviderRuntimeGroupPolicy,
6
+ } from "openclaw/plugin-sdk";
7
+
8
+ const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
9
+
10
+ export function isZaloSenderAllowed(senderId: string, allowFrom: string[]): boolean {
11
+ return isNormalizedSenderAllowed({
12
+ senderId,
13
+ allowFrom,
14
+ stripPrefixRe: ZALO_ALLOW_FROM_PREFIX_RE,
15
+ });
16
+ }
17
+
18
+ export function resolveZaloRuntimeGroupPolicy(params: {
19
+ providerConfigPresent: boolean;
20
+ groupPolicy?: GroupPolicy;
21
+ defaultGroupPolicy?: GroupPolicy;
22
+ }): {
23
+ groupPolicy: GroupPolicy;
24
+ providerMissingFallbackApplied: boolean;
25
+ } {
26
+ return resolveOpenProviderRuntimeGroupPolicy({
27
+ providerConfigPresent: params.providerConfigPresent,
28
+ groupPolicy: params.groupPolicy,
29
+ defaultGroupPolicy: params.defaultGroupPolicy,
30
+ });
31
+ }
32
+
33
+ export function evaluateZaloGroupAccess(params: {
34
+ providerConfigPresent: boolean;
35
+ configuredGroupPolicy?: GroupPolicy;
36
+ defaultGroupPolicy?: GroupPolicy;
37
+ groupAllowFrom: string[];
38
+ senderId: string;
39
+ }): SenderGroupAccessDecision {
40
+ return evaluateSenderGroupAccess({
41
+ providerConfigPresent: params.providerConfigPresent,
42
+ configuredGroupPolicy: params.configuredGroupPolicy,
43
+ defaultGroupPolicy: params.defaultGroupPolicy,
44
+ groupAllowFrom: params.groupAllowFrom,
45
+ senderId: params.senderId,
46
+ isSenderAllowed: isZaloSenderAllowed,
47
+ });
48
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { __testing } from "./monitor.js";
3
+
4
+ describe("zalo group policy access", () => {
5
+ it("defaults missing provider config to allowlist", () => {
6
+ const resolved = __testing.resolveZaloRuntimeGroupPolicy({
7
+ providerConfigPresent: false,
8
+ groupPolicy: undefined,
9
+ defaultGroupPolicy: "open",
10
+ });
11
+ expect(resolved).toEqual({
12
+ groupPolicy: "allowlist",
13
+ providerMissingFallbackApplied: true,
14
+ });
15
+ });
16
+
17
+ it("blocks all group messages when policy is disabled", () => {
18
+ const decision = __testing.evaluateZaloGroupAccess({
19
+ providerConfigPresent: true,
20
+ configuredGroupPolicy: "disabled",
21
+ defaultGroupPolicy: "open",
22
+ groupAllowFrom: ["zalo:123"],
23
+ senderId: "123",
24
+ });
25
+ expect(decision).toMatchObject({
26
+ allowed: false,
27
+ groupPolicy: "disabled",
28
+ reason: "disabled",
29
+ });
30
+ });
31
+
32
+ it("blocks group messages on allowlist policy with empty allowlist", () => {
33
+ const decision = __testing.evaluateZaloGroupAccess({
34
+ providerConfigPresent: true,
35
+ configuredGroupPolicy: "allowlist",
36
+ defaultGroupPolicy: "open",
37
+ groupAllowFrom: [],
38
+ senderId: "attacker",
39
+ });
40
+ expect(decision).toMatchObject({
41
+ allowed: false,
42
+ groupPolicy: "allowlist",
43
+ reason: "empty_allowlist",
44
+ });
45
+ });
46
+
47
+ it("blocks sender not in group allowlist", () => {
48
+ const decision = __testing.evaluateZaloGroupAccess({
49
+ providerConfigPresent: true,
50
+ configuredGroupPolicy: "allowlist",
51
+ defaultGroupPolicy: "open",
52
+ groupAllowFrom: ["zalo:victim-user-001"],
53
+ senderId: "attacker-user-999",
54
+ });
55
+ expect(decision).toMatchObject({
56
+ allowed: false,
57
+ groupPolicy: "allowlist",
58
+ reason: "sender_not_allowlisted",
59
+ });
60
+ });
61
+
62
+ it("allows sender in group allowlist", () => {
63
+ const decision = __testing.evaluateZaloGroupAccess({
64
+ providerConfigPresent: true,
65
+ configuredGroupPolicy: "allowlist",
66
+ defaultGroupPolicy: "open",
67
+ groupAllowFrom: ["zl:12345"],
68
+ senderId: "12345",
69
+ });
70
+ expect(decision).toMatchObject({
71
+ allowed: true,
72
+ groupPolicy: "allowlist",
73
+ reason: "allowed",
74
+ });
75
+ });
76
+
77
+ it("allows any sender with wildcard allowlist", () => {
78
+ const decision = __testing.evaluateZaloGroupAccess({
79
+ providerConfigPresent: true,
80
+ configuredGroupPolicy: "allowlist",
81
+ defaultGroupPolicy: "open",
82
+ groupAllowFrom: ["*"],
83
+ senderId: "random-user",
84
+ });
85
+ expect(decision).toMatchObject({
86
+ allowed: true,
87
+ groupPolicy: "allowlist",
88
+ reason: "allowed",
89
+ });
90
+ });
91
+
92
+ it("allows all group senders on open policy", () => {
93
+ const decision = __testing.evaluateZaloGroupAccess({
94
+ providerConfigPresent: true,
95
+ configuredGroupPolicy: "open",
96
+ defaultGroupPolicy: "allowlist",
97
+ groupAllowFrom: [],
98
+ senderId: "attacker-user-999",
99
+ });
100
+ expect(decision).toMatchObject({
101
+ allowed: true,
102
+ groupPolicy: "open",
103
+ reason: "allowed",
104
+ });
105
+ });
106
+ });
package/src/monitor.ts CHANGED
@@ -1,19 +1,13 @@
1
- import { timingSafeEqual } from "node:crypto";
2
1
  import type { IncomingMessage, ServerResponse } from "node:http";
3
2
  import type { MarkdownTableMode, OpenClawConfig, OutboundReplyPayload } from "openclaw/plugin-sdk";
4
3
  import {
5
- createDedupeCache,
6
4
  createReplyPrefixOptions,
7
- readJsonBodyWithLimit,
8
- registerWebhookTarget,
9
- rejectNonPostWebhookRequest,
10
- resolveSingleWebhookTarget,
11
5
  resolveSenderCommandAuthorization,
12
6
  resolveOutboundMediaUrls,
7
+ resolveDefaultGroupPolicy,
13
8
  sendMediaWithLeadingCaption,
14
9
  resolveWebhookPath,
15
- resolveWebhookTargets,
16
- requestBodyErrorToText,
10
+ warnMissingProviderGroupPolicyFallbackOnce,
17
11
  } from "openclaw/plugin-sdk";
18
12
  import type { ResolvedZaloAccount } from "./accounts.js";
19
13
  import {
@@ -27,6 +21,16 @@ import {
27
21
  type ZaloMessage,
28
22
  type ZaloUpdate,
29
23
  } from "./api.js";
24
+ import {
25
+ evaluateZaloGroupAccess,
26
+ isZaloSenderAllowed,
27
+ resolveZaloRuntimeGroupPolicy,
28
+ } from "./group-access.js";
29
+ import {
30
+ handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
31
+ registerZaloWebhookTarget as registerZaloWebhookTargetInternal,
32
+ type ZaloWebhookTarget,
33
+ } from "./monitor.webhook.js";
30
34
  import { resolveZaloProxyFetch } from "./proxy.js";
31
35
  import { getZaloRuntime } from "./runtime.js";
32
36
 
@@ -55,13 +59,8 @@ export type ZaloMonitorResult = {
55
59
 
56
60
  const ZALO_TEXT_LIMIT = 2000;
57
61
  const DEFAULT_MEDIA_MAX_MB = 5;
58
- const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
59
- const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
60
- const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
61
- const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
62
62
 
63
63
  type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
64
- type WebhookRateLimitState = { count: number; windowStartMs: number };
65
64
 
66
65
  function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
67
66
  if (core.logging.shouldLogVerbose()) {
@@ -69,216 +68,27 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
69
68
  }
70
69
  }
71
70
 
72
- function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
73
- if (allowFrom.includes("*")) {
74
- return true;
75
- }
76
- const normalizedSenderId = senderId.toLowerCase();
77
- return allowFrom.some((entry) => {
78
- const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, "");
79
- return normalized === normalizedSenderId;
80
- });
81
- }
82
-
83
- type WebhookTarget = {
84
- token: string;
85
- account: ResolvedZaloAccount;
86
- config: OpenClawConfig;
87
- runtime: ZaloRuntimeEnv;
88
- core: ZaloCoreRuntime;
89
- secret: string;
90
- path: string;
91
- mediaMaxMb: number;
92
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
93
- fetcher?: ZaloFetch;
94
- };
95
-
96
- const webhookTargets = new Map<string, WebhookTarget[]>();
97
- const webhookRateLimits = new Map<string, WebhookRateLimitState>();
98
- const recentWebhookEvents = createDedupeCache({
99
- ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
100
- maxSize: 5000,
101
- });
102
- const webhookStatusCounters = new Map<string, number>();
103
-
104
- function isJsonContentType(value: string | string[] | undefined): boolean {
105
- const first = Array.isArray(value) ? value[0] : value;
106
- if (!first) {
107
- return false;
108
- }
109
- const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
110
- return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
111
- }
112
-
113
- function timingSafeEquals(left: string, right: string): boolean {
114
- const leftBuffer = Buffer.from(left);
115
- const rightBuffer = Buffer.from(right);
116
-
117
- if (leftBuffer.length !== rightBuffer.length) {
118
- const length = Math.max(1, leftBuffer.length, rightBuffer.length);
119
- const paddedLeft = Buffer.alloc(length);
120
- const paddedRight = Buffer.alloc(length);
121
- leftBuffer.copy(paddedLeft);
122
- rightBuffer.copy(paddedRight);
123
- timingSafeEqual(paddedLeft, paddedRight);
124
- return false;
125
- }
126
-
127
- return timingSafeEqual(leftBuffer, rightBuffer);
128
- }
129
-
130
- function isWebhookRateLimited(key: string, nowMs: number): boolean {
131
- const state = webhookRateLimits.get(key);
132
- if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
133
- webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
134
- return false;
135
- }
136
-
137
- state.count += 1;
138
- if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
139
- return true;
140
- }
141
- return false;
142
- }
143
-
144
- function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
145
- const messageId = update.message?.message_id;
146
- if (!messageId) {
147
- return false;
148
- }
149
- const key = `${update.event_name}:${messageId}`;
150
- return recentWebhookEvents.check(key, nowMs);
151
- }
152
-
153
- function recordWebhookStatus(
154
- runtime: ZaloRuntimeEnv | undefined,
155
- path: string,
156
- statusCode: number,
157
- ): void {
158
- if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
159
- return;
160
- }
161
- const key = `${path}:${statusCode}`;
162
- const next = (webhookStatusCounters.get(key) ?? 0) + 1;
163
- webhookStatusCounters.set(key, next);
164
- if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
165
- runtime?.log?.(
166
- `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
167
- );
168
- }
169
- }
170
-
171
- export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
172
- return registerWebhookTarget(webhookTargets, target).unregister;
71
+ export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
72
+ return registerZaloWebhookTargetInternal(target);
173
73
  }
174
74
 
175
75
  export async function handleZaloWebhookRequest(
176
76
  req: IncomingMessage,
177
77
  res: ServerResponse,
178
78
  ): Promise<boolean> {
179
- const resolved = resolveWebhookTargets(req, webhookTargets);
180
- if (!resolved) {
181
- return false;
182
- }
183
- const { targets } = resolved;
184
-
185
- if (rejectNonPostWebhookRequest(req, res)) {
186
- return true;
187
- }
188
-
189
- const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
190
- const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
191
- timingSafeEquals(entry.secret, headerToken),
192
- );
193
- if (matchedTarget.kind === "none") {
194
- res.statusCode = 401;
195
- res.end("unauthorized");
196
- recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
197
- return true;
198
- }
199
- if (matchedTarget.kind === "ambiguous") {
200
- res.statusCode = 401;
201
- res.end("ambiguous webhook target");
202
- recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
203
- return true;
204
- }
205
- const target = matchedTarget.target;
206
- const path = req.url ?? "<unknown>";
207
- const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
208
- const nowMs = Date.now();
209
-
210
- if (isWebhookRateLimited(rateLimitKey, nowMs)) {
211
- res.statusCode = 429;
212
- res.end("Too Many Requests");
213
- recordWebhookStatus(target.runtime, path, res.statusCode);
214
- return true;
215
- }
216
-
217
- if (!isJsonContentType(req.headers["content-type"])) {
218
- res.statusCode = 415;
219
- res.end("Unsupported Media Type");
220
- recordWebhookStatus(target.runtime, path, res.statusCode);
221
- return true;
222
- }
223
-
224
- const body = await readJsonBodyWithLimit(req, {
225
- maxBytes: 1024 * 1024,
226
- timeoutMs: 30_000,
227
- emptyObjectOnEmpty: false,
228
- });
229
- if (!body.ok) {
230
- res.statusCode =
231
- body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
232
- const message =
233
- body.code === "PAYLOAD_TOO_LARGE"
234
- ? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
235
- : body.code === "REQUEST_BODY_TIMEOUT"
236
- ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
237
- : "Bad Request";
238
- res.end(message);
239
- recordWebhookStatus(target.runtime, path, res.statusCode);
240
- return true;
241
- }
242
-
243
- // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
244
- const raw = body.value;
245
- const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
246
- const update: ZaloUpdate | undefined =
247
- record && record.ok === true && record.result
248
- ? (record.result as ZaloUpdate)
249
- : ((record as ZaloUpdate | null) ?? undefined);
250
-
251
- if (!update?.event_name) {
252
- res.statusCode = 400;
253
- res.end("Bad Request");
254
- recordWebhookStatus(target.runtime, path, res.statusCode);
255
- return true;
256
- }
257
-
258
- if (isReplayEvent(update, nowMs)) {
259
- res.statusCode = 200;
260
- res.end("ok");
261
- return true;
262
- }
263
-
264
- target.statusSink?.({ lastInboundAt: Date.now() });
265
- processUpdate(
266
- update,
267
- target.token,
268
- target.account,
269
- target.config,
270
- target.runtime,
271
- target.core,
272
- target.mediaMaxMb,
273
- target.statusSink,
274
- target.fetcher,
275
- ).catch((err) => {
276
- target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
79
+ return handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
80
+ await processUpdate(
81
+ update,
82
+ target.token,
83
+ target.account,
84
+ target.config,
85
+ target.runtime,
86
+ target.core as ZaloCoreRuntime,
87
+ target.mediaMaxMb,
88
+ target.statusSink,
89
+ target.fetcher,
90
+ );
277
91
  });
278
-
279
- res.statusCode = 200;
280
- res.end("ok");
281
- return true;
282
92
  }
283
93
 
284
94
  function startPollingLoop(params: {
@@ -502,6 +312,42 @@ async function processMessageWithPipeline(params: {
502
312
 
503
313
  const dmPolicy = account.config.dmPolicy ?? "pairing";
504
314
  const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
315
+ const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
316
+ const groupAllowFrom =
317
+ configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom;
318
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
319
+ const groupAccess = isGroup
320
+ ? evaluateZaloGroupAccess({
321
+ providerConfigPresent: config.channels?.zalo !== undefined,
322
+ configuredGroupPolicy: account.config.groupPolicy,
323
+ defaultGroupPolicy,
324
+ groupAllowFrom,
325
+ senderId,
326
+ })
327
+ : undefined;
328
+ if (groupAccess) {
329
+ warnMissingProviderGroupPolicyFallbackOnce({
330
+ providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied,
331
+ providerKey: "zalo",
332
+ accountId: account.accountId,
333
+ log: (message) => logVerbose(core, runtime, message),
334
+ });
335
+ if (!groupAccess.allowed) {
336
+ if (groupAccess.reason === "disabled") {
337
+ logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`);
338
+ } else if (groupAccess.reason === "empty_allowlist") {
339
+ logVerbose(
340
+ core,
341
+ runtime,
342
+ `zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`,
343
+ );
344
+ } else if (groupAccess.reason === "sender_not_allowlisted") {
345
+ logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`);
346
+ }
347
+ return;
348
+ }
349
+ }
350
+
505
351
  const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
506
352
  const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
507
353
  cfg: config,
@@ -510,7 +356,7 @@ async function processMessageWithPipeline(params: {
510
356
  dmPolicy,
511
357
  configuredAllowFrom: configAllowFrom,
512
358
  senderId,
513
- isSenderAllowed,
359
+ isSenderAllowed: isZaloSenderAllowed,
514
360
  readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
515
361
  shouldComputeCommandAuthorized: (body, cfg) =>
516
362
  core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
@@ -818,3 +664,8 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
818
664
 
819
665
  return { stop };
820
666
  }
667
+
668
+ export const __testing = {
669
+ evaluateZaloGroupAccess,
670
+ resolveZaloRuntimeGroupPolicy,
671
+ };
@@ -0,0 +1,219 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
4
+ import {
5
+ createDedupeCache,
6
+ readJsonBodyWithLimit,
7
+ registerWebhookTarget,
8
+ rejectNonPostWebhookRequest,
9
+ requestBodyErrorToText,
10
+ resolveSingleWebhookTarget,
11
+ resolveWebhookTargets,
12
+ } from "openclaw/plugin-sdk";
13
+ import type { ResolvedZaloAccount } from "./accounts.js";
14
+ import type { ZaloFetch, ZaloUpdate } from "./api.js";
15
+ import type { ZaloRuntimeEnv } from "./monitor.js";
16
+
17
+ type WebhookRateLimitState = { count: number; windowStartMs: number };
18
+
19
+ const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
20
+ const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
21
+ const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
22
+ const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
23
+
24
+ export type ZaloWebhookTarget = {
25
+ token: string;
26
+ account: ResolvedZaloAccount;
27
+ config: OpenClawConfig;
28
+ runtime: ZaloRuntimeEnv;
29
+ core: unknown;
30
+ secret: string;
31
+ path: string;
32
+ mediaMaxMb: number;
33
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
34
+ fetcher?: ZaloFetch;
35
+ };
36
+
37
+ export type ZaloWebhookProcessUpdate = (params: {
38
+ update: ZaloUpdate;
39
+ target: ZaloWebhookTarget;
40
+ }) => Promise<void>;
41
+
42
+ const webhookTargets = new Map<string, ZaloWebhookTarget[]>();
43
+ const webhookRateLimits = new Map<string, WebhookRateLimitState>();
44
+ const recentWebhookEvents = createDedupeCache({
45
+ ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
46
+ maxSize: 5000,
47
+ });
48
+ const webhookStatusCounters = new Map<string, number>();
49
+
50
+ function isJsonContentType(value: string | string[] | undefined): boolean {
51
+ const first = Array.isArray(value) ? value[0] : value;
52
+ if (!first) {
53
+ return false;
54
+ }
55
+ const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
56
+ return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
57
+ }
58
+
59
+ function timingSafeEquals(left: string, right: string): boolean {
60
+ const leftBuffer = Buffer.from(left);
61
+ const rightBuffer = Buffer.from(right);
62
+
63
+ if (leftBuffer.length !== rightBuffer.length) {
64
+ const length = Math.max(1, leftBuffer.length, rightBuffer.length);
65
+ const paddedLeft = Buffer.alloc(length);
66
+ const paddedRight = Buffer.alloc(length);
67
+ leftBuffer.copy(paddedLeft);
68
+ rightBuffer.copy(paddedRight);
69
+ timingSafeEqual(paddedLeft, paddedRight);
70
+ return false;
71
+ }
72
+
73
+ return timingSafeEqual(leftBuffer, rightBuffer);
74
+ }
75
+
76
+ function isWebhookRateLimited(key: string, nowMs: number): boolean {
77
+ const state = webhookRateLimits.get(key);
78
+ if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
79
+ webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
80
+ return false;
81
+ }
82
+
83
+ state.count += 1;
84
+ if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
91
+ const messageId = update.message?.message_id;
92
+ if (!messageId) {
93
+ return false;
94
+ }
95
+ const key = `${update.event_name}:${messageId}`;
96
+ return recentWebhookEvents.check(key, nowMs);
97
+ }
98
+
99
+ function recordWebhookStatus(
100
+ runtime: ZaloRuntimeEnv | undefined,
101
+ path: string,
102
+ statusCode: number,
103
+ ): void {
104
+ if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
105
+ return;
106
+ }
107
+ const key = `${path}:${statusCode}`;
108
+ const next = (webhookStatusCounters.get(key) ?? 0) + 1;
109
+ webhookStatusCounters.set(key, next);
110
+ if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
111
+ runtime?.log?.(
112
+ `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
113
+ );
114
+ }
115
+ }
116
+
117
+ export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
118
+ return registerWebhookTarget(webhookTargets, target).unregister;
119
+ }
120
+
121
+ export async function handleZaloWebhookRequest(
122
+ req: IncomingMessage,
123
+ res: ServerResponse,
124
+ processUpdate: ZaloWebhookProcessUpdate,
125
+ ): Promise<boolean> {
126
+ const resolved = resolveWebhookTargets(req, webhookTargets);
127
+ if (!resolved) {
128
+ return false;
129
+ }
130
+ const { targets } = resolved;
131
+
132
+ if (rejectNonPostWebhookRequest(req, res)) {
133
+ return true;
134
+ }
135
+
136
+ const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
137
+ const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
138
+ timingSafeEquals(entry.secret, headerToken),
139
+ );
140
+ if (matchedTarget.kind === "none") {
141
+ res.statusCode = 401;
142
+ res.end("unauthorized");
143
+ recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
144
+ return true;
145
+ }
146
+ if (matchedTarget.kind === "ambiguous") {
147
+ res.statusCode = 401;
148
+ res.end("ambiguous webhook target");
149
+ recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
150
+ return true;
151
+ }
152
+ const target = matchedTarget.target;
153
+ const path = req.url ?? "<unknown>";
154
+ const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
155
+ const nowMs = Date.now();
156
+
157
+ if (isWebhookRateLimited(rateLimitKey, nowMs)) {
158
+ res.statusCode = 429;
159
+ res.end("Too Many Requests");
160
+ recordWebhookStatus(target.runtime, path, res.statusCode);
161
+ return true;
162
+ }
163
+
164
+ if (!isJsonContentType(req.headers["content-type"])) {
165
+ res.statusCode = 415;
166
+ res.end("Unsupported Media Type");
167
+ recordWebhookStatus(target.runtime, path, res.statusCode);
168
+ return true;
169
+ }
170
+
171
+ const body = await readJsonBodyWithLimit(req, {
172
+ maxBytes: 1024 * 1024,
173
+ timeoutMs: 30_000,
174
+ emptyObjectOnEmpty: false,
175
+ });
176
+ if (!body.ok) {
177
+ res.statusCode =
178
+ body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
179
+ const message =
180
+ body.code === "PAYLOAD_TOO_LARGE"
181
+ ? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
182
+ : body.code === "REQUEST_BODY_TIMEOUT"
183
+ ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
184
+ : "Bad Request";
185
+ res.end(message);
186
+ recordWebhookStatus(target.runtime, path, res.statusCode);
187
+ return true;
188
+ }
189
+
190
+ // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
191
+ const raw = body.value;
192
+ const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
193
+ const update: ZaloUpdate | undefined =
194
+ record && record.ok === true && record.result
195
+ ? (record.result as ZaloUpdate)
196
+ : ((record as ZaloUpdate | null) ?? undefined);
197
+
198
+ if (!update?.event_name) {
199
+ res.statusCode = 400;
200
+ res.end("Bad Request");
201
+ recordWebhookStatus(target.runtime, path, res.statusCode);
202
+ return true;
203
+ }
204
+
205
+ if (isReplayEvent(update, nowMs)) {
206
+ res.statusCode = 200;
207
+ res.end("ok");
208
+ return true;
209
+ }
210
+
211
+ target.statusSink?.({ lastInboundAt: Date.now() });
212
+ processUpdate({ update, target }).catch((err) => {
213
+ target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
214
+ });
215
+
216
+ res.statusCode = 200;
217
+ res.end("ok");
218
+ return true;
219
+ }
package/src/types.ts CHANGED
@@ -17,6 +17,10 @@ export type ZaloAccountConfig = {
17
17
  dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
18
18
  /** Allowlist for DM senders (Zalo user IDs). */
19
19
  allowFrom?: Array<string | number>;
20
+ /** Group-message access policy. */
21
+ groupPolicy?: "open" | "allowlist" | "disabled";
22
+ /** Allowlist for group senders (falls back to allowFrom when unset). */
23
+ groupAllowFrom?: Array<string | number>;
20
24
  /** Max inbound media size in MB. */
21
25
  mediaMaxMb?: number;
22
26
  /** Proxy URL for API requests. */