@openclaw/zalo 2026.3.13 → 2026.5.1-beta.2

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.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +5 -0
  5. package/index.test.ts +15 -0
  6. package/index.ts +16 -13
  7. package/openclaw.plugin.json +514 -1
  8. package/package.json +31 -5
  9. package/runtime-api.test.ts +17 -0
  10. package/runtime-api.ts +75 -0
  11. package/secret-contract-api.ts +5 -0
  12. package/setup-api.ts +34 -0
  13. package/setup-entry.ts +13 -0
  14. package/src/accounts.test.ts +70 -0
  15. package/src/accounts.ts +19 -19
  16. package/src/actions.runtime.ts +5 -0
  17. package/src/actions.test.ts +32 -0
  18. package/src/actions.ts +20 -14
  19. package/src/api.test.ts +93 -2
  20. package/src/api.ts +29 -2
  21. package/src/approval-auth.test.ts +17 -0
  22. package/src/approval-auth.ts +25 -0
  23. package/src/channel.directory.test.ts +19 -6
  24. package/src/channel.runtime.ts +93 -0
  25. package/src/channel.startup.test.ts +26 -19
  26. package/src/channel.ts +228 -336
  27. package/src/config-schema.ts +3 -3
  28. package/src/group-access.ts +4 -3
  29. package/src/monitor.group-policy.test.ts +0 -12
  30. package/src/monitor.image.polling.test.ts +110 -0
  31. package/src/monitor.lifecycle.test.ts +41 -22
  32. package/src/monitor.pairing.lifecycle.test.ts +141 -0
  33. package/src/monitor.polling.media-reply.test.ts +425 -0
  34. package/src/monitor.reply-once.lifecycle.test.ts +171 -0
  35. package/src/monitor.ts +460 -206
  36. package/src/monitor.types.ts +4 -0
  37. package/src/monitor.webhook.test.ts +392 -62
  38. package/src/monitor.webhook.ts +73 -36
  39. package/src/outbound-media.test.ts +182 -0
  40. package/src/outbound-media.ts +241 -0
  41. package/src/outbound-payload.contract.test.ts +45 -0
  42. package/src/probe.ts +1 -1
  43. package/src/proxy.ts +1 -1
  44. package/src/runtime-api.ts +75 -0
  45. package/src/runtime-support.ts +91 -0
  46. package/src/runtime.ts +6 -3
  47. package/src/secret-contract.ts +109 -0
  48. package/src/secret-input.ts +1 -9
  49. package/src/send.test.ts +120 -0
  50. package/src/send.ts +15 -13
  51. package/src/session-route.ts +32 -0
  52. package/src/setup-allow-from.ts +94 -0
  53. package/src/setup-core.ts +149 -0
  54. package/src/{onboarding.status.test.ts → setup-status.test.ts} +13 -4
  55. package/src/setup-surface.test.ts +175 -0
  56. package/src/{onboarding.ts → setup-surface.ts} +59 -177
  57. package/src/status-issues.test.ts +2 -14
  58. package/src/status-issues.ts +8 -2
  59. package/src/test-support/lifecycle-test-support.ts +413 -0
  60. package/src/test-support/monitor-mocks-test-support.ts +209 -0
  61. package/src/token.test.ts +15 -0
  62. package/src/token.ts +8 -17
  63. package/src/types.ts +2 -2
  64. package/test-api.ts +1 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -101
  67. package/src/channel.sendpayload.test.ts +0 -44
