@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/src/channel.ts CHANGED
@@ -1,26 +1,36 @@
1
+ import {
2
+ buildAccountScopedDmSecurityPolicy,
3
+ collectOpenProviderGroupPolicyWarnings,
4
+ buildOpenGroupPolicyRestrictSendersWarning,
5
+ buildOpenGroupPolicyWarning,
6
+ mapAllowFromEntries,
7
+ } from "openclaw/plugin-sdk/compat";
1
8
  import type {
2
9
  ChannelAccountSnapshot,
3
10
  ChannelDock,
4
11
  ChannelPlugin,
5
12
  OpenClawConfig,
6
- } from "openclaw/plugin-sdk";
13
+ } from "openclaw/plugin-sdk/zalo";
7
14
  import {
8
15
  applyAccountNameToChannelSection,
16
+ applySetupAccountConfigPatch,
17
+ buildBaseAccountStatusSnapshot,
9
18
  buildChannelConfigSchema,
10
19
  buildTokenChannelStatusSummary,
20
+ buildChannelSendResult,
11
21
  DEFAULT_ACCOUNT_ID,
12
22
  deleteAccountFromConfigSection,
13
23
  chunkTextForOutbound,
14
24
  formatAllowFromLowercase,
15
- formatPairingApproveHint,
16
25
  migrateBaseNameToDefaultAccount,
26
+ listDirectoryUserEntriesFromAllowFrom,
17
27
  normalizeAccountId,
28
+ isNumericTargetId,
18
29
  PAIRING_APPROVED_MESSAGE,
19
- resolveDefaultGroupPolicy,
20
- resolveOpenProviderRuntimeGroupPolicy,
21
- resolveChannelAccountConfigBasePath,
30
+ resolveOutboundMediaUrls,
31
+ sendPayloadWithChunkedTextAndMedia,
22
32
  setAccountEnabledInConfigSection,
23
- } from "openclaw/plugin-sdk";
33
+ } from "openclaw/plugin-sdk/zalo";
24
34
  import {
25
35
  listZaloAccountIds,
26
36
  resolveDefaultZaloAccountId,
@@ -66,9 +76,7 @@ export const zaloDock: ChannelDock = {
66
76
  outbound: { textChunkLimit: 2000 },
67
77
  config: {
68
78
  resolveAllowFrom: ({ cfg, accountId }) =>
69
- (resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
70
- String(entry),
71
- ),
79
+ mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
72
80
  formatAllowFrom: ({ allowFrom }) =>
73
81
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
74
82
  },
@@ -123,53 +131,57 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
123
131
  tokenSource: account.tokenSource,
124
132
  }),
125
133
  resolveAllowFrom: ({ cfg, accountId }) =>
126
- (resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) =>
127
- String(entry),
128
- ),
134
+ mapAllowFromEntries(resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom),
129
135
  formatAllowFrom: ({ allowFrom }) =>
130
136
  formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
131
137
  },
