@openclaw/zalouser 2026.3.13 → 2026.5.2-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.
Files changed (67) hide show
  1. package/README.md +4 -3
  2. package/api.ts +9 -0
  3. package/channel-plugin-api.ts +3 -0
  4. package/contract-api.ts +2 -0
  5. package/doctor-contract-api.ts +1 -0
  6. package/index.ts +29 -24
  7. package/openclaw.plugin.json +293 -1
  8. package/package.json +38 -11
  9. package/runtime-api.ts +67 -0
  10. package/secret-contract-api.ts +4 -0
  11. package/setup-entry.ts +9 -0
  12. package/setup-plugin-api.ts +2 -0
  13. package/src/accounts.runtime.ts +1 -0
  14. package/src/accounts.test-mocks.ts +7 -3
  15. package/src/accounts.test.ts +53 -1
  16. package/src/accounts.ts +38 -24
  17. package/src/channel-api.ts +20 -0
  18. package/src/channel.adapters.ts +391 -0
  19. package/src/channel.directory.test.ts +47 -40
  20. package/src/channel.runtime.ts +12 -0
  21. package/src/channel.sendpayload.test.ts +41 -23
  22. package/src/channel.setup.test.ts +33 -0
  23. package/src/channel.setup.ts +12 -0
  24. package/src/channel.test.ts +231 -20
  25. package/src/channel.ts +176 -685
  26. package/src/config-schema.ts +5 -5
  27. package/src/directory.ts +54 -0
  28. package/src/doctor-contract.ts +156 -0
  29. package/src/doctor.test.ts +77 -0
  30. package/src/doctor.ts +37 -0
  31. package/src/group-policy.test.ts +4 -4
  32. package/src/group-policy.ts +4 -2
  33. package/src/monitor.account-scope.test.ts +2 -1
  34. package/src/monitor.group-gating.test.ts +162 -8
  35. package/src/monitor.ts +233 -173
  36. package/src/probe.ts +3 -2
  37. package/src/qr-temp-file.ts +1 -1
  38. package/src/reaction.ts +5 -2
  39. package/src/runtime.ts +6 -3
  40. package/src/security-audit.test.ts +80 -0
  41. package/src/security-audit.ts +71 -0
  42. package/src/send.test.ts +2 -2
  43. package/src/send.ts +3 -3
  44. package/src/session-route.ts +121 -0
  45. package/src/setup-core.ts +33 -0
  46. package/src/setup-surface.test.ts +363 -0
  47. package/src/setup-surface.ts +470 -0
  48. package/src/setup-test-helpers.ts +42 -0
  49. package/src/shared.ts +92 -0
  50. package/src/status-issues.test.ts +1 -13
  51. package/src/status-issues.ts +8 -2
  52. package/src/test-helpers.ts +1 -1
  53. package/src/text-styles.test.ts +1 -1
  54. package/src/text-styles.ts +5 -2
  55. package/src/tool.test.ts +66 -3
  56. package/src/tool.ts +76 -14
  57. package/src/types.ts +3 -3
  58. package/src/zalo-js.credentials.test.ts +465 -0
  59. package/src/zalo-js.test-mocks.ts +89 -0
  60. package/src/zalo-js.ts +491 -274
  61. package/src/zca-client.test.ts +24 -0
  62. package/src/zca-client.ts +24 -58
  63. package/src/zca-constants.ts +55 -0
  64. package/test-api.ts +21 -0
  65. package/tsconfig.json +16 -0
  66. package/CHANGELOG.md +0 -107
  67. package/src/onboarding.ts +0 -340
@@ -1,12 +1,15 @@
1
- import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
1
+ import {
2
+ installChannelOutboundPayloadContractSuite,
3
+ primeChannelOutboundSendMock,
4
+ type OutboundPayloadHarnessParams,
5
+ } from "openclaw/plugin-sdk/channel-contract-testing";
2
6
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
7
  import "./accounts.test-mocks.js";
4
- import {
5
- installSendPayloadContractSuite,
6
- primeSendMock,
7
- } from "../../../src/test-utils/send-payload-contract.js";
8
+ import "./zalo-js.test-mocks.js";
9
+ import type { ReplyPayload } from "../runtime-api.js";
8
10
  import { zalouserPlugin } from "./channel.js";
9
11
  import { setZalouserRuntime } from "./runtime.js";
