@openclaw/zalo 2026.3.7 → 2026.3.10

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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.10
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.9
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
15
+ ## 2026.3.8-beta.1
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
21
+ ## 2026.3.8
22
+
23
+ ### Changes
24
+
25
+ - Version alignment with core OpenClaw release numbers.
26
+
3
27
  ## 2026.3.7
4
28
 
5
29
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.3.7",
3
+ "version": "2026.3.10",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { deleteWebhook, getWebhookInfo, sendChatAction, type ZaloFetch } from "./api.js";
3
+
4
+ describe("Zalo API request methods", () => {
5
+ it("uses POST for getWebhookInfo", async () => {
6
+ const fetcher = vi.fn<ZaloFetch>(
7
+ async () => new Response(JSON.stringify({ ok: true, result: {} })),
8
+ );
9
+
10
+ await getWebhookInfo("test-token", fetcher);
11
+
12
+ expect(fetcher).toHaveBeenCalledTimes(1);
13
+ const [, init] = fetcher.mock.calls[0] ?? [];
14
+ expect(init?.method).toBe("POST");
15
+ expect(init?.headers).toEqual({ "Content-Type": "application/json" });
16
+ });
17
+
18
+ it("keeps POST for deleteWebhook", async () => {
19
+ const fetcher = vi.fn<ZaloFetch>(
20
+ async () => new Response(JSON.stringify({ ok: true, result: {} })),
21
+ );
22
+
23
+ await deleteWebhook("test-token", fetcher);
24
+
25
+ expect(fetcher).toHaveBeenCalledTimes(1);
26
+ const [, init] = fetcher.mock.calls[0] ?? [];
27
+ expect(init?.method).toBe("POST");
28
+ expect(init?.headers).toEqual({ "Content-Type": "application/json" });
29
+ });
30
+
31
+ it("aborts sendChatAction when the typing timeout elapses", async () => {
32
+ vi.useFakeTimers();
33
+ try {
34
+ const fetcher = vi.fn<ZaloFetch>(
35
+ (_, init) =>
36
+ new Promise<Response>((_, reject) => {
37
+ init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
38
+ once: true,
39
+ });
40
+ }),
41
+ );
42
+
43
+ const promise = sendChatAction(
44
+ "test-token",
45
+ {
46
+ chat_id: "chat-123",
47
+ action: "typing",
48
+ },
49
+ fetcher,
50
+ 25,
51
+ );
52
+ const rejected = expect(promise).rejects.toThrow("aborted");
53
+
54
+ await vi.advanceTimersByTimeAsync(25);
55
+
56
+ await rejected;
57
+ const [, init] = fetcher.mock.calls[0] ?? [];
58
+ expect(init?.signal?.aborted).toBe(true);
59
+ } finally {
60
+ vi.useRealTimers();
61
+ }
62
+ });
63
+ });
package/src/api.ts CHANGED
@@ -58,11 +58,22 @@ export type ZaloSendPhotoParams = {
58
58
  caption?: string;
59
59
  };
60
60
 
61
+ export type ZaloSendChatActionParams = {
62
+ chat_id: string;
63
+ action: "typing" | "upload_photo";
64
+ };
65
+
61
66
  export type ZaloSetWebhookParams = {
62
67
  url: string;
63
68
  secret_token: string;
64
69
  };
65
70
 