132
138
  security: {
133
139
  resolveDmPolicy: ({ cfg, accountId, account }) => {
134
- const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
135
- const basePath = resolveChannelAccountConfigBasePath({
140
+ return buildAccountScopedDmSecurityPolicy({
136
141
  cfg,
137
142
  channelKey: "zalo",
138
- accountId: resolvedAccountId,
139
- });
140
- return {
141
- policy: account.config.dmPolicy ?? "pairing",
143
+ accountId,
144
+ fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
145
+ policy: account.config.dmPolicy,
142
146
  allowFrom: account.config.allowFrom ?? [],
143
- policyPath: `${basePath}dmPolicy`,
144
- allowFromPath: basePath,
145
- approveHint: formatPairingApproveHint("zalo"),
147
+ policyPathSuffix: "dmPolicy",
146
148
  normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
147
- };
149
+ });
148
150
  },
149
151
  collectWarnings: ({ account, cfg }) => {
150
- const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
151
- const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
152
+ return collectOpenProviderGroupPolicyWarnings({
153
+ cfg,
152
154
  providerConfigPresent: cfg.channels?.zalo !== undefined,
153
- groupPolicy: account.config.groupPolicy,
154
- defaultGroupPolicy,
155
+ configuredGroupPolicy: account.config.groupPolicy,
156
+ collect: (groupPolicy) => {
157
+ if (groupPolicy !== "open") {
158
+ return [];
159
+ }
160
+ const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
161
+ const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
162
+ const effectiveAllowFrom =
163
+ explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
164
+ if (effectiveAllowFrom.length > 0) {
165
+ return [
166
+ buildOpenGroupPolicyRestrictSendersWarning({
167
+ surface: "Zalo groups",
168
+ openScope: "any member",
169
+ groupPolicyPath: "channels.zalo.groupPolicy",
170
+ groupAllowFromPath: "channels.zalo.groupAllowFrom",
171
+ }),
172
+ ];
173
+ }
174
+ return [
175
+ buildOpenGroupPolicyWarning({
176
+ surface: "Zalo groups",
177
+ openBehavior:
178
+ "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
179
+ remediation:
180
+ 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
181
+ }),
182
+ ];
183
+ },
155
184
  });
156
- if (groupPolicy !== "open") {
157
- return [];
158
- }
159
- const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) =>
160
- String(entry),
161
- );
162
- const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
163
- const effectiveAllowFrom =
164
- explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
165
- if (effectiveAllowFrom.length > 0) {
166
- return [
167
- `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`,
168
- ];
169
- }
170
- return [
171
- `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`,
172
- ];
173
185
  },
174
186
  },
175
187
  groups: {
@@ -182,13 +194,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
182
194
  messaging: {
183
195
  normalizeTarget: normalizeZaloMessagingTarget,
184
196
  targetResolver: {
185
- looksLikeId: (raw) => {
186
- const trimmed = raw.trim();
187
- if (!trimmed) {
188
- return false;
189
- }
190
- return /^\d{3,}$/.test(trimmed);
191
- },
197
+ looksLikeId: isNumericTargetId,
192
198
  hint: "<chatId>",
193
199
  },
194
200
  },
@@ -196,19 +202,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
196
202
  self: async () => null,
197
203
  listPeers: async ({ cfg, accountId, query, limit }) => {
198
204
  const account = resolveZaloAccount({ cfg: cfg, accountId });
199
- const q = query?.trim().toLowerCase() || "";
200
- const peers = Array.from(
201
- new Set(
202
- (account.config.allowFrom ?? [])
203
- .map((entry) => String(entry).trim())
204
- .filter((entry) => Boolean(entry) && entry !== "*")
205
- .map((entry) => entry.replace(/^(zalo|zl):/i, "")),
206
- ),
207
- )
208
- .filter((id) => (q ? id.toLowerCase().includes(q) : true))
209
- .slice(0, limit && limit > 0 ? limit : undefined)
210
- .map((id) => ({ kind: "user", id }) as const);
211
- return peers;
205
+ return listDirectoryUserEntriesFromAllowFrom({
206
+ allowFrom: account.config.allowFrom,
207
+ query,
208
+ limit,
209
+ normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""),
210
+ });
212
211
  },
213
212
  listGroups: async () => [],
214
213
  },
@@ -244,47 +243,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
244
243
  channelKey: "zalo",
245
244
  })
246
245
  : namedConfig;