12
+ import * as sendModule from "./send.js";
10
13
 
11
14
  vi.mock("./send.js", () => ({
12
15
  sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
@@ -25,7 +28,7 @@ function baseCtx(payload: ReplyPayload) {
25
28
  describe("zalouserPlugin outbound sendPayload", () => {
26
29
  let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
27
30
 
28
- beforeEach(async () => {
31
+ beforeEach(() => {
29
32
  setZalouserRuntime({
30
33
  channel: {
31
34
  text: {
@@ -34,10 +37,8 @@ describe("zalouserPlugin outbound sendPayload", () => {
34
37
  },
35
38
  },
36
39
  } as never);
37
- const mod = await import("./send.js");
38
- mockedSend = vi.mocked(mod.sendMessageZalouser);
39
- mockedSend.mockClear();
40
- mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
40
+ mockedSend = vi.mocked(sendModule.sendMessageZalouser);
41
+ primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
41
42
  });
42
43
 
43
44
  it("group target delegates with isGroup=true and stripped threadId", async () => {
@@ -110,27 +111,45 @@ describe("zalouserPlugin outbound sendPayload", () => {
110
111
  );
111
112
  expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
112
113
  });
114
+ });
115
+
116
+ describe("zalouserPlugin outbound payload contract", () => {
117
+ function createZalouserHarness(params: OutboundPayloadHarnessParams) {
118
+ const mockedSend = vi.mocked(sendModule.sendMessageZalouser);
119
+ setZalouserRuntime({
120
+ channel: {
121
+ text: {
122
+ resolveChunkMode: vi.fn(() => "length"),
123
+ resolveTextChunkLimit: vi.fn(() => 1200),
124
+ },
125
+ },
126
+ } as never);
127
+ primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, params.sendResults);
128
+ const ctx = {
129
+ cfg: {},
130
+ to: "user:987654321",
131
+ text: "",
132
+ payload: params.payload,
133
+ };
134
+ return {
135
+ run: async () => await zalouserPlugin.outbound!.sendPayload!(ctx),
136
+ sendMock: mockedSend,
137
+ to: "987654321",
138
+ };
139
+ }
113
140
 
114
- installSendPayloadContractSuite({
141
+ installChannelOutboundPayloadContractSuite({
115
142
  channel: "zalouser",
116
143
  chunking: { mode: "passthrough", longTextLength: 3000 },
117
- createHarness: ({ payload, sendResults }) => {
118
- primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
119
- return {
120
- run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)),
121
- sendMock: mockedSend,
122
- to: "987654321",
123
- };
124
- },
144
+ createHarness: createZalouserHarness,
125
145
  });
126
146
  });
127
147
 
128
148
  describe("zalouserPlugin messaging target normalization", () => {
129
149
  it("normalizes user/group aliases to canonical targets", () => {
130
150
  const normalize = zalouserPlugin.messaging?.normalizeTarget;
131
- expect(normalize).toBeTypeOf("function");
132
151
  if (!normalize) {
133
- return;
152
+ throw new Error("normalizeTarget unavailable");
134
153
  }
135
154
  expect(normalize("zlu:g:30003")).toBe("group:30003");
136
155
  expect(normalize("zalouser:u:20002")).toBe("user:20002");
@@ -141,9 +160,8 @@ describe("zalouserPlugin messaging target normalization", () => {
141
160
 
142
161
  it("treats canonical and provider-native user/group targets as ids", () => {
143
162
  const looksLikeId = zalouserPlugin.messaging?.targetResolver?.looksLikeId;
144
- expect(looksLikeId).toBeTypeOf("function");
145
163
  if (!looksLikeId) {
146
- return;
164
+ throw new Error("looksLikeId unavailable");
147
165
  }
148
166
  expect(looksLikeId("user:20002")).toBe(true);
149
167
  expect(looksLikeId("group:30003")).toBe(true);
@@ -0,0 +1,33 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
5
+ import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
6
+ import { describe, expect, it } from "vitest";
7
+ import "./zalo-js.test-mocks.js";
8
+ import { zalouserSetupPlugin } from "./setup-test-helpers.js";
9
+
10
+ const zalouserSetupGetStatus = createPluginSetupWizardStatus(zalouserSetupPlugin);
11
+
12
+ describe("zalouser setup plugin", () => {
13
+ it("builds setup status without an initialized runtime", async () => {
14
+ const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-setup-"));
15
+
16
+ try {
17
+ await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
18
+ await expect(
19
+ zalouserSetupGetStatus({
20
+ cfg: {},
21
+ accountOverrides: {},
22
+ }),
23
+ ).resolves.toMatchObject({
24
+ channel: "zalouser",
25
+ configured: false,
26
+ statusLines: ["Zalo Personal: needs QR login"],
27
+ });
28
+ });
29
+ } finally {
30
+ await rm(stateDir, { recursive: true, force: true });
31
+ }
32
+ });
33
+ });
@@ -0,0 +1,12 @@
1
+ import type { ResolvedZalouserAccount } from "./accounts.js";
2
+ import type { ChannelPlugin } from "./channel-api.js";
3
+ import { zalouserSetupAdapter } from "./setup-core.js";
4
+ import { zalouserSetupWizard } from "./setup-surface.js";
5
+ import { createZalouserPluginBase } from "./shared.js";
6
+
7
+ export const zalouserSetupPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
8
+ ...createZalouserPluginBase({
9
+ setupWizard: zalouserSetupWizard,
10
+ setup: zalouserSetupAdapter,
11
+ }),
12
+ };
@@ -1,10 +1,29 @@
1
+ import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
1
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { zalouserPlugin } from "./channel.js";
3
+ import "./zalo-js.test-mocks.js";
4
+ import {
5
+ zalouserAuthAdapter,
6
+ zalouserGroupsAdapter,
7
+ zalouserMessageActions,
8
+ zalouserOutboundAdapter,
9
+ zalouserPairingTextAdapter,
10
+ zalouserResolverAdapter,
11
+ zalouserSecurityAdapter,
12
+ } from "./channel.adapters.js";
3
13
  import { setZalouserRuntime } from "./runtime.js";
4
14
  import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
15
+ import {
16
+ listZaloFriendsMatchingMock,
17
+ startZaloQrLoginMock,
18
+ waitForZaloQrLoginMock,
19
+ } from "./zalo-js.test-mocks.js";
5
20
 
6
- vi.mock("./send.js", async (importOriginal) => {
7
- const actual = (await importOriginal()) as Record<string, unknown>;
21
+ vi.mock("./qr-temp-file.js", () => ({
22
+ writeQrDataUrlToTempFile: vi.fn(async () => null),
23
+ }));
24
+
25
+ vi.mock("./send.js", async () => {
26
+ const actual = (await vi.importActual("./send.js")) as Record<string, unknown>;
8
27
  return {
9
28
  ...actual,
10
29
  sendMessageZalouser: vi.fn(async () => ({ ok: true, messageId: "mid-1" })),
@@ -15,15 +34,38 @@ vi.mock("./send.js", async (importOriginal) => {
15
34
  const mockSendMessage = vi.mocked(sendMessageZalouser);
16
35
  const mockSendReaction = vi.mocked(sendReactionZalouser);
17
36
 
37
+ function requireZalouserSendText() {
38
+ const sendText = zalouserOutboundAdapter.sendText;
39
+ if (!sendText) {
40
+ throw new Error("zalouser outbound.sendText unavailable");
41
+ }
42
+ return sendText;
43
+ }
44
+
18
45
  function getResolveToolPolicy() {
19
- const resolveToolPolicy = zalouserPlugin.groups?.resolveToolPolicy;
20
- expect(resolveToolPolicy).toBeTypeOf("function");
46
+ const resolveToolPolicy = zalouserGroupsAdapter.resolveToolPolicy;
21
47
  if (!resolveToolPolicy) {
22
48
  throw new Error("resolveToolPolicy unavailable");
23
49
  }
24
50
  return resolveToolPolicy;
25
51
  }
26
52
 
53
+ function requireZalouserResolveRequireMention() {
54
+ const resolveRequireMention = zalouserGroupsAdapter.resolveRequireMention;
55
+ if (!resolveRequireMention) {
56
+ throw new Error("resolveRequireMention unavailable");
57
+ }
58
+ return resolveRequireMention;
59
+ }
60
+
61
+ function requireZalouserPairingNormalizer() {
62
+ const normalizeAllowEntry = zalouserPairingTextAdapter.normalizeAllowEntry;
63
+ if (!normalizeAllowEntry) {
64
+ throw new Error("pairing.normalizeAllowEntry unavailable");
65
+ }
66
+ return normalizeAllowEntry;
67
+ }
68
+
27
69
  function resolveGroupToolPolicy(
28
70
  groups: Record<string, { tools: { allow?: string[]; deny?: string[] } }>,
29
71
  groupId: string,
@@ -56,11 +98,7 @@ describe("zalouser outbound", () => {
56
98
  });
57
99
 
58
100
  it("passes markdown chunk settings through sendText", async () => {
59
- const sendText = zalouserPlugin.outbound?.sendText;
60
- expect(sendText).toBeTypeOf("function");
61
- if (!sendText) {
62
- return;
63
- }
101
+ const sendText = requireZalouserSendText();
64
102
 
65
103
  const result = await sendText({
66
104
  cfg: { channels: { zalouser: { enabled: true } } } as never,
@@ -90,18 +128,67 @@ describe("zalouser outbound", () => {
90
128
  });
91
129
  });
92
130
 
131
+ describe("zalouser outbound chunking", () => {
132
+ it("chunks outbound text without requiring Zalouser runtime initialization", () => {
133
+ const chunker = zalouserOutboundAdapter.chunker;
134
+ if (!chunker) {
135
+ throw new Error("zalouser outbound.chunker unavailable");
136
+ }
137
+
138
+ expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
139
+ });
140
+ });
141
+
93
142
  describe("zalouser channel policies", () => {
94
143
  beforeEach(() => {
95
144
  mockSendReaction.mockClear();
96
145
  mockSendReaction.mockResolvedValue({ ok: true });
97
146
  });
98
147
 
99
- it("resolves requireMention from group config", () => {
100
- const resolveRequireMention = zalouserPlugin.groups?.resolveRequireMention;
101
- expect(resolveRequireMention).toBeTypeOf("function");
102
- if (!resolveRequireMention) {
103
- return;
148
+ it("normalizes dm allowlist entries after trimming channel prefixes", () => {
149
+ const resolveDmPolicy = zalouserSecurityAdapter.resolveDmPolicy;
150
+ if (!resolveDmPolicy) {
151
+ throw new Error("resolveDmPolicy unavailable");
104
152
  }
153
+
154
+ const cfg = {
155
+ channels: {
156
+ zalouser: {
157
+ dmPolicy: "allowlist",
158
+ allowFrom: [" zlu:123456 "],
159
+ },
160
+ },
161
+ } as never;
162
+ const account = {
163
+ accountId: "default",
164
+ enabled: true,
165
+ authenticated: false,
166
+ profile: "default",
167
+ config: {
168
+ dmPolicy: "allowlist",
169
+ allowFrom: [" zlu:123456 "],
170
+ },
171
+ } as never;
172
+
173
+ const result = resolveDmPolicy({ cfg, account });
174
+ if (!result) {
175
+ throw new Error("zalouser resolveDmPolicy returned null");
176
+ }
177
+
178
+ expect(result.policy).toBe("allowlist");
179
+ expect(result.allowFrom).toEqual([" zlu:123456 "]);
180
+ expect(result.normalizeEntry?.(" zlu:123456 ")).toBe("123456");
181
+ });
182
+
183
+ it("normalizes pairing allowlist entries after trimming channel prefixes", () => {
184
+ const normalizeAllowEntry = requireZalouserPairingNormalizer();
185
+
186
+ expect(normalizeAllowEntry(" zlu:123456 ")).toBe("123456");
187
+ expect(normalizeAllowEntry(" zalouser:654321 ")).toBe("654321");
188
+ });
189
+
190
+ it("resolves requireMention from group config", () => {
191
+ const resolveRequireMention = requireZalouserResolveRequireMention();
105
192
  const requireMention = resolveRequireMention({
106
193
  cfg: {
107
194
  channels: {
@@ -130,10 +217,11 @@ describe("zalouser channel policies", () => {
130
217
  });
131
218
 
132
219
  it("handles react action", async () => {
133
- const actions = zalouserPlugin.actions;
134
- expect(actions?.listActions?.({ cfg: { channels: { zalouser: { enabled: true } } } })).toEqual([
135
- "react",
136
- ]);
220
+ const actions = zalouserMessageActions;
221
+ expect(
222
+ actions?.describeMessageTool?.({ cfg: { channels: { zalouser: { enabled: true } } } })
223
+ ?.actions,
224
+ ).toEqual(["react"]);
137
225
  const result = await actions?.handleAction?.({
138
226
  channel: "zalouser",
139
227
  action: "react",
@@ -161,6 +249,129 @@ describe("zalouser channel policies", () => {
161
249
  emoji: "👍",
162
250
  remove: false,
163
251
  });
164
- expect(result).toBeDefined();
252
+ expect(result).toMatchObject({
253
+ content: [{ type: "text", text: "Reacted 👍 on 111" }],
254
+ details: {
255
+ messageId: "111",
256
+ cliMsgId: "222",
257
+ threadId: "123456",
258
+ },
259
+ });
260
+ });
261
+
262
+ it("honors the selected Zalouser account during discovery", () => {
263
+ const actions = zalouserMessageActions;
264
+ const cfg = {
265
+ channels: {
266
+ zalouser: {
267
+ enabled: true,
268
+ profile: "default",
269
+ accounts: {
270
+ default: {
271
+ enabled: false,
272
+ profile: "default",
273
+ },
274
+ work: {
275
+ enabled: true,
276
+ profile: "work",
277
+ },
278
+ },
279
+ },
280
+ },
281
+ };
282
+
283
+ expect(actions?.describeMessageTool?.({ cfg, accountId: "default" })).toBeNull();
284
+ expect(actions?.describeMessageTool?.({ cfg, accountId: "work" })?.actions).toEqual(["react"]);
285
+ });
286
+ });
287
+
288
+ describe("zalouser account resolution", () => {
289
+ beforeEach(() => {
290
+ listZaloFriendsMatchingMock.mockReset();
291
+ startZaloQrLoginMock.mockReset();
292
+ waitForZaloQrLoginMock.mockReset();
293
+ });
294
+
295
+ it("uses the configured default account for omitted target lookup", async () => {
296
+ const resolveTargets = zalouserResolverAdapter.resolveTargets;
297
+ if (!resolveTargets) {
298
+ throw new Error("zalouser resolver.resolveTargets unavailable");
299
+ }
300
+
301
+ listZaloFriendsMatchingMock.mockResolvedValue([
302
+ { userId: "42", displayName: "Work User" } as never,
303
+ ]);
304
+
305
+ const result = await resolveTargets({
306
+ cfg: {
307
+ channels: {
308
+ zalouser: {
309
+ defaultAccount: "work",
310
+ accounts: {
311
+ work: {
312
+ profile: "work-profile",
313
+ },
314
+ },
315
+ },
316
+ },
317
+ } as never,
318
+ inputs: ["Work User"],
319
+ kind: "user",
320
+ runtime: createNonExitingRuntimeEnv(),
321
+ });
322
+
323
+ expect(listZaloFriendsMatchingMock).toHaveBeenCalledWith("work-profile", "Work User");
324
+ expect(result).toEqual([
325
+ expect.objectContaining({
326
+ input: "Work User",
327
+ resolved: true,
328
+ id: "42",
329
+ name: "Work User",
330
+ }),
331
+ ]);
332
+ });
333
+
334
+ it("uses the configured default account for omitted qr login", async () => {
335
+ const login = zalouserAuthAdapter.login;
336
+ if (!login) {
337
+ throw new Error("zalouser auth.login unavailable");
338
+ }
339
+
340
+ startZaloQrLoginMock.mockResolvedValue({
341
+ message: "qr ready",
342
+ qrDataUrl: "data:image/png;base64,abc",
343
+ } as never);
344
+ waitForZaloQrLoginMock.mockResolvedValue({
345
+ connected: true,
346
+ userId: "u-1",
347
+ displayName: "Work User",
348
+ } as never);
349
+
350
+ const runtime = createNonExitingRuntimeEnv();
351
+
352
+ await login({
353
+ cfg: {
354
+ channels: {
355
+ zalouser: {
356
+ defaultAccount: "work",
357
+ accounts: {
358
+ work: {
359
+ profile: "work-profile",
360
+ },
361
+ },
362
+ },
363
+ },
364
+ } as never,
365
+ runtime,
366
+ });
367
+
368
+ expect(startZaloQrLoginMock).toHaveBeenCalledWith({
369
+ profile: "work-profile",
370
+ timeoutMs: 35_000,
371
+ });
372
+ expect(waitForZaloQrLoginMock).toHaveBeenCalledWith({
373
+ profile: "work-profile",
374
+ timeoutMs: 180_000,
375
+ });
165
376
  });
166
377
  });