71
+ export type ZaloWebhookInfo = {
72
+ url?: string;
73
+ updated_at?: number;
74
+ has_custom_certificate?: boolean;
75
+ };
76
+
66
77
  export type ZaloGetUpdatesParams = {
67
78
  /** Timeout in seconds (passed as string to API) */
68
79
  timeout?: number;
@@ -161,6 +172,21 @@ export async function sendPhoto(
161
172
  return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
162
173
  }
163
174
 
175
+ /**
176
+ * Send a temporary chat action such as typing.
177
+ */
178
+ export async function sendChatAction(
179
+ token: string,
180
+ params: ZaloSendChatActionParams,
181
+ fetcher?: ZaloFetch,
182
+ timeoutMs?: number,
183
+ ): Promise<ZaloApiResponse<boolean>> {
184
+ return callZaloApi<boolean>("sendChatAction", token, params, {
185
+ timeoutMs,
186
+ fetch: fetcher,
187
+ });
188
+ }
189
+
164
190
  /**
165
191
  * Get updates using long polling (dev/testing only)
166
192
  * Note: Zalo returns a single update per call, not an array like Telegram
@@ -183,8 +209,8 @@ export async function setWebhook(
183
209
  token: string,
184
210
  params: ZaloSetWebhookParams,
185
211
  fetcher?: ZaloFetch,
186
- ): Promise<ZaloApiResponse<boolean>> {
187
- return callZaloApi<boolean>("setWebhook", token, params, { fetch: fetcher });
212
+ ): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
213
+ return callZaloApi<ZaloWebhookInfo>("setWebhook", token, params, { fetch: fetcher });
188
214
  }
189
215
 
190
216
  /**
@@ -193,8 +219,12 @@ export async function setWebhook(
193
219
  export async function deleteWebhook(
194
220
  token: string,
195
221
  fetcher?: ZaloFetch,
196
- ): Promise<ZaloApiResponse<boolean>> {
197
- return callZaloApi<boolean>("deleteWebhook", token, undefined, { fetch: fetcher });
222
+ timeoutMs?: number,
223
+ ): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
224
+ return callZaloApi<ZaloWebhookInfo>("deleteWebhook", token, undefined, {
225
+ timeoutMs,
226
+ fetch: fetcher,
227
+ });
198
228
  }
199
229
 
200
230
  /**
@@ -203,6 +233,6 @@ export async function deleteWebhook(
203
233
  export async function getWebhookInfo(
204
234
  token: string,
205
235
  fetcher?: ZaloFetch,
206
- ): Promise<ZaloApiResponse<{ url?: string; has_custom_certificate?: boolean }>> {
207
- return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
236
+ ): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
237
+ return callZaloApi<ZaloWebhookInfo>("getWebhookInfo", token, undefined, { fetch: fetcher });
208
238
  }
@@ -1,5 +1,9 @@
1
1
  import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ installSendPayloadContractSuite,
5
+ primeSendMock,
6
+ } from "../../../src/test-utils/send-payload-contract.js";
3
7
  import { zaloPlugin } from "./channel.js";
4
8
 
5
9
  vi.mock("./send.js", () => ({
@@ -25,78 +29,16 @@ describe("zaloPlugin outbound sendPayload", () => {
25
29
  mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
26
30
  });
27
31
 
28
- it("text-only delegates to sendText", async () => {
29
- mockedSend.mockResolvedValue({ ok: true, messageId: "zl-t1" });
30
-
31
- const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: "hello" }));
32
-
33
- expect(mockedSend).toHaveBeenCalledWith("123456789", "hello", expect.any(Object));
34
- expect(result).toMatchObject({ channel: "zalo", messageId: "zl-t1" });
35
- });
36
-
37
- it("single media delegates to sendMedia", async () => {
38
- mockedSend.mockResolvedValue({ ok: true, messageId: "zl-m1" });
39
-
40
- const result = await zaloPlugin.outbound!.sendPayload!(
41
- baseCtx({ text: "cap", mediaUrl: "https://example.com/a.jpg" }),
42
- );
43
-
44
- expect(mockedSend).toHaveBeenCalledWith(
45
- "123456789",
46
- "cap",
47
- expect.objectContaining({ mediaUrl: "https://example.com/a.jpg" }),
48
- );
49
- expect(result).toMatchObject({ channel: "zalo" });
50
- });
51
-
52
- it("multi-media iterates URLs with caption on first", async () => {
53
- mockedSend
54
- .mockResolvedValueOnce({ ok: true, messageId: "zl-1" })
55
- .mockResolvedValueOnce({ ok: true, messageId: "zl-2" });
56
-
57
- const result = await zaloPlugin.outbound!.sendPayload!(
58
- baseCtx({
59
- text: "caption",
60
- mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"],
61
- }),
62
- );
63
-
64
- expect(mockedSend).toHaveBeenCalledTimes(2);
65
- expect(mockedSend).toHaveBeenNthCalledWith(
66
- 1,
67
- "123456789",
68
- "caption",
69
- expect.objectContaining({ mediaUrl: "https://example.com/1.jpg" }),
70
- );
71
- expect(mockedSend).toHaveBeenNthCalledWith(
72
- 2,
73
- "123456789",
74
- "",
75
- expect.objectContaining({ mediaUrl: "https://example.com/2.jpg" }),
76
- );
77
- expect(result).toMatchObject({ channel: "zalo", messageId: "zl-2" });
78
- });
79
-
80
- it("empty payload returns no-op", async () => {
81
- const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({}));
82
-
83
- expect(mockedSend).not.toHaveBeenCalled();
84
- expect(result).toEqual({ channel: "zalo", messageId: "" });
85
- });
86
-
87
- it("chunking splits long text", async () => {
88
- mockedSend
89
- .mockResolvedValueOnce({ ok: true, messageId: "zl-c1" })
90
- .mockResolvedValueOnce({ ok: true, messageId: "zl-c2" });
91
-
92
- const longText = "a".repeat(3000);
93
- const result = await zaloPlugin.outbound!.sendPayload!(baseCtx({ text: longText }));
94
-
95
- // textChunkLimit is 2000 with chunkTextForOutbound, so it should split
96
- expect(mockedSend.mock.calls.length).toBeGreaterThanOrEqual(2);
97
- for (const call of mockedSend.mock.calls) {
98
- expect((call[1] as string).length).toBeLessThanOrEqual(2000);
99
- }
100
- expect(result).toMatchObject({ channel: "zalo" });
32
+ installSendPayloadContractSuite({
33
+ channel: "zalo",
34
+ chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
35
+ createHarness: ({ payload, sendResults }) => {
36
+ primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults);
37
+ return {
38
+ run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)),
39
+ sendMock: mockedSend,
40
+ to: "123456789",
41
+ };
42
+ },
101
43
  });
102
44
  });
@@ -0,0 +1,100 @@
1
+ import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/zalo";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { createStartAccountContext } from "../../test-utils/start-account-context.js";
4
+ import type { ResolvedZaloAccount } from "./accounts.js";
5
+
6
+ const hoisted = vi.hoisted(() => ({
7
+ monitorZaloProvider: vi.fn(),
8
+ probeZalo: vi.fn(async () => ({
9
+ ok: false as const,
10
+ error: "probe failed",
11
+ elapsedMs: 1,
12
+ })),
13
+ }));
14
+
15
+ vi.mock("./monitor.js", async () => {
16
+ const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
17
+ return {
18
+ ...actual,
19
+ monitorZaloProvider: hoisted.monitorZaloProvider,
20
+ };
21
+ });
22
+
23
+ vi.mock("./probe.js", async () => {
24
+ const actual = await vi.importActual<typeof import("./probe.js")>("./probe.js");
25
+ return {
26
+ ...actual,
27
+ probeZalo: hoisted.probeZalo,
28
+ };
29
+ });
30
+
31
+ import { zaloPlugin } from "./channel.js";
32
+
33
+ function buildAccount(): ResolvedZaloAccount {
34
+ return {
35
+ accountId: "default",
36
+ enabled: true,
37
+ token: "test-token",
38
+ tokenSource: "config",
39
+ config: {},
40
+ };
41
+ }
42
+
43
+ describe("zaloPlugin gateway.startAccount", () => {
44
+ afterEach(() => {
45
+ vi.clearAllMocks();
46
+ });
47
+
48
+ it("keeps startAccount pending until abort", async () => {
49
+ hoisted.monitorZaloProvider.mockImplementationOnce(
50
+ async ({ abortSignal }: { abortSignal: AbortSignal }) =>
51
+ await new Promise<void>((resolve) => {
52
+ if (abortSignal.aborted) {
53
+ resolve();
54
+ return;
55
+ }
56
+ abortSignal.addEventListener("abort", () => resolve(), { once: true });
57
+ }),
58
+ );
59
+
60
+ const patches: ChannelAccountSnapshot[] = [];
61
+ const abort = new AbortController();
62
+ const task = zaloPlugin.gateway!.startAccount!(
63
+ createStartAccountContext({
64
+ account: buildAccount(),
65
+ abortSignal: abort.signal,
66
+ statusPatchSink: (next) => patches.push({ ...next }),
67
+ }),
68
+ );
69
+
70
+ let settled = false;
71
+ void task.then(() => {
72
+ settled = true;
73
+ });
74
+
75
+ await vi.waitFor(() => {
76
+ expect(hoisted.probeZalo).toHaveBeenCalledOnce();
77
+ expect(hoisted.monitorZaloProvider).toHaveBeenCalledOnce();
78
+ });
79
+
80
+ expect(settled).toBe(false);
81
+ expect(patches).toContainEqual(
82
+ expect.objectContaining({
83
+ accountId: "default",
84
+ }),
85
+ );
86
+
87
+ abort.abort();
88
+ await task;
89
+
90
+ expect(settled).toBe(true);
91
+ expect(hoisted.monitorZaloProvider).toHaveBeenCalledWith(
92
+ expect.objectContaining({
93
+ token: "test-token",
94
+ account: expect.objectContaining({ accountId: "default" }),
95
+ abortSignal: abort.signal,
96
+ useWebhook: false,
97
+ }),
98
+ );
99
+ });
100
+ });
package/src/channel.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  buildAccountScopedDmSecurityPolicy,
3
- collectOpenProviderGroupPolicyWarnings,
4
3
  buildOpenGroupPolicyRestrictSendersWarning,
5
4
  buildOpenGroupPolicyWarning,
5
+ collectOpenProviderGroupPolicyWarnings,
6
+ createAccountStatusSink,
6
7
  mapAllowFromEntries,
7
8
  } from "openclaw/plugin-sdk/compat";
8
9
  import type {
@@ -334,6 +335,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
334
335
  startAccount: async (ctx) => {
335
336
  const account = ctx.account;
336
337
  const token = account.token.trim();
338
+ const mode = account.config.webhookUrl ? "webhook" : "polling";
337
339
  let zaloBotLabel = "";
338
340
  const fetcher = resolveZaloProxyFetch(account.config.proxy);
339
341
  try {
@@ -342,14 +344,25 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
342
344
  if (name) {
343
345
  zaloBotLabel = ` (${name})`;
344
346
  }
347
+ if (!probe.ok) {
348
+ ctx.log?.warn?.(
349
+ `[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
350
+ );
351
+ }
345
352
  ctx.setStatus({
346
353
  accountId: account.accountId,
347
354
  bot: probe.bot,
348
355
  });
349
- } catch {
350
- // ignore probe errors
356
+ } catch (err) {
357
+ ctx.log?.warn?.(
358
+ `[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
359
+ );
351
360
  }
352
- ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
361
+ const statusSink = createAccountStatusSink({
362
+ accountId: ctx.accountId,
363
+ setStatus: ctx.setStatus,
364
+ });
365
+ ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
353
366
  const { monitorZaloProvider } = await import("./monitor.js");
354
367
  return monitorZaloProvider({
355
368
  token,
@@ -362,7 +375,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
362
375
  webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
363
376
  webhookPath: account.config.webhookPath,
364
377
  fetcher,
365
- statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
378
+ statusSink,
366
379
  });
367
380
  },
368
381
  },
@@ -1,9 +1,13 @@
1
+ import {
2
+ AllowFromListSchema,
3
+ buildCatchallMultiAccountChannelSchema,
4
+ DmPolicySchema,
5
+ GroupPolicySchema,
6
+ } from "openclaw/plugin-sdk/compat";
1
7
  import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
2
8
  import { z } from "zod";
3
9
  import { buildSecretInputSchema } from "./secret-input.js";
4
10
 
5
- const allowFromEntry = z.union([z.string(), z.number()]);
6
-
7
11
  const zaloAccountSchema = z.object({
8
12
  name: z.string().optional(),
9
13
  enabled: z.boolean().optional(),
@@ -13,16 +17,13 @@ const zaloAccountSchema = z.object({
13
17
  webhookUrl: z.string().optional(),
14
18
  webhookSecret: buildSecretInputSchema().optional(),
15
19
  webhookPath: z.string().optional(),
16
- dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
17
- allowFrom: z.array(allowFromEntry).optional(),
18
- groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
19
- groupAllowFrom: z.array(allowFromEntry).optional(),
20
+ dmPolicy: DmPolicySchema.optional(),
21
+ allowFrom: AllowFromListSchema,
22
+ groupPolicy: GroupPolicySchema.optional(),
23
+ groupAllowFrom: AllowFromListSchema,
20
24
  mediaMaxMb: z.number().optional(),
21
25
  proxy: z.string().optional(),
22
26
  responsePrefix: z.string().optional(),
23
27
  });
24
28
 
25
- export const ZaloConfigSchema = zaloAccountSchema.extend({
26
- accounts: z.object({}).catchall(zaloAccountSchema).optional(),
27
- defaultAccount: z.string().optional(),
28
- });
29
+ export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);
@@ -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
+ });
package/src/monitor.ts CHANGED
@@ -5,9 +5,11 @@ import type {
5
5
  OutboundReplyPayload,
6
6
  } from "openclaw/plugin-sdk/zalo";
7
7
  import {
8
+ createTypingCallbacks,
8
9
  createScopedPairingAccess,
9
10
  createReplyPrefixOptions,
10
11
  issuePairingChallenge,
12
+ logTypingFailure,
11
13
  resolveDirectDmAuthorizationOutcome,
12
14
  resolveSenderCommandAuthorizationWithRuntime,
13
15
  resolveOutboundMediaUrls,
@@ -15,13 +17,16 @@ import {
15
17
  resolveInboundRouteEnvelopeBuilderWithRuntime,
16
18
  sendMediaWithLeadingCaption,
17
19
  resolveWebhookPath,
20
+ waitForAbortSignal,
18
21
  warnMissingProviderGroupPolicyFallbackOnce,
19
22
  } from "openclaw/plugin-sdk/zalo";
20
23
  import type { ResolvedZaloAccount } from "./accounts.js";
21
24
  import {
22
25
  ZaloApiError,
23
26
  deleteWebhook,
27
+ getWebhookInfo,
24
28
  getUpdates,
29
+ sendChatAction,
25
30
  sendMessage,
26
31
  sendPhoto,
27
32
  setWebhook,
@@ -64,15 +69,34 @@ export type ZaloMonitorOptions = {
64
69
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
65
70
  };
66
71
 
67
- export type ZaloMonitorResult = {
68
- stop: () => void;
69
- };
70
-
71
72
  const ZALO_TEXT_LIMIT = 2000;
72
73
  const DEFAULT_MEDIA_MAX_MB = 5;
74
+ const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
75
+ const ZALO_TYPING_TIMEOUT_MS = 5_000;
73
76
 
74
77
  type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
75
78
 
79
+ function formatZaloError(error: unknown): string {
80
+ if (error instanceof Error) {
81
+ return error.stack ?? `${error.name}: ${error.message}`;
82
+ }
83
+ return String(error);
84
+ }
85
+
86
+ function describeWebhookTarget(rawUrl: string): string {
87
+ try {
88
+ const parsed = new URL(rawUrl);
89
+ return `${parsed.origin}${parsed.pathname}`;
90
+ } catch {
91
+ return rawUrl;
92
+ }
93
+ }
94
+
95
+ function normalizeWebhookUrl(url: string | undefined): string | undefined {
96
+ const trimmed = url?.trim();
97
+ return trimmed ? trimmed : undefined;
98
+ }
99
+
76
100
  function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
77
101
  if (core.logging.shouldLogVerbose()) {
78
102
  runtime.log?.(`[zalo] ${message}`);
@@ -151,6 +175,8 @@ function startPollingLoop(params: {
151
175
  } = params;
152
176
  const pollTimeout = 30;
153
177
 
178
+ runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
179
+
154
180
  const poll = async () => {
155
181
  if (isStopped() || abortSignal.aborted) {
156
182
  return;
@@ -176,7 +202,7 @@ function startPollingLoop(params: {
176
202
  if (err instanceof ZaloApiError && err.isPollingTimeout) {
177
203
  // no updates
178
204
  } else if (!isStopped() && !abortSignal.aborted) {
179
- runtime.error?.(`[${account.accountId}] Zalo polling error: ${String(err)}`);
205
+ runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
180
206
  await new Promise((resolve) => setTimeout(resolve, 5000));
181
207
  }
182
208
  }
@@ -522,12 +548,35 @@ async function processMessageWithPipeline(params: {
522
548
  channel: "zalo",
523
549
  accountId: account.accountId,
524
550
  });
551
+ const typingCallbacks = createTypingCallbacks({
552
+ start: async () => {
553
+ await sendChatAction(
554
+ token,
555
+ {
556
+ chat_id: chatId,
557
+ action: "typing",
558
+ },
559
+ fetcher,
560
+ ZALO_TYPING_TIMEOUT_MS,
561
+ );
562
+ },
563
+ onStartError: (err) => {
564
+ logTypingFailure({
565
+ log: (message) => logVerbose(core, runtime, message),
566
+ channel: "zalo",
567
+ action: "start",
568
+ target: chatId,
569
+ error: err,
570
+ });
571
+ },
572
+ });
525
573
 
526
574
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
527
575
  ctx: ctxPayload,
528
576
  cfg: config,
529
577
  dispatcherOptions: {
530
578
  ...prefixOptions,
579
+ typingCallbacks,
531
580
  deliver: async (payload) => {
532
581
  await deliverZaloReply({
533
582
  payload,
@@ -567,7 +616,6 @@ async function deliverZaloReply(params: {
567
616
  const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
568
617
  const tableMode = params.tableMode ?? "code";
569
618
  const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
570
-
571
619
  const sentMedia = await sendMediaWithLeadingCaption({
572
620
  mediaUrls: resolveOutboundMediaUrls(payload),
573
621
  caption: text,
@@ -597,7 +645,7 @@ async function deliverZaloReply(params: {
597
645
  }
598
646
  }
599
647
 
600
- export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<ZaloMonitorResult> {
648
+ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
601
649
  const {
602
650
  token,
603
651
  account,
@@ -615,78 +663,140 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
615
663
  const core = getZaloRuntime();
616
664
  const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
617
665
  const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
666
+ const mode = useWebhook ? "webhook" : "polling";
618
667
 
619
668
  let stopped = false;
620
669
  const stopHandlers: Array<() => void> = [];
670
+ let cleanupWebhook: (() => Promise<void>) | undefined;
621
671
 
622
672
  const stop = () => {
673
+ if (stopped) {
674
+ return;
675
+ }
623
676
  stopped = true;
624
677
  for (const handler of stopHandlers) {
625
678
  handler();
626
679
  }
627
680
  };
628
681
 
629
- if (useWebhook) {
630
- if (!webhookUrl || !webhookSecret) {
631
- throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
632
- }
633
- if (!webhookUrl.startsWith("https://")) {
634
- throw new Error("Zalo webhook URL must use HTTPS");
635
- }
636
- if (webhookSecret.length < 8 || webhookSecret.length > 256) {
637
- throw new Error("Zalo webhook secret must be 8-256 characters");
638
- }
682
+ runtime.log?.(
683
+ `[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`,
684
+ );
685
+
686
+ try {
687
+ if (useWebhook) {
688
+ if (!webhookUrl || !webhookSecret) {
689
+ throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
690
+ }
691
+ if (!webhookUrl.startsWith("https://")) {
692
+ throw new Error("Zalo webhook URL must use HTTPS");
693
+ }
694
+ if (webhookSecret.length < 8 || webhookSecret.length > 256) {
695
+ throw new Error("Zalo webhook secret must be 8-256 characters");
696
+ }
697
+
698
+ const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
699
+ if (!path) {
700
+ throw new Error("Zalo webhookPath could not be derived");
701
+ }
702
+
703
+ runtime.log?.(
704
+ `[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(webhookUrl)}`,
705
+ );
706
+ await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
707
+ let webhookCleanupPromise: Promise<void> | undefined;
708
+ cleanupWebhook = async () => {
709
+ if (!webhookCleanupPromise) {
710
+ webhookCleanupPromise = (async () => {
711
+ runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
712
+ try {
713
+ await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
714
+ runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
715
+ } catch (err) {
716
+ const detail =
717
+ err instanceof Error && err.name === "AbortError"
718
+ ? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms`
719
+ : formatZaloError(err);
720
+ runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
721
+ }
722
+ })();
723
+ }
724
+ await webhookCleanupPromise;
725
+ };
726
+ runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
639
727
 
640
- const path = resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: null });
641
- if (!path) {
642
- throw new Error("Zalo webhookPath could not be derived");
728
+ const unregister = registerZaloWebhookTarget({
729
+ token,
730
+ account,
731
+ config,
732
+ runtime,
733
+ core,
734
+ path,
735
+ secret: webhookSecret,
736
+ statusSink: (patch) => statusSink?.(patch),
737
+ mediaMaxMb: effectiveMediaMaxMb,
738
+ fetcher,
739
+ });
740
+ stopHandlers.push(unregister);
741
+ await waitForAbortSignal(abortSignal);
742
+ return;
643
743
  }
644
744
 
645
- await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
745
+ runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
746
+ try {
747
+ try {
748
+ const currentWebhookUrl = normalizeWebhookUrl(
749
+ (await getWebhookInfo(token, fetcher)).result?.url,
750
+ );
751
+ if (!currentWebhookUrl) {
752
+ runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
753
+ } else {
754
+ runtime.log?.(
755
+ `[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
756
+ );
757
+ await deleteWebhook(token, fetcher);
758
+ runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
759
+ }
760
+ } catch (err) {
761
+ if (err instanceof ZaloApiError && err.errorCode === 404) {
762
+ // Some Zalo environments do not expose webhook inspection for polling bots.
763
+ runtime.log?.(
764
+ `[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
765
+ );
766
+ } else {
767
+ throw err;
768
+ }
769
+ }
770
+ } catch (err) {
771
+ runtime.error?.(
772
+ `[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
773
+ );
774
+ }
646
775
 
647
- const unregister = registerZaloWebhookTarget({
776
+ startPollingLoop({
648
777
  token,
649
778
  account,
650
779
  config,
651
780
  runtime,
652
781
  core,
653
- path,
654
- secret: webhookSecret,
655
- statusSink: (patch) => statusSink?.(patch),
782
+ abortSignal,
783
+ isStopped: () => stopped,
656
784
  mediaMaxMb: effectiveMediaMaxMb,
785
+ statusSink,
657
786
  fetcher,
658
787
  });
659
- stopHandlers.push(unregister);
660
- abortSignal.addEventListener(
661
- "abort",
662
- () => {
663
- void deleteWebhook(token, fetcher).catch(() => {});
664
- },
665
- { once: true },
666
- );
667
- return { stop };
668
- }
669
788
 
670
- try {
671
- await deleteWebhook(token, fetcher);
672
- } catch {
673
- // ignore
789
+ await waitForAbortSignal(abortSignal);
790
+ } catch (err) {
791
+ runtime.error?.(
792
+ `[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`,
793
+ );
794
+ throw err;
795
+ } finally {
796
+ await cleanupWebhook?.();
797
+ stop();
798
+ runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
674
799
  }
675
-
676
- startPollingLoop({
677
- token,
678
- account,
679
- config,
680
- runtime,
681
- core,
682
- abortSignal,
683
- isStopped: () => stopped,
684
- mediaMaxMb: effectiveMediaMaxMb,
685
- statusSink,
686
- fetcher,
687
- });
688
-
689
- return { stop };
690
800
  }
691
801
 
692
802
  export const __testing = {
package/src/onboarding.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  mergeAllowFromEntries,
13
13
  normalizeAccountId,
14
14
  promptSingleChannelSecretInput,
15
+ runSingleChannelSecretStep,
15
16
  resolveAccountIdForConfigure,
16
17
  setTopLevelChannelDmPolicyWithAllowFrom,
17
18
  } from "openclaw/plugin-sdk/zalo";
@@ -255,80 +256,66 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
255
256
  const hasConfigToken = Boolean(
256
257
  hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
257
258
  );
258
- const tokenPromptState = buildSingleChannelSecretPromptState({
259
- accountConfigured,
260
- hasConfigToken,
261
- allowEnv,
262
- envValue: process.env.ZALO_BOT_TOKEN,
263
- });
264
-
265
- let token: SecretInput | null = null;
266
- if (!accountConfigured) {
267
- await noteZaloTokenHelp(prompter);
268
- }
269
- const tokenResult = await promptSingleChannelSecretInput({
259
+ const tokenStep = await runSingleChannelSecretStep({
270
260
  cfg: next,
271
261
  prompter,
272
262
  providerHint: "zalo",
273
263
  credentialLabel: "bot token",
274
- accountConfigured: tokenPromptState.accountConfigured,
275
- canUseEnv: tokenPromptState.canUseEnv,
276
- hasConfigToken: tokenPromptState.hasConfigToken,
264
+ accountConfigured,
265
+ hasConfigToken,
266
+ allowEnv,
267
+ envValue: process.env.ZALO_BOT_TOKEN,
277
268
  envPrompt: "ZALO_BOT_TOKEN detected. Use env var?",
278
269
  keepPrompt: "Zalo token already configured. Keep it?",
279
270
  inputPrompt: "Enter Zalo bot token",
280
271
  preferredEnvVar: "ZALO_BOT_TOKEN",
281
- });
282
- if (tokenResult.action === "set") {
283
- token = tokenResult.value;
284
- }
285
- if (tokenResult.action === "use-env" && zaloAccountId === DEFAULT_ACCOUNT_ID) {
286
- next = {
287
- ...next,
288
- channels: {
289
- ...next.channels,
290
- zalo: {
291
- ...next.channels?.zalo,
292
- enabled: true,
293
- },
294
- },
295
- } as OpenClawConfig;
296
- }
297
-
298
- if (token) {
299
- if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
300
- next = {
301
- ...next,
302
- channels: {
303
- ...next.channels,
304
- zalo: {
305
- ...next.channels?.zalo,
306
- enabled: true,
307
- botToken: token,
308
- },
309
- },
310
- } as OpenClawConfig;
311
- } else {
312
- next = {
313
- ...next,
314
- channels: {
315
- ...next.channels,
316
- zalo: {
317
- ...next.channels?.zalo,
318
- enabled: true,
319
- accounts: {
320
- ...next.channels?.zalo?.accounts,
321
- [zaloAccountId]: {
322
- ...next.channels?.zalo?.accounts?.[zaloAccountId],
272
+ onMissingConfigured: async () => await noteZaloTokenHelp(prompter),
273
+ applyUseEnv: async (cfg) =>
274
+ zaloAccountId === DEFAULT_ACCOUNT_ID
275
+ ? ({
276
+ ...cfg,
277
+ channels: {
278
+ ...cfg.channels,
279
+ zalo: {
280
+ ...cfg.channels?.zalo,
323
281
  enabled: true,
324
- botToken: token,
325
282
  },
326
283
  },
327
- },
328
- },
329
- } as OpenClawConfig;
330
- }
331
- }
284
+ } as OpenClawConfig)
285
+ : cfg,
286
+ applySet: async (cfg, value) =>
287
+ zaloAccountId === DEFAULT_ACCOUNT_ID
288
+ ? ({
289
+ ...cfg,
290
+ channels: {
291
+ ...cfg.channels,
292
+ zalo: {
293
+ ...cfg.channels?.zalo,
294
+ enabled: true,
295
+ botToken: value,
296
+ },
297
+ },
298
+ } as OpenClawConfig)
299
+ : ({
300
+ ...cfg,
301
+ channels: {
302
+ ...cfg.channels,
303
+ zalo: {
304
+ ...cfg.channels?.zalo,
305
+ enabled: true,
306
+ accounts: {
307
+ ...cfg.channels?.zalo?.accounts,
308
+ [zaloAccountId]: {
309
+ ...cfg.channels?.zalo?.accounts?.[zaloAccountId],
310
+ enabled: true,
311
+ botToken: value,
312
+ },
313
+ },
314
+ },
315
+ },
316
+ } as OpenClawConfig),
317
+ });
318
+ next = tokenStep.cfg;
332
319
 
333
320
  const wantsWebhook = await prompter.confirm({
334
321
  message: "Use webhook mode for Zalo?",
package/src/runtime.ts CHANGED
@@ -1,14 +1,6 @@
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
1
2
  import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
2
3
 
3
- let runtime: PluginRuntime | null = null;
4
-
5
- export function setZaloRuntime(next: PluginRuntime): void {
6
- runtime = next;
7
- }
8
-
9
- export function getZaloRuntime(): PluginRuntime {
10
- if (!runtime) {
11
- throw new Error("Zalo runtime not initialized");
12
- }
13
- return runtime;
14
- }
4
+ const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
5
+ createPluginRuntimeStore<PluginRuntime>("Zalo runtime not initialized");
6
+ export { getZaloRuntime, setZaloRuntime };
package/src/token.test.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { describe, expect, it } from "vitest";
2
5
  import { resolveZaloToken } from "./token.js";
3
6
  import type { ZaloConfig } from "./types.js";
@@ -55,4 +58,20 @@ describe("resolveZaloToken", () => {
55
58
  expect(res.token).toBe("work-token");
56
59
  expect(res.source).toBe("config");
57
60
  });
61
+
62
+ it.runIf(process.platform !== "win32")("rejects symlinked token files", () => {
63
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
64
+ const tokenFile = path.join(dir, "token.txt");
65
+ const tokenLink = path.join(dir, "token-link.txt");
66
+ fs.writeFileSync(tokenFile, "file-token\n", "utf8");
67
+ fs.symlinkSync(tokenFile, tokenLink);
68
+
69
+ const cfg = {
70
+ tokenFile: tokenLink,
71
+ } as ZaloConfig;
72
+ const res = resolveZaloToken(cfg);
73
+ expect(res.token).toBe("");
74
+ expect(res.source).toBe("none");
75
+ fs.rmSync(dir, { recursive: true, force: true });
76
+ });
58
77
  });
package/src/token.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
3
3
  import type { BaseTokenResolution } from "openclaw/plugin-sdk/zalo";
4
4
  import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
5
5
  import type { ZaloConfig } from "./types.js";
@@ -9,16 +9,7 @@ export type ZaloTokenResolution = BaseTokenResolution & {
9
9
  };
10
10
 
11
11
  function readTokenFromFile(tokenFile: string | undefined): string {
12
- const trimmedPath = tokenFile?.trim();
13
- if (!trimmedPath) {
14
- return "";
15
- }
16
- try {
17
- return readFileSync(trimmedPath, "utf8").trim();
18
- } catch {
19
- // ignore read failures
20
- return "";
21
- }
12
+ return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? "";
22
13
  }
23
14
 
24
15
  export function resolveZaloToken(