247
- if (accountId === DEFAULT_ACCOUNT_ID) {
248
- return {
249
- ...next,
250
- channels: {
251
- ...next.channels,
252
- zalo: {
253
- ...next.channels?.zalo,
254
- enabled: true,
255
- ...(input.useEnv
256
- ? {}
257
- : input.tokenFile
258
- ? { tokenFile: input.tokenFile }
259
- : input.token
260
- ? { botToken: input.token }
261
- : {}),
262
- },
263
- },
264
- } as OpenClawConfig;
265
- }
266
- return {
267
- ...next,
268
- channels: {
269
- ...next.channels,
270
- zalo: {
271
- ...next.channels?.zalo,
272
- enabled: true,
273
- accounts: {
274
- ...next.channels?.zalo?.accounts,
275
- [accountId]: {
276
- ...next.channels?.zalo?.accounts?.[accountId],
277
- enabled: true,
278
- ...(input.tokenFile
279
- ? { tokenFile: input.tokenFile }
280
- : input.token
281
- ? { botToken: input.token }
282
- : {}),
283
- },
284
- },
285
- },
286
- },
287
- } as OpenClawConfig;
246
+ const patch = input.useEnv
247
+ ? {}
248
+ : input.tokenFile
249
+ ? { tokenFile: input.tokenFile }
250
+ : input.token
251
+ ? { botToken: input.token }
252
+ : {};
253
+ return applySetupAccountConfigPatch({
254
+ cfg: next,
255
+ channelKey: "zalo",
256
+ accountId,
257
+ patch,
258
+ });
288
259
  },
289
260
  },
290
261
  pairing: {
@@ -303,51 +274,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
303
274
  chunker: chunkTextForOutbound,
304
275
  chunkerMode: "text",
305
276
  textChunkLimit: 2000,
306
- sendPayload: async (ctx) => {
307
- const text = ctx.payload.text ?? "";
308
- const urls = ctx.payload.mediaUrls?.length
309
- ? ctx.payload.mediaUrls
310
- : ctx.payload.mediaUrl
311
- ? [ctx.payload.mediaUrl]
312
- : [];
313
- if (!text && urls.length === 0) {
314
- return { channel: "zalo", messageId: "" };
315
- }
316
- if (urls.length > 0) {
317
- let lastResult = await zaloPlugin.outbound!.sendMedia!({
318
- ...ctx,
319
- text,
320
- mediaUrl: urls[0],
321
- });
322
- for (let i = 1; i < urls.length; i++) {
323
- lastResult = await zaloPlugin.outbound!.sendMedia!({
324
- ...ctx,
325
- text: "",
326
- mediaUrl: urls[i],
327
- });
328
- }
329
- return lastResult;
330
- }
331
- const outbound = zaloPlugin.outbound!;
332
- const limit = outbound.textChunkLimit;
333
- const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text];
334
- let lastResult: Awaited<ReturnType<NonNullable<typeof outbound.sendText>>>;
335
- for (const chunk of chunks) {
336
- lastResult = await outbound.sendText!({ ...ctx, text: chunk });
337
- }
338
- return lastResult!;
339
- },
277
+ sendPayload: async (ctx) =>
278
+ await sendPayloadWithChunkedTextAndMedia({
279
+ ctx,
280
+ textChunkLimit: zaloPlugin.outbound!.textChunkLimit,
281
+ chunker: zaloPlugin.outbound!.chunker,
282
+ sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx),
283
+ sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx),
284
+ emptyResult: { channel: "zalo", messageId: "" },
285
+ }),
340
286
  sendText: async ({ to, text, accountId, cfg }) => {
341
287
  const result = await sendMessageZalo(to, text, {
342
288
  accountId: accountId ?? undefined,
343
289
  cfg: cfg,
344
290
  });
345
- return {
346
- channel: "zalo",
347
- ok: result.ok,
348
- messageId: result.messageId ?? "",
349
- error: result.error ? new Error(result.error) : undefined,
350
- };
291
+ return buildChannelSendResult("zalo", result);
351
292
  },
352
293
  sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
353
294
  const result = await sendMessageZalo(to, text, {
@@ -355,12 +296,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
355
296
  mediaUrl,
356
297
  cfg: cfg,
357
298
  });
358
- return {
359
- channel: "zalo",
360
- ok: result.ok,
361
- messageId: result.messageId ?? "",
362
- error: result.error ? new Error(result.error) : undefined,
363
- };
299
+ return buildChannelSendResult("zalo", result);
364
300
  },
