@openclaw/zalo 2026.2.22 → 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.22",
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/actions.ts CHANGED
@@ -3,7 +3,7 @@ import type {
3
3
  ChannelMessageActionName,
4
4
  OpenClawConfig,
5
5
  } from "openclaw/plugin-sdk";
6
- import { jsonResult, readStringParam } from "openclaw/plugin-sdk";
6
+ import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
7
7
  import { listEnabledZaloAccounts } from "./accounts.js";
8
8
  import { sendMessageZalo } from "./send.js";
9
9
 
@@ -25,18 +25,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
25
25
  return Array.from(actions);
26
26
  },
27
27
  supportsButtons: () => false,
28
- extractToolSend: ({ args }) => {
29
- const action = typeof args.action === "string" ? args.action.trim() : "";
30
- if (action !== "sendMessage") {
31
- return null;
32
- }
33
- const to = typeof args.to === "string" ? args.to : undefined;
34
- if (!to) {
35
- return null;
36
- }
37
- const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
38
- return { to, accountId };
39
- },
28
+ extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
40
29
  handleAction: async ({ action, params, cfg, accountId }) => {
41
30
  if (action === "send") {
42
31
  const to = readStringParam(params, "to", { required: true });
package/src/channel.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  import {
8
8
  applyAccountNameToChannelSection,
9
9
  buildChannelConfigSchema,
10
+ buildTokenChannelStatusSummary,
10
11
  DEFAULT_ACCOUNT_ID,
11
12
  deleteAccountFromConfigSection,
12
13
  chunkTextForOutbound,
@@ -15,6 +16,8 @@ import {
15
16
  migrateBaseNameToDefaultAccount,
16
17
  normalizeAccountId,
17
18
  PAIRING_APPROVED_MESSAGE,
19
+ resolveDefaultGroupPolicy,
20
+ resolveOpenProviderRuntimeGroupPolicy,
18
21
  resolveChannelAccountConfigBasePath,
19
22
  setAccountEnabledInConfigSection,
20
23
  } from "openclaw/plugin-sdk";
@@ -55,7 +58,7 @@ function normalizeZaloMessagingTarget(raw: string): string | undefined {
55
58
  export const zaloDock: ChannelDock = {
56
59
  id: "zalo",
57
60
  capabilities: {
58
- chatTypes: ["direct"],
61
+ chatTypes: ["direct", "group"],
59
62
  media: true,
60
63
  blockStreaming: true,
61
64
  },
@@ -81,7 +84,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
81
84
  meta,
82
85
  onboarding: zaloOnboardingAdapter,
83
86
  capabilities: {
84
- chatTypes: ["direct"],
87
+ chatTypes: ["direct", "group"],
85
88
  media: true,
86
89
  reactions: false,
87
90
  threads: false,
@@ -142,6 +145,31 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
142
145
  normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
143
146
  };
144
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
+ },
145
173
  },
146
174
  groups: {
147
175
  resolveRequireMention: () => true,
@@ -309,17 +337,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
309
337
  lastError: null,
310
338
  },
311
339
  collectStatusIssues: collectZaloStatusIssues,
312
- buildChannelSummary: ({ snapshot }) => ({
313
- configured: snapshot.configured ?? false,
314
- tokenSource: snapshot.tokenSource ?? "none",
315
- running: snapshot.running ?? false,
316
- mode: snapshot.mode ?? null,
317
- lastStartAt: snapshot.lastStartAt ?? null,
318
- lastStopAt: snapshot.lastStopAt ?? null,
319
- lastError: snapshot.lastError ?? null,
320
- probe: snapshot.probe,
321
- lastProbeAt: snapshot.lastProbeAt ?? null,
322
- }),
340
+ buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
323
341
  probeAccount: async ({ account, timeoutMs }) =>
324
342
  probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
325
343
  buildAccountSnapshot: ({ account, runtime }) => {
@@ -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,17 +1,13 @@
1
- import { timingSafeEqual } from "node:crypto";
2
1
  import type { IncomingMessage, ServerResponse } from "node:http";
3
- import type { OpenClawConfig, MarkdownTableMode } from "openclaw/plugin-sdk";
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,
6
+ resolveOutboundMediaUrls,
7
+ resolveDefaultGroupPolicy,
8
+ sendMediaWithLeadingCaption,
12
9
  resolveWebhookPath,
13
- resolveWebhookTargets,
14
- requestBodyErrorToText,
10
+ warnMissingProviderGroupPolicyFallbackOnce,
15
11
  } from "openclaw/plugin-sdk";
16
12
  import type { ResolvedZaloAccount } from "./accounts.js";
17
13
  import {
@@ -25,6 +21,16 @@ import {
25
21
  type ZaloMessage,
26
22
  type ZaloUpdate,
27
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";
28
34
  import { resolveZaloProxyFetch } from "./proxy.js";
29
35
  import { getZaloRuntime } from "./runtime.js";
30
36
 
@@ -53,13 +59,8 @@ export type ZaloMonitorResult = {
53
59
 
54
60
  const ZALO_TEXT_LIMIT = 2000;
55
61
  const DEFAULT_MEDIA_MAX_MB = 5;
56
- const ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
57
- const ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
58
- const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
59
- const ZALO_WEBHOOK_COUNTER_LOG_EVERY = 25;
60
62
 
61
63
  type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
62
- type WebhookRateLimitState = { count: number; windowStartMs: number };
63
64
 
64
65
  function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
65
66
  if (core.logging.shouldLogVerbose()) {
@@ -67,216 +68,27 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
67
68
  }
68
69
  }
69
70
 
70
- function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
71
- if (allowFrom.includes("*")) {
72
- return true;
73
- }
74
- const normalizedSenderId = senderId.toLowerCase();
75
- return allowFrom.some((entry) => {
76
- const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, "");
77
- return normalized === normalizedSenderId;
78
- });
79
- }
80
-
81
- type WebhookTarget = {
82
- token: string;
83
- account: ResolvedZaloAccount;
84
- config: OpenClawConfig;
85
- runtime: ZaloRuntimeEnv;
86
- core: ZaloCoreRuntime;
87
- secret: string;
88
- path: string;
89
- mediaMaxMb: number;
90
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
91
- fetcher?: ZaloFetch;
92
- };
93
-
94
- const webhookTargets = new Map<string, WebhookTarget[]>();
95
- const webhookRateLimits = new Map<string, WebhookRateLimitState>();
96
- const recentWebhookEvents = createDedupeCache({
97
- ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
98
- maxSize: 5000,
99
- });
100
- const webhookStatusCounters = new Map<string, number>();
101
-
102
- function isJsonContentType(value: string | string[] | undefined): boolean {
103
- const first = Array.isArray(value) ? value[0] : value;
104
- if (!first) {
105
- return false;
106
- }
107
- const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
108
- return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
109
- }
110
-
111
- function timingSafeEquals(left: string, right: string): boolean {
112
- const leftBuffer = Buffer.from(left);
113
- const rightBuffer = Buffer.from(right);
114
-
115
- if (leftBuffer.length !== rightBuffer.length) {
116
- const length = Math.max(1, leftBuffer.length, rightBuffer.length);
117
- const paddedLeft = Buffer.alloc(length);
118
- const paddedRight = Buffer.alloc(length);
119
- leftBuffer.copy(paddedLeft);
120
- rightBuffer.copy(paddedRight);
121
- timingSafeEqual(paddedLeft, paddedRight);
122
- return false;
123
- }
124
-
125
- return timingSafeEqual(leftBuffer, rightBuffer);
126
- }
127
-
128
- function isWebhookRateLimited(key: string, nowMs: number): boolean {
129
- const state = webhookRateLimits.get(key);
130
- if (!state || nowMs - state.windowStartMs >= ZALO_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
131
- webhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
132
- return false;
133
- }
134
-
135
- state.count += 1;
136
- if (state.count > ZALO_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
137
- return true;
138
- }
139
- return false;
140
- }
141
-
142
- function isReplayEvent(update: ZaloUpdate, nowMs: number): boolean {
143
- const messageId = update.message?.message_id;
144
- if (!messageId) {
145
- return false;
146
- }
147
- const key = `${update.event_name}:${messageId}`;
148
- return recentWebhookEvents.check(key, nowMs);
149
- }
150
-
151
- function recordWebhookStatus(
152
- runtime: ZaloRuntimeEnv | undefined,
153
- path: string,
154
- statusCode: number,
155
- ): void {
156
- if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
157
- return;
158
- }
159
- const key = `${path}:${statusCode}`;
160
- const next = (webhookStatusCounters.get(key) ?? 0) + 1;
161
- webhookStatusCounters.set(key, next);
162
- if (next === 1 || next % ZALO_WEBHOOK_COUNTER_LOG_EVERY === 0) {
163
- runtime?.log?.(
164
- `[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(next)}`,
165
- );
166
- }
167
- }
168
-
169
- export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
170
- return registerWebhookTarget(webhookTargets, target).unregister;
71
+ export function registerZaloWebhookTarget(target: ZaloWebhookTarget): () => void {
72
+ return registerZaloWebhookTargetInternal(target);
171
73
  }
172
74
 
173
75
  export async function handleZaloWebhookRequest(
174
76
  req: IncomingMessage,
175
77
  res: ServerResponse,
176
78
  ): Promise<boolean> {
177
- const resolved = resolveWebhookTargets(req, webhookTargets);
178
- if (!resolved) {
179
- return false;
180
- }
181
- const { targets } = resolved;
182
-
183
- if (rejectNonPostWebhookRequest(req, res)) {
184
- return true;
185
- }
186
-
187
- const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
188
- const matchedTarget = resolveSingleWebhookTarget(targets, (entry) =>
189
- timingSafeEquals(entry.secret, headerToken),
190
- );
191
- if (matchedTarget.kind === "none") {
192
- res.statusCode = 401;
193
- res.end("unauthorized");
194
- recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
195
- return true;
196
- }
197
- if (matchedTarget.kind === "ambiguous") {
198
- res.statusCode = 401;
199
- res.end("ambiguous webhook target");
200
- recordWebhookStatus(targets[0]?.runtime, req.url ?? "<unknown>", res.statusCode);
201
- return true;
202
- }
203
- const target = matchedTarget.target;
204
- const path = req.url ?? "<unknown>";
205
- const rateLimitKey = `${path}:${req.socket.remoteAddress ?? "unknown"}`;
206
- const nowMs = Date.now();
207
-
208
- if (isWebhookRateLimited(rateLimitKey, nowMs)) {
209
- res.statusCode = 429;
210
- res.end("Too Many Requests");
211
- recordWebhookStatus(target.runtime, path, res.statusCode);
212
- return true;
213
- }
214
-
215
- if (!isJsonContentType(req.headers["content-type"])) {
216
- res.statusCode = 415;
217
- res.end("Unsupported Media Type");
218
- recordWebhookStatus(target.runtime, path, res.statusCode);
219
- return true;
220
- }
221
-
222
- const body = await readJsonBodyWithLimit(req, {
223
- maxBytes: 1024 * 1024,
224
- timeoutMs: 30_000,
225
- emptyObjectOnEmpty: false,
226
- });
227
- if (!body.ok) {
228
- res.statusCode =
229
- body.code === "PAYLOAD_TOO_LARGE" ? 413 : body.code === "REQUEST_BODY_TIMEOUT" ? 408 : 400;
230
- const message =
231
- body.code === "PAYLOAD_TOO_LARGE"
232
- ? requestBodyErrorToText("PAYLOAD_TOO_LARGE")
233
- : body.code === "REQUEST_BODY_TIMEOUT"
234
- ? requestBodyErrorToText("REQUEST_BODY_TIMEOUT")
235
- : "Bad Request";
236
- res.end(message);
237
- recordWebhookStatus(target.runtime, path, res.statusCode);
238
- return true;
239
- }
240
-
241
- // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
242
- const raw = body.value;
243
- const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
244
- const update: ZaloUpdate | undefined =
245
- record && record.ok === true && record.result
246
- ? (record.result as ZaloUpdate)
247
- : ((record as ZaloUpdate | null) ?? undefined);
248
-
249
- if (!update?.event_name) {
250
- res.statusCode = 400;
251
- res.end("Bad Request");
252
- recordWebhookStatus(target.runtime, path, res.statusCode);
253
- return true;
254
- }
255
-
256
- if (isReplayEvent(update, nowMs)) {
257
- res.statusCode = 200;
258
- res.end("ok");
259
- return true;
260
- }
261
-
262
- target.statusSink?.({ lastInboundAt: Date.now() });
263
- processUpdate(
264
- update,
265
- target.token,
266
- target.account,
267
- target.config,
268
- target.runtime,
269
- target.core,
270
- target.mediaMaxMb,
271
- target.statusSink,
272
- target.fetcher,
273
- ).catch((err) => {
274
- 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
+ );
275
91
  });
276
-
277
- res.statusCode = 200;
278
- res.end("ok");
279
- return true;
280
92
  }
281
93
 
282
94
  function startPollingLoop(params: {
@@ -500,6 +312,42 @@ async function processMessageWithPipeline(params: {
500
312
 
501
313
  const dmPolicy = account.config.dmPolicy ?? "pairing";
502
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
+
503
351
  const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
504
352
  const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({
505
353
  cfg: config,
@@ -508,7 +356,7 @@ async function processMessageWithPipeline(params: {
508
356
  dmPolicy,
509
357
  configuredAllowFrom: configAllowFrom,
510
358
  senderId,
511
- isSenderAllowed,
359
+ isSenderAllowed: isZaloSenderAllowed,
512
360
  readAllowFromStore: () => core.channel.pairing.readAllowFromStore("zalo"),
513
361
  shouldComputeCommandAuthorized: (body, cfg) =>
514
362
  core.channel.commands.shouldComputeCommandAuthorized(body, cfg),
@@ -681,7 +529,7 @@ async function processMessageWithPipeline(params: {
681
529
  }
682
530
 
683
531
  async function deliverZaloReply(params: {
684
- payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
532
+ payload: OutboundReplyPayload;
685
533
  token: string;
686
534
  chatId: string;
687
535
  runtime: ZaloRuntimeEnv;
@@ -696,24 +544,18 @@ async function deliverZaloReply(params: {
696
544
  const tableMode = params.tableMode ?? "code";
697
545
  const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
698
546
 
699
- const mediaList = payload.mediaUrls?.length
700
- ? payload.mediaUrls
701
- : payload.mediaUrl
702
- ? [payload.mediaUrl]
703
- : [];
704
-
705
- if (mediaList.length > 0) {
706
- let first = true;
707
- for (const mediaUrl of mediaList) {
708
- const caption = first ? text : undefined;
709
- first = false;
710
- try {
711
- await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
712
- statusSink?.({ lastOutboundAt: Date.now() });
713
- } catch (err) {
714
- runtime.error?.(`Zalo photo send failed: ${String(err)}`);
715
- }
716
- }
547
+ const sentMedia = await sendMediaWithLeadingCaption({
548
+ mediaUrls: resolveOutboundMediaUrls(payload),
549
+ caption: text,
550
+ send: async ({ mediaUrl, caption }) => {
551
+ await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
552
+ statusSink?.({ lastOutboundAt: Date.now() });
553
+ },
554
+ onError: (error) => {
555
+ runtime.error?.(`Zalo photo send failed: ${String(error)}`);
556
+ },
557
+ });
558
+ if (sentMedia) {
717
559
  return;
718
560
  }
719
561
 
@@ -822,3 +664,8 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
822
664
 
823
665
  return { stop };
824
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. */