@openclaw/zalo 2026.3.12 → 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 +108 -22
  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 +22 -16
  24. package/src/channel.runtime.ts +93 -0
  25. package/src/channel.startup.test.ts +36 -35
  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 +77 -92
  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 +527 -304
  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 +64 -40
  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 +17 -0
  58. package/src/status-issues.ts +11 -27
  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 -95
  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,8 +31,39 @@ vi.mock("./runtime.js", () => ({
28
31
  }),
29
32
  }));
30
33
 
31
- async function waitForPollingLoopStart(): Promise<void> {
32
- await vi.waitFor(() => expect(getUpdatesMock).toHaveBeenCalledTimes(1));
34
+ const TEST_ACCOUNT = {
35
+ accountId: "default",
36
+ config: {},
37
+ } as unknown as ResolvedZaloAccount;
38
+
39
+ const TEST_CONFIG = {} as OpenClawConfig;
40
+
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
+ }
46
+ }
47
+
48
+ async function startLifecycleMonitor(
49
+ options: {
50
+ useWebhook?: boolean;
51
+ webhookSecret?: string;
52
+ webhookUrl?: string;
53
+ } = {},
54
+ ) {
55
+ const { monitorZaloProvider } = await import("./monitor.js");
56
+ const abort = new AbortController();
57
+ const runtime = createRuntimeEnv();
58
+ const run = monitorZaloProvider({
59
+ token: "test-token",
60
+ account: TEST_ACCOUNT,
61
+ config: TEST_CONFIG,
62
+ runtime,
63
+ abortSignal: abort.signal,
64
+ ...options,
65
+ });
66
+ return { abort, runtime, run };
33
67
  }
34
68
 
35
69
  describe("monitorZaloProvider lifecycle", () => {
@@ -39,30 +73,14 @@ describe("monitorZaloProvider lifecycle", () => {
39
73
  });
40
74
 
41
75
  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
76
  let settled = false;
55
- const run = monitorZaloProvider({
56
- token: "test-token",
57
- account,
58
- config,
59
- runtime,
60
- abortSignal: abort.signal,
61
- }).then(() => {
77
+ const { abort, runtime, run } = await startLifecycleMonitor();
78
+ const monitoredRun = run.then(() => {
62
79
  settled = true;
63
80
  });
64
81
 
65
- await waitForPollingLoopStart();
82
+ await settleLifecycleWork();
83
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
66
84
 
67
85
  expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
68
86
  expect(deleteWebhookMock).not.toHaveBeenCalled();
@@ -70,7 +88,7 @@ describe("monitorZaloProvider lifecycle", () => {
70
88
  expect(settled).toBe(false);
71
89
 
72
90
  abort.abort();
73
- await run;
91
+ await monitoredRun;
74
92
 
75
93
  expect(settled).toBe(true);
76
94
  expect(runtime.log).toHaveBeenCalledWith(
@@ -84,27 +102,10 @@ describe("monitorZaloProvider lifecycle", () => {
84
102
  result: { url: "https://example.com/hooks/zalo" },
85
103
  });
86
104
 
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
- });
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);
@@ -120,27 +121,10 @@ describe("monitorZaloProvider lifecycle", () => {
120
121
  const { ZaloApiError } = await import("./api.js");
121
122
  getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
122
123
 
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
- });
124
+ const { abort, runtime, run } = await startLifecycleMonitor();
142
125
 
143
- await waitForPollingLoopStart();
126
+ await settleLifecycleWork();
127
+ expect(getUpdatesMock).toHaveBeenCalledTimes(1);
144
128
 
145
129
  expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
146
130
  expect(deleteWebhookMock).not.toHaveBeenCalled();
@@ -157,52 +141,53 @@ describe("monitorZaloProvider lifecycle", () => {
157
141
  const registry = createEmptyPluginRegistry();
158
142
  setActivePluginRegistry(registry);
159
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
+ });
160
157
  let resolveDeleteWebhook: (() => void) | undefined;
161
158
  deleteWebhookMock.mockImplementationOnce(
162
159
  () =>
163
160
  new Promise((resolve) => {
161
+ resolveDeleteWebhookCalled?.();
164
162
  resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
165
163
  }),
166
164
  );
167
165
 
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
166
  let settled = false;
181
- const run = monitorZaloProvider({
182
- token: "test-token",
183
- account,
184
- config,
185
- runtime,
186
- abortSignal: abort.signal,
167
+ const { abort, runtime, run } = await startLifecycleMonitor({
187
168
  useWebhook: true,
188
169
  webhookUrl: "https://example.com/hooks/zalo",
189
170
  webhookSecret: "supersecret", // pragma: allowlist secret
190
- }).then(() => {
171
+ });
172
+ const monitoredRun = run.then(() => {
191
173
  settled = true;
192
174
  });
193
175
 
194
- await vi.waitFor(() => expect(setWebhookMock).toHaveBeenCalledTimes(1));
195
- expect(registry.httpRoutes).toHaveLength(1);
176
+ await setWebhookCalled;
177
+ await settleLifecycleWork();
178
+ expect(setWebhookMock).toHaveBeenCalledTimes(1);
179
+ expect(registry.httpRoutes).toHaveLength(2);
196
180
 
197
181
  abort.abort();
198
182
 
199
- await vi.waitFor(() => expect(deleteWebhookMock).toHaveBeenCalledTimes(1));
183
+ await deleteWebhookCalled;
184
+ expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
200
185
  expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
201
186
  expect(settled).toBe(false);
202
- expect(registry.httpRoutes).toHaveLength(1);
187
+ expect(registry.httpRoutes).toHaveLength(2);
203
188
 
204
189
  resolveDeleteWebhook?.();
205
- await run;
190
+ await monitoredRun;
206
191
 
207
192
  expect(settled).toBe(true);
208
193
  expect(registry.httpRoutes).toHaveLength(0);
@@ -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
+ });