365
301
  },
366
302
  status: {
@@ -377,19 +313,19 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
377
313
  probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
378
314
  buildAccountSnapshot: ({ account, runtime }) => {
379
315
  const configured = Boolean(account.token?.trim());
316
+ const base = buildBaseAccountStatusSnapshot({
317
+ account: {
318
+ accountId: account.accountId,
319
+ name: account.name,
320
+ enabled: account.enabled,
321
+ configured,
322
+ },
323
+ runtime,
324
+ });
380
325
  return {
381
- accountId: account.accountId,
382
- name: account.name,
383
- enabled: account.enabled,
384
- configured,
326
+ ...base,
385
327
  tokenSource: account.tokenSource,
386
- running: runtime?.running ?? false,
387
- lastStartAt: runtime?.lastStartAt ?? null,
388
- lastStopAt: runtime?.lastStopAt ?? null,
389
- lastError: runtime?.lastError ?? null,
390
328
  mode: account.config.webhookUrl ? "webhook" : "polling",
391
- lastInboundAt: runtime?.lastInboundAt ?? null,
392
- lastOutboundAt: runtime?.lastOutboundAt ?? null,
393
329
  dmPolicy: account.config.dmPolicy ?? "pairing",
394
330
  };
395
331
  },
@@ -398,6 +334,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
398
334
  startAccount: async (ctx) => {
399
335
  const account = ctx.account;
400
336
  const token = account.token.trim();
337
+ const mode = account.config.webhookUrl ? "webhook" : "polling";
401
338
  let zaloBotLabel = "";
402
339
  const fetcher = resolveZaloProxyFetch(account.config.proxy);
403
340
  try {
@@ -406,14 +343,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
406
343
  if (name) {
407
344
  zaloBotLabel = ` (${name})`;
408
345
  }
346
+ if (!probe.ok) {
347
+ ctx.log?.warn?.(
348
+ `[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
349
+ );
350
+ }
409
351
  ctx.setStatus({
410
352
  accountId: account.accountId,
411
353
  bot: probe.bot,
412
354
  });
413
- } catch {
414
- // ignore probe errors
355
+ } catch (err) {
356
+ ctx.log?.warn?.(
357
+ `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
358
+ );
415
359
  }
416
- ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
360
+ ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
417
361
  const { monitorZaloProvider } = await import("./monitor.js");
418
362
  return monitorZaloProvider({
419
363
  token,
@@ -1,9 +1,11 @@
1
- import { MarkdownConfigSchema } from "openclaw/plugin-sdk";
1
+ import {
2
+ AllowFromEntrySchema,
3
+ buildCatchallMultiAccountChannelSchema,
4
+ } from "openclaw/plugin-sdk/compat";
5
+ import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
2
6
  import { z } from "zod";
3
7
  import { buildSecretInputSchema } from "./secret-input.js";
4
8
 
5
- const allowFromEntry = z.union([z.string(), z.number()]);
6
-
7
9
  const zaloAccountSchema = z.object({
8
10
  name: z.string().optional(),
9
11
  enabled: z.boolean().optional(),
@@ -14,15 +16,12 @@ const zaloAccountSchema = z.object({
14
16
  webhookSecret: buildSecretInputSchema().optional(),
15
17
  webhookPath: z.string().optional(),
16
18
  dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
17
- allowFrom: z.array(allowFromEntry).optional(),
19
+ allowFrom: z.array(AllowFromEntrySchema).optional(),
18
20
  groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
19
- groupAllowFrom: z.array(allowFromEntry).optional(),
21
+ groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
20
22
  mediaMaxMb: z.number().optional(),
21
23
  proxy: z.string().optional(),
22
24
  responsePrefix: z.string().optional(),
23
25
  });
24
26
 
25
- export const ZaloConfigSchema = zaloAccountSchema.extend({
26
- accounts: z.object({}).catchall(zaloAccountSchema).optional(),
27
- defaultAccount: z.string().optional(),
28
- });
27
+ export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);
@@ -1,9 +1,9 @@
1
- import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk";
1
+ import type { GroupPolicy, SenderGroupAccessDecision } from "openclaw/plugin-sdk/zalo";
2
2
  import {
3
3
  evaluateSenderGroupAccess,
4
4
  isNormalizedSenderAllowed,
5
5
  resolveOpenProviderRuntimeGroupPolicy,
6
- } from "openclaw/plugin-sdk";
6
+ } from "openclaw/plugin-sdk/zalo";
7
7
 
8
8
  const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
9
9
 
@@ -0,0 +1,213 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
4
+ import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
5
+ import type { ResolvedZaloAccount } from "./accounts.js";
6
+
7
+ const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
8
+ const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
9
+ const getUpdatesMock = vi.fn(() => new Promise(() => {}));
10
+ const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
11
+
12
+ vi.mock("./api.js", async (importOriginal) => {
13
+ const actual = await importOriginal<typeof import("./api.js")>();
14
+ return {
15
+ ...actual,
16
+ deleteWebhook: deleteWebhookMock,
17
+ getWebhookInfo: getWebhookInfoMock,
18
+ getUpdates: getUpdatesMock,
19
+ setWebhook: setWebhookMock,
20
+ };
21
+ });
22
+
23
+ vi.mock("./runtime.js", () => ({
24
+ getZaloRuntime: () => ({
25
+ logging: {
26
+ shouldLogVerbose: () => false,
27
+ },
28
+ }),
29
+ }));
30
+
31
+ async function waitForPollingLoopStart(): Promise<void> {
32
+ await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
33
+ }
34
+
35
+ describe("monitorZaloProvider lifecycle", () => {
36
+ afterEach(() => {
37
+ vi.clearAllMocks();
38
+ setActivePluginRegistry(createEmptyPluginRegistry());
39
+ });
40
+
41
+ it("stays alive in polling mode until abort", async () => {
42
+ const { monitorZaloProvider } = await import("./monitor.js");
43
+ const abort = new AbortController();
44
+ const runtime = {
45
+ log: vi.fn<(message: string) => void>(),
46
+ error: vi.fn<(message: string) => void>(),
47
+ };
48
+ const account = {
49
+ accountId: "default",
50
+ config: {},
51
+ } as unknown as ResolvedZaloAccount;
52
+ const config = {} as OpenClawConfig;
53
+
54
+ let settled = false;
55
+ const run = monitorZaloProvider({
56
+ token: "test-token",
57
+ account,
58
+ config,
59
+ runtime,
60
+ abortSignal: abort.signal,
61
+ }).then(() => {
62
+ settled = true;
63
+ });
64
+
65
+ await waitForPollingLoopStart();
66
+
67
+ expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
68
+ expect(deleteWebhookMock).not.toHaveBeenCalled();
69
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
70
+ expect(settled).toBe(false);
71
+
72
+ abort.abort();
73
+ await run;
74
+
75
+ expect(settled).toBe(true);
76
+ expect(runtime.log).toHaveBeenCalledWith(
77
+ expect.stringContaining("Zalo provider stopped mode=polling"),
78
+ );
79
+ });
80
+
81
+ it("deletes an existing webhook before polling", async () => {
82
+ getWebhookInfoMock.mockResolvedValueOnce({
83
+ ok: true,
84
+ result: { url: "https://example.com/hooks/zalo" },
85
+ });
86
+
87
+ const { monitorZaloProvider } = await import("./monitor.js");
88
+ const abort = new AbortController();
89
+ const runtime = {
90
+ log: vi.fn<(message: string) => void>(),
91
+ error: vi.fn<(message: string) => void>(),
92
+ };
93
+ const account = {
94
+ accountId: "default",
95
+ config: {},
96
+ } as unknown as ResolvedZaloAccount;
97
+ const config = {} as OpenClawConfig;
98
+
99
+ const run = monitorZaloProvider({
100
+ token: "test-token",
101
+ account,
102
+ config,
103
+ runtime,
104
+ abortSignal: abort.signal,
105
+ });
106
+
107
+ await waitForPollingLoopStart();
108
+
109
+ expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
110
+ expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
111
+ expect(runtime.log).toHaveBeenCalledWith(
112
+ expect.stringContaining("Zalo polling mode ready (webhook disabled)"),
113
+ );
114
+
115
+ abort.abort();
116
+ await run;
117
+ });
118
+
119
+ it("continues polling when webhook inspection returns 404", async () => {
120
+ const { ZaloApiError } = await import("./api.js");
121
+ getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
122
+
123
+ const { monitorZaloProvider } = await import("./monitor.js");
124
+ const abort = new AbortController();
125
+ const runtime = {
126
+ log: vi.fn<(message: string) => void>(),
127
+ error: vi.fn<(message: string) => void>(),
128
+ };
129
+ const account = {
130
+ accountId: "default",
131
+ config: {},
132
+ } as unknown as ResolvedZaloAccount;
133
+ const config = {} as OpenClawConfig;
134
+
135
+ const run = monitorZaloProvider({
136
+ token: "test-token",
137
+ account,
138
+ config,
139
+ runtime,
140
+ abortSignal: abort.signal,
141
+ });
142
+
143
+ await waitForPollingLoopStart();
144
+
145
+ expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
146
+ expect(deleteWebhookMock).not.toHaveBeenCalled();
147
+ expect(runtime.log).toHaveBeenCalledWith(
148
+ expect.stringContaining("webhook inspection unavailable; continuing without webhook cleanup"),
149
+ );
150
+ expect(runtime.error).not.toHaveBeenCalled();
151
+
152
+ abort.abort();
153
+ await run;
154
+ });
155
+
156
+ it("waits for webhook deletion before finishing webhook shutdown", async () => {
157
+ const registry = createEmptyPluginRegistry();
158
+ setActivePluginRegistry(registry);
159
+
160
+ let resolveDeleteWebhook: (() => void) | undefined;
161
+ deleteWebhookMock.mockImplementationOnce(
162
+ () =>
163
+ new Promise((resolve) => {
164
+ resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
165
+ }),
166
+ );
167
+
168
+ const { monitorZaloProvider } = await import("./monitor.js");
169
+ const abort = new AbortController();
170
+ const runtime = {
171
+ log: vi.fn<(message: string) => void>(),
172
+ error: vi.fn<(message: string) => void>(),
173
+ };
174
+ const account = {
175
+ accountId: "default",
176
+ config: {},
177
+ } as unknown as ResolvedZaloAccount;
178
+ const config = {} as OpenClawConfig;
179
+
180
+ let settled = false;
181
+ const run = monitorZaloProvider({
182
+ token: "test-token",
183
+ account,
184
+ config,
185
+ runtime,
186
+ abortSignal: abort.signal,
187
+ useWebhook: true,
188
+ webhookUrl: "https://example.com/hooks/zalo",
189
+ webhookSecret: "supersecret", // pragma: allowlist secret
190
+ }).then(() => {
191
+ settled = true;
192
+ });
193
+
194
+ await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1));
195
+ expect(registry.httpRoutes).toHaveLength(1);
196
+
197
+ abort.abort();
198
+
199
+ await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1));
200
+ expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
201
+ expect(settled).toBe(false);
202
+ expect(registry.httpRoutes).toHaveLength(1);
203
+
204
+ resolveDeleteWebhook?.();
205
+ await run;
206
+
207
+ expect(settled).toBe(true);
208
+ expect(registry.httpRoutes).toHaveLength(0);
209
+ expect(runtime.log).toHaveBeenCalledWith(
210
+ expect.stringContaining("Zalo provider stopped mode=webhook"),
211
+ );
212
+ });
213
+ });