@@ -0,0 +1,110 @@
1
+ import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
2
+ import { afterAll, beforeEach, describe, expect, it } from "vitest";
3
+ import {
4
+ createImageLifecycleCore,
5
+ createImageUpdate,
6
+ createLifecycleMonitorSetup,
7
+ expectImageLifecycleDelivery,
8
+ settleAsyncWork,
9
+ } from "./test-support/lifecycle-test-support.js";
10
+ import {
11
+ getUpdatesMock,
12
+ getZaloRuntimeMock,
13
+ loadCachedLifecycleMonitorModule,
14
+ resetLifecycleTestState,
15
+ sendMessageMock,
16
+ } from "./test-support/monitor-mocks-test-support.js";
17
+
18
+ describe("Zalo polling image handling", () => {
19
+ const {
20
+ core,
21
+ finalizeInboundContextMock,
22
+ recordInboundSessionMock,
23
+ fetchRemoteMediaMock,
24
+ saveMediaBufferMock,
25
+ } = createImageLifecycleCore();
26
+
27
+ beforeEach(async () => {
28
+ await resetLifecycleTestState();
29
+ getZaloRuntimeMock.mockReturnValue(core);
30
+ });
31
+
32
+ afterAll(async () => {
33
+ await resetLifecycleTestState();
34
+ });
35
+
36
+ it("downloads inbound image media from photo_url and preserves display_name", async () => {
37
+ getUpdatesMock
38
+ .mockResolvedValueOnce({
39
+ ok: true,
40
+ result: createImageUpdate({ date: 1774084566880 }),
41
+ })
42
+ .mockImplementation(() => new Promise(() => {}));
43
+
44
+ const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
45
+ const abort = new AbortController();
46
+ const runtime = createRuntimeEnv();
47
+ const { account, config } = createLifecycleMonitorSetup({
48
+ accountId: "default",
49
+ dmPolicy: "open",
50
+ });
51
+ const run = monitorZaloProvider({
52
+ token: "zalo-token", // pragma: allowlist secret
53
+ account,
54
+ config,
55
+ runtime,
56
+ abortSignal: abort.signal,
57
+ });
58
+
59
+ await settleAsyncWork();
60
+ expect(fetchRemoteMediaMock).toHaveBeenCalledTimes(1);
61
+ expectImageLifecycleDelivery({
62
+ fetchRemoteMediaMock,
63
+ saveMediaBufferMock,
64
+ finalizeInboundContextMock,
65
+ recordInboundSessionMock,
66
+ });
67
+
68
+ abort.abort();
69
+ await run;
70
+ });
71
+
72
+ it("rejects unauthorized DM images before downloading media", async () => {
73
+ getUpdatesMock
74
+ .mockResolvedValueOnce({
75
+ ok: true,
76
+ result: createImageUpdate({
77
+ messageId: "msg-unauthorized-1",
78
+ userId: "user-unauthorized-1",
79
+ chatId: "chat-unauthorized-1",
80
+ }),
81
+ })
82
+ .mockImplementation(() => new Promise(() => {}));
83
+
84
+ const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
85
+ const abort = new AbortController();
86
+ const runtime = createRuntimeEnv();
87
+ const { account, config } = createLifecycleMonitorSetup({
88
+ accountId: "default",
89
+ dmPolicy: "pairing",
90
+ allowFrom: ["allowed-user"],
91
+ });
92
+ const run = monitorZaloProvider({
93
+ token: "zalo-token", // pragma: allowlist secret
94
+ account,
95
+ config,
96
+ runtime,
97
+ abortSignal: abort.signal,
98
+ });
99
+
100
+ await settleAsyncWork();
101
+ expect(sendMessageMock).toHaveBeenCalledTimes(1);
102
+ expect(fetchRemoteMediaMock).not.toHaveBeenCalled();
103
+ expect(saveMediaBufferMock).not.toHaveBeenCalled();
104
+ expect(finalizeInboundContextMock).not.toHaveBeenCalled();
105
+ expect(recordInboundSessionMock).not.toHaveBeenCalled();
106
+
107
+ abort.abort();
108
+ await run;
109
+ });
110
+ });
@@ -1,7 +1,10 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
1
+ import {
2
+ createEmptyPluginRegistry,
3
+ createRuntimeEnv,
4
+ setActivePluginRegistry,
5
+ } from "openclaw/plugin-sdk/plugin-test-runtime";
2
6
  import { afterEach, describe, expect, it, vi } from "vitest";
3
- import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
4
- import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
7
+ import type { OpenClawConfig } from "../runtime-api.js";
5
8
  import type { ResolvedZaloAccount } from "./accounts.js";
6
9
 
7
10
  const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
@@ -9,8 +12,8 @@ const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }))
9
12
  const getUpdatesMock = vi.fn(() => new Promise(() => {}));
10
13
  const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
11
14
 
