@openclaw/zalo 2026.3.7 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.3.8-beta.1
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
9
+ ## 2026.3.8
10
+
11
+ ### Changes
12
+
13
+ - Version alignment with core OpenClaw release numbers.
14
+
3
15
  ## 2026.3.7
4
16
 
5
17
  ### 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.8-beta.1",
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
  }
@@ -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
@@ -334,6 +334,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
334
334
  startAccount: async (ctx) => {
335
335
  const account = ctx.account;
336
336
  const token = account.token.trim();
337
+ const mode = account.config.webhookUrl ? "webhook" : "polling";
337
338
  let zaloBotLabel = "";
338
339
  const fetcher = resolveZaloProxyFetch(account.config.proxy);
339
340
  try {
@@ -342,14 +343,21 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
342
343
  if (name) {
343
344
  zaloBotLabel = ` (${name})`;
344
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
+ }
345
351
  ctx.setStatus({
346
352
  accountId: account.accountId,
347
353
  bot: probe.bot,
348
354
  });
349
- } catch {
350
- // 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
+ );
351
359
  }
352
- ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
360
+ ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
353
361
  const { monitorZaloProvider } = await import("./monitor.js");
354
362
  return monitorZaloProvider({
355
363
  token,
@@ -1,9 +1,11 @@
1
+ import {
2
+ AllowFromEntrySchema,
3
+ buildCatchallMultiAccountChannelSchema,
4
+ } from "openclaw/plugin-sdk/compat";
1
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);
@@ -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/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 };