@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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
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
+
15
+ ## 2026.3.7
16
+
17
+ ### Changes
18
+
19
+ - Version alignment with core OpenClaw release numbers.
20
+
21
+ ## 2026.3.3
22
+
23
+ ### Changes
24
+
25
+ - Version alignment with core OpenClaw release numbers.
26
+
3
27
  ## 2026.3.2
4
28
 
5
29
  ### Changes
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/zalo";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/zalo";
3
3
  import { zaloDock, zaloPlugin } from "./src/channel.js";
4
4
  import { setZaloRuntime } from "./src/runtime.js";
5
5
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@openclaw/zalo",
3
- "version": "2026.3.2",
3
+ "version": "2026.3.8-beta.1",
4
4
  "description": "OpenClaw Zalo channel plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "undici": "7.22.0"
7
+ "undici": "7.22.0",
8
+ "zod": "^4.3.6"
8
9
  },
9
10
  "openclaw": {
10
11
  "extensions": [
package/src/accounts.ts CHANGED
@@ -1,45 +1,13 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
- import {
3
- DEFAULT_ACCOUNT_ID,
4
- normalizeAccountId,
5
- normalizeOptionalAccountId,
6
- } from "openclaw/plugin-sdk/account-id";
1
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/zalo";
7
3
  import { resolveZaloToken } from "./token.js";
8
4
  import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
9
5
 
10
6
  export type { ResolvedZaloAccount };
11
7
 
12
- function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
13
- const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
14
- if (!accounts || typeof accounts !== "object") {
15
- return [];
16
- }
17
- return Object.keys(accounts).filter(Boolean);
18
- }
19
-
20
- export function listZaloAccountIds(cfg: OpenClawConfig): string[] {
21
- const ids = listConfiguredAccountIds(cfg);
22
- if (ids.length === 0) {
23
- return [DEFAULT_ACCOUNT_ID];
24
- }
25
- return ids.toSorted((a, b) => a.localeCompare(b));
26
- }
27
-
28
- export function resolveDefaultZaloAccountId(cfg: OpenClawConfig): string {
29
- const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
30
- const preferred = normalizeOptionalAccountId(zaloConfig?.defaultAccount);
31
- if (
32
- preferred &&
33
- listZaloAccountIds(cfg).some((accountId) => normalizeAccountId(accountId) === preferred)
34
- ) {
35
- return preferred;
36
- }
37
- const ids = listZaloAccountIds(cfg);
38
- if (ids.includes(DEFAULT_ACCOUNT_ID)) {
39
- return DEFAULT_ACCOUNT_ID;
40
- }
41
- return ids[0] ?? DEFAULT_ACCOUNT_ID;
42
- }
8
+ const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefaultZaloAccountId } =
9
+ createAccountListHelpers("zalo");
10
+ export { listZaloAccountIds, resolveDefaultZaloAccountId };
43
11
 
44
12
  function resolveAccountConfig(
45
13
  cfg: OpenClawConfig,
package/src/actions.ts CHANGED
@@ -2,8 +2,8 @@ import type {
2
2
  ChannelMessageActionAdapter,
3
3
  ChannelMessageActionName,
4
4
  OpenClawConfig,
5
- } from "openclaw/plugin-sdk";
6
- import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
5
+ } from "openclaw/plugin-sdk/zalo";
6
+ import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk/zalo";
7
7
  import { listEnabledZaloAccounts } from "./accounts.js";
8
8
  import { sendMessageZalo } from "./send.js";
9
9
 
@@ -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,4 +1,4 @@
1
- import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/zalo";
2
2
  import { describe, expect, it } from "vitest";
3
3
  import { zaloPlugin } from "./channel.js";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ReplyPayload } from "openclaw/plugin-sdk";
1
+ import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { zaloPlugin } from "./channel.js";
4
4
 
@@ -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
+ });