12
- vi.mock("./api.js", async (importOriginal) => {
13
- const actual = await importOriginal<typeof import("./api.js")>();
15
+ vi.mock("./api.js", async () => {
16
+ const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
14
17
  return {
15
18
  ...actual,
16
19
  deleteWebhook: deleteWebhookMock,
@@ -28,10 +31,6 @@ vi.mock("./runtime.js", () => ({
28
31
  }),
29
32
  }));
30
33
 
31
- async function waitForPollingLoopStart(): Promise<void> {
32
- await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
33
- }
34
-
35
34
  const TEST_ACCOUNT = {
36
35
  accountId: "default",
37
36
  config: {},
@@ -39,11 +38,11 @@ const TEST_ACCOUNT = {
39
38
 
40
39
  const TEST_CONFIG = {} as OpenClawConfig;
41
40
 
42
- function createLifecycleRuntime() {
43
- return {
44
- log: vi.fn<(message: string) => void>(),
45
- error: vi.fn<(message: string) => void>(),
46
- };
41
+ async function settleLifecycleWork(): Promise<void> {
42
+ for (let i = 0; i < 6; i += 1) {
43
+ await Promise.resolve();
44
+ await new Promise((resolve) => setImmediate(resolve));
45
+ }
47
46
  }
48
47
 
49
48
  async function startLifecycleMonitor(
@@ -55,7 +54,7 @@ async function startLifecycleMonitor(
55
54
  ) {
56
55
  const { monitorZaloProvider } = await import("./monitor.js");
57
56
  const abort = new AbortController();
58
- const runtime = createLifecycleRuntime();
57
+ const runtime = createRuntimeEnv();
59
58
  const run = monitorZaloProvider({
60
59
  token: "test-token",
61
60
  account: TEST_ACCOUNT,
@@ -80,7 +79,8 @@ describe("monitorZaloProvider lifecycle", () => {
80
79
  settled = true;
81
80
  });
82
81
 
83
- await waitForPollingLoopStart();
82
+ await settleLifecycleWork();
83
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
84
84
 
85
85
  expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
86
86
  expect(deleteWebhookMock).not.toHaveBeenCalled();
@@ -104,7 +104,8 @@ describe("monitorZaloProvider lifecycle", () => {
104
104
 
105
105
  const { abort, runtime, run } = await startLifecycleMonitor();
106
106
 
107
- await waitForPollingLoopStart();
107
+ await settleLifecycleWork();
108
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
108
109
 
109
110
  expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
110
111
  expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
@@ -122,7 +123,8 @@ describe("monitorZaloProvider lifecycle", () => {
122
123
 
123
124
  const { abort, runtime, run } = await startLifecycleMonitor();
124
125
 
125
- await waitForPollingLoopStart();
126
+ await settleLifecycleWork();
127
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
126
128
 
127
129
  expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
128
130
  expect(deleteWebhookMock).not.toHaveBeenCalled();
@@ -139,10 +141,24 @@ describe("monitorZaloProvider lifecycle", () => {
139
141
  const registry = createEmptyPluginRegistry();
140
142
  setActivePluginRegistry(registry);
141
143
 
144
+ let resolveSetWebhookCalled: (() => void) | undefined;
145
+ const setWebhookCalled = new Promise<void>((resolve) => {
146
+ resolveSetWebhookCalled = resolve;
147
+ });
148
+ setWebhookMock.mockImplementationOnce(async () => {
149
+ resolveSetWebhookCalled?.();
150
+ return { ok: true, result: { url: "" } };
151
+ });
152
+
153
+ let resolveDeleteWebhookCalled: (() => void) | undefined;
154
+ const deleteWebhookCalled = new Promise<void>((resolve) => {
155
+ resolveDeleteWebhookCalled = resolve;
156
+ });
142
157
  let resolveDeleteWebhook: (() => void) | undefined;
143
158
  deleteWebhookMock.mockImplementationOnce(
144
159
  () =>
145
160
  new Promise((resolve) => {
161
+ resolveDeleteWebhookCalled?.();
146
162
  resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
147
163
  }),
148
164
  );
@@ -157,15 +173,18 @@ describe("monitorZaloProvider lifecycle", () => {
157
173
  settled = true;
158
174
  });
159
175
 
160
- await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1));
161
- expect(registry.httpRoutes).toHaveLength(1);
176
+ await setWebhookCalled;
177
+ await settleLifecycleWork();
178
+ expect(setWebhookMock).toHaveBeenCalledTimes(1);
179
+ expect(registry.httpRoutes).toHaveLength(2);
162
180
 
163
181
  abort.abort();
164
182
 
165
- await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1));
183
+ await deleteWebhookCalled;
184
+ expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
166
185
  expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
167
186
  expect(settled).toBe(false);
168
- expect(registry.httpRoutes).toHaveLength(1);
187
+ expect(registry.httpRoutes).toHaveLength(2);
169
188
 
170
189
  resolveDeleteWebhook?.();
171
190
  await monitoredRun;
@@ -0,0 +1,141 @@
1
+ import { withServer } from "openclaw/plugin-sdk/test-env";
2
+ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ createLifecycleMonitorSetup,
5
+ createTextUpdate,
6
+ postWebhookReplay,
7
+ settleAsyncWork,
8
+ } from "./test-support/lifecycle-test-support.js";
9
+ import {
10
+ resetLifecycleTestState,
11
+ sendMessageMock,
12
+ setLifecycleRuntimeCore,
13
+ startWebhookLifecycleMonitor,
14
+ } from "./test-support/monitor-mocks-test-support.js";
15
+
16
+ describe("Zalo pairing lifecycle", () => {
17
+ const readAllowFromStoreMock = vi.fn(async () => [] as string[]);
18
+ const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true }));
19
+
20
+ beforeEach(async () => {
21
+ await resetLifecycleTestState();
22
+ setLifecycleRuntimeCore({
23
+ pairing: {
24
+ readAllowFromStore: readAllowFromStoreMock,
25
+ upsertPairingRequest: upsertPairingRequestMock,
26
+ },
27
+ commands: {
28
+ shouldComputeCommandAuthorized: vi.fn(() => false),
29
+ resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
30
+ },
31
+ });
32
+ });
33
+
34
+ afterAll(async () => {
35
+ await resetLifecycleTestState();
36
+ });
37
+
38
+ function createPairingMonitorSetup() {
39
+ return createLifecycleMonitorSetup({
40
+ accountId: "acct-zalo-pairing",
41
+ dmPolicy: "pairing",
42
+ allowFrom: [],
43
+ });
44
+ }
45
+
46
+ it("emits one pairing reply across duplicate webhook replay and scopes reads and writes to accountId", async () => {
47
+ const monitor = await startWebhookLifecycleMonitor({
48
+ ...createPairingMonitorSetup(),
49
+ cacheKey: "zalo-pairing-lifecycle",
50
+ });
51
+
52
+ try {
53
+ await withServer(
54
+ (req, res) => monitor.route.handler(req, res),
55
+ async (baseUrl) => {
56
+ const { first, replay } = await postWebhookReplay({
57
+ baseUrl,
58
+ path: "/hooks/zalo",
59
+ secret: "supersecret",
60
+ payload: createTextUpdate({
61
+ messageId: `zalo-pairing-${Date.now()}`,
62
+ userId: "user-unauthorized",
63
+ userName: "Unauthorized User",
64
+ chatId: "dm-pairing-1",
65
+ }),
66
+ });
67
+
68
+ expect(first.status).toBe(200);
69
+ expect(replay.status).toBe(200);
70
+ await settleAsyncWork();
71
+ },
72
+ );
73
+
74
+ expect(readAllowFromStoreMock).toHaveBeenCalledTimes(1);
75
+ expect(readAllowFromStoreMock).toHaveBeenCalledWith(
76
+ expect.objectContaining({
77
+ channel: "zalo",
78
+ accountId: "acct-zalo-pairing",
79
+ }),
80
+ );
81
+ expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
82
+ expect(upsertPairingRequestMock).toHaveBeenCalledWith(
83
+ expect.objectContaining({
84
+ channel: "zalo",
85
+ accountId: "acct-zalo-pairing",
86
+ id: "user-unauthorized",
87
+ }),
88
+ );
89
+ expect(sendMessageMock).toHaveBeenCalledTimes(1);
90
+ expect(sendMessageMock).toHaveBeenCalledWith(
91
+ "zalo-token",
92
+ expect.objectContaining({
93
+ chat_id: "dm-pairing-1",
94
+ text: expect.stringContaining("PAIRCODE"),
95
+ }),
96
+ undefined,
97
+ );
98
+ } finally {
99
+ await monitor.stop();
100
+ }
101
+ });
102
+
103
+ it("does not emit a second pairing reply when replay arrives after the first send fails", async () => {
104
+ sendMessageMock.mockRejectedValueOnce(new Error("pairing send failed"));
105
+
106
+ const monitor = await startWebhookLifecycleMonitor({
107
+ ...createPairingMonitorSetup(),
108
+ cacheKey: "zalo-pairing-lifecycle",
109
+ });
110
+
111
+ try {
112
+ await withServer(
113
+ (req, res) => monitor.route.handler(req, res),
114
+ async (baseUrl) => {
115
+ const { first, replay } = await postWebhookReplay({
116
+ baseUrl,
117
+ path: "/hooks/zalo",
118
+ secret: "supersecret",
119
+ payload: createTextUpdate({
120
+ messageId: `zalo-pairing-retry-${Date.now()}`,
121
+ userId: "user-unauthorized",
122
+ userName: "Unauthorized User",
123
+ chatId: "dm-pairing-1",
124
+ }),
125
+ settleBeforeReplay: true,
126
+ });
127
+
128
+ expect(first.status).toBe(200);
129
+ expect(replay.status).toBe(200);
130
+ await settleAsyncWork();
131
+ },
132
+ );
133
+
134
+ expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
135
+ expect(sendMessageMock).toHaveBeenCalledTimes(1);
136
+ expect(monitor.runtime.error).not.toHaveBeenCalled();
137
+ } finally {
138
+ await monitor.stop();
139
+ }
140
+ });
141
+ });