@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
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { collectZalouserSecurityAuditFindings } from "./security-audit.js";
3
+ import type { ResolvedZalouserAccount, ZalouserAccountConfig } from "./types.js";
4
+
5
+ function createAccount(config: ZalouserAccountConfig): ResolvedZalouserAccount {
6
+ return {
7
+ accountId: "default",
8
+ enabled: true,
9
+ profile: "default",
10
+ authenticated: true,
11
+ config,
12
+ };
13
+ }
14
+
15
+ describe("Zalouser security audit findings", () => {
16
+ const cases: Array<{
17
+ name: string;
18
+ config: ZalouserAccountConfig;
19
+ expectedSeverity: "info" | "warn";
20
+ detailIncludes: string[];
21
+ detailExcludes?: string[];
22
+ expectFindingMatch?: { checkId: string; severity: "info" | "warn" };
23
+ }> = [
24
+ {
25
+ name: "warns when group routing contains mutable group entries",
26
+ config: {
27
+ enabled: true,
28
+ groups: {
29
+ "Ops Room": { enabled: true },
30
+ "group:g-123": { enabled: true },
31
+ },
32
+ } satisfies ZalouserAccountConfig,
33
+ expectedSeverity: "warn",
34
+ detailIncludes: ["channels.zalouser.groups:Ops Room"],
35
+ detailExcludes: ["group:g-123"],
36
+ },
37
+ {
38
+ name: "marks mutable group routing as break-glass when dangerous matching is enabled",
39
+ config: {
40
+ enabled: true,
41
+ dangerouslyAllowNameMatching: true,
42
+ groups: {
43
+ "Ops Room": { enabled: true },
44
+ },
45
+ } satisfies ZalouserAccountConfig,
46
+ expectedSeverity: "info",
47
+ detailIncludes: ["out-of-scope"],
48
+ expectFindingMatch: {
49
+ checkId: "channels.zalouser.groups.mutable_entries",
50
+ severity: "info",
51
+ },
52
+ },
53
+ ];
54
+
55
+ it.each(cases)("$name", (testCase) => {
56
+ const findings = collectZalouserSecurityAuditFindings({
57
+ account: createAccount(testCase.config),
58
+ accountId: "default",
59
+ orderedAccountIds: ["default"],
60
+ hasExplicitAccountPath: false,
61
+ });
62
+ const finding = findings.find(
63
+ (entry) => entry.checkId === "channels.zalouser.groups.mutable_entries",
64
+ );
65
+
66
+ expect(finding).toBeDefined();
67
+ expect(finding?.severity).toBe(testCase.expectedSeverity);
68
+ for (const snippet of testCase.detailIncludes) {
69
+ expect(finding?.detail).toContain(snippet);
70
+ }
71
+ for (const snippet of testCase.detailExcludes ?? []) {
72
+ expect(finding?.detail).not.toContain(snippet);
73
+ }
74
+ if (testCase.expectFindingMatch) {
75
+ expect(findings).toEqual(
76
+ expect.arrayContaining([expect.objectContaining(testCase.expectFindingMatch)]),
77
+ );
78
+ }
79
+ });
80
+ });
@@ -0,0 +1,71 @@
1
+ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
2
+ import type { ResolvedZalouserAccount } from "./accounts.js";
3
+
4
+ export function isZalouserMutableGroupEntry(raw: string): boolean {
5
+ const text = raw.trim();
6
+ if (!text || text === "*") {
7
+ return false;
8
+ }
9
+ const normalized = text
10
+ .replace(/^(zalouser|zlu):/i, "")
11
+ .replace(/^group:/i, "")
12
+ .trim();
13
+ if (!normalized) {
14
+ return false;
15
+ }
16
+ if (/^\d+$/.test(normalized)) {
17
+ return false;
18
+ }
19
+ return !/^g-\S+$/i.test(normalized);
20
+ }
21
+
22
+ export function collectZalouserSecurityAuditFindings(params: {
23
+ accountId?: string | null;
24
+ account: ResolvedZalouserAccount;
25
+ orderedAccountIds: string[];
26
+ hasExplicitAccountPath: boolean;
27
+ }) {
28
+ const zalouserCfg = params.account.config ?? {};
29
+ const accountId = params.accountId?.trim() || params.account.accountId || "default";
30
+ const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
31
+ const zalouserPathPrefix =
32
+ params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
33
+ ? `channels.zalouser.accounts.${accountId}`
34
+ : "channels.zalouser";
35
+ const mutableGroupEntries = new Set<string>();
36
+ const groups = zalouserCfg.groups;
37
+ if (groups && typeof groups === "object" && !Array.isArray(groups)) {
38
+ for (const key of Object.keys(groups as Record<string, unknown>)) {
39
+ if (!isZalouserMutableGroupEntry(key)) {
40
+ continue;
41
+ }
42
+ mutableGroupEntries.add(`${zalouserPathPrefix}.groups:${key}`);
43
+ }
44
+ }
45
+ if (mutableGroupEntries.size === 0) {
46
+ return [];
47
+ }
48
+ const examples = Array.from(mutableGroupEntries).slice(0, 5);
49
+ const more =
50
+ mutableGroupEntries.size > examples.length
51
+ ? ` (+${mutableGroupEntries.size - examples.length} more)`
52
+ : "";
53
+ const severity: "info" | "warn" = dangerousNameMatchingEnabled ? "info" : "warn";
54
+ return [
55
+ {
56
+ checkId: "channels.zalouser.groups.mutable_entries",
57
+ severity,
58
+ title: dangerousNameMatchingEnabled
59
+ ? "Zalouser group routing uses break-glass name matching"
60
+ : "Zalouser group routing contains mutable group entries",
61
+ detail: dangerousNameMatchingEnabled
62
+ ? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
63
+ `Found: ${examples.join(", ")}${more}.`
64
+ : "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
65
+ `Found: ${examples.join(", ")}${more}.`,
66
+ remediation: dangerousNameMatchingEnabled
67
+ ? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
68
+ : "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
69
+ },
70
+ ];
71
+ }
package/src/send.test.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  sendZaloTextMessage,
18
18
  sendZaloTypingEvent,
19
19
  } from "./zalo-js.js";
20
- import { TextStyle } from "./zca-client.js";
20
+ import { TextStyle } from "./zca-constants.js";
21
21
 
22
22
  vi.mock("./zalo-js.js", () => ({
23
23
  sendZaloTextMessage: vi.fn(),
@@ -149,7 +149,7 @@ describe("zalouser send helpers", () => {
149
149
  expect(formatted.text.length).toBeGreaterThan(2000);
150
150
  expect(mockSendText).toHaveBeenCalledTimes(2);
151
151
  expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
152
- expect(mockSendText.mock.calls.every((call) => (call[1] as string).length <= 2000)).toBe(true);
152
+ expect(mockSendText.mock.calls.every((call) => call[1].length <= 2000)).toBe(true);
153
153
  expect(result).toEqual({ ok: true, messageId: "mid-2c-2" });
154
154
  });
155
155
 
package/src/send.ts CHANGED
@@ -8,10 +8,10 @@ import {
8
8
  sendZaloTextMessage,
9
9
  sendZaloTypingEvent,
10
10
  } from "./zalo-js.js";
11
- import { TextStyle } from "./zca-client.js";
11
+ import { TextStyle } from "./zca-constants.js";
12
12
 
13
- export type ZalouserSendOptions = ZaloSendOptions;
14
- export type ZalouserSendResult = ZaloSendResult;
13
+ type ZalouserSendOptions = ZaloSendOptions;
14
+ type ZalouserSendResult = ZaloSendResult;
15
15
 
16
16
  const ZALO_TEXT_LIMIT = 2000;
17
17
  const DEFAULT_TEXT_CHUNK_MODE = "length";
@@ -0,0 +1,121 @@
1
+ import {
2
+ buildChannelOutboundSessionRoute,
3
+ type ChannelOutboundSessionRouteParams,
4
+ } from "openclaw/plugin-sdk/core";
5
+ import {
6
+ normalizeLowercaseStringOrEmpty,
7
+ normalizeOptionalLowercaseString,
8
+ } from "openclaw/plugin-sdk/text-runtime";
9
+
10
+ function stripZalouserTargetPrefix(raw: string): string {
11
+ return raw
12
+ .trim()
13
+ .replace(/^(zalouser|zlu):/i, "")
14
+ .trim();
15
+ }
16
+
17
+ export function normalizeZalouserTarget(raw: string): string | undefined {
18
+ const trimmed = stripZalouserTargetPrefix(raw);
19
+ if (!trimmed) {
20
+ return undefined;
21
+ }
22
+
23
+ const lower = normalizeLowercaseStringOrEmpty(trimmed);
24
+ if (lower.startsWith("group:")) {
25
+ const id = trimmed.slice("group:".length).trim();
26
+ return id ? `group:${id}` : undefined;
27
+ }
28
+ if (lower.startsWith("g:")) {
29
+ const id = trimmed.slice("g:".length).trim();
30
+ return id ? `group:${id}` : undefined;
31
+ }
32
+ if (lower.startsWith("user:")) {
33
+ const id = trimmed.slice("user:".length).trim();
34
+ return id ? `user:${id}` : undefined;
35
+ }
36
+ if (lower.startsWith("dm:")) {
37
+ const id = trimmed.slice("dm:".length).trim();
38
+ return id ? `user:${id}` : undefined;
39
+ }
40
+ if (lower.startsWith("u:")) {
41
+ const id = trimmed.slice("u:".length).trim();
42
+ return id ? `user:${id}` : undefined;
43
+ }
44
+ if (/^g-\S+$/i.test(trimmed)) {
45
+ return `group:${trimmed}`;
46
+ }
47
+ if (/^u-\S+$/i.test(trimmed)) {
48
+ return `user:${trimmed}`;
49
+ }
50
+
51
+ return trimmed;
52
+ }
53
+
54
+ export function parseZalouserOutboundTarget(raw: string): {
55
+ threadId: string;
56
+ isGroup: boolean;
57
+ } {
58
+ const normalized = normalizeZalouserTarget(raw);
59
+ if (!normalized) {
60
+ throw new Error("Zalouser target is required");
61
+ }
62
+ const lowered = normalizeLowercaseStringOrEmpty(normalized);
63
+ if (lowered.startsWith("group:")) {
64
+ const threadId = normalized.slice("group:".length).trim();
65
+ if (!threadId) {
66
+ throw new Error("Zalouser group target is missing group id");
67
+ }
68
+ return { threadId, isGroup: true };
69
+ }
70
+ if (lowered.startsWith("user:")) {
71
+ const threadId = normalized.slice("user:".length).trim();
72
+ if (!threadId) {
73
+ throw new Error("Zalouser user target is missing user id");
74
+ }
75
+ return { threadId, isGroup: false };
76
+ }
77
+ // Backward-compatible fallback for bare IDs.
78
+ // Group sends should use explicit `group:<id>` targets.
79
+ return { threadId: normalized, isGroup: false };
80
+ }
81
+
82
+ export function parseZalouserDirectoryGroupId(raw: string): string {
83
+ const normalized = normalizeZalouserTarget(raw);
84
+ if (!normalized) {
85
+ throw new Error("Zalouser group target is required");
86
+ }
87
+ const lowered = normalizeLowercaseStringOrEmpty(normalized);
88
+ if (lowered.startsWith("group:")) {
89
+ const groupId = normalized.slice("group:".length).trim();
90
+ if (!groupId) {
91
+ throw new Error("Zalouser group target is missing group id");
92
+ }
93
+ return groupId;
94
+ }
95
+ if (lowered.startsWith("user:")) {
96
+ throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
97
+ }
98
+ return normalized;
99
+ }
100
+
101
+ export function resolveZalouserOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
102
+ const normalized = normalizeZalouserTarget(params.target);
103
+ if (!normalized) {
104
+ return null;
105
+ }
106
+ const isGroup = (normalizeOptionalLowercaseString(normalized) ?? "").startsWith("group:");
107
+ const peerId = normalized.replace(/^(group|user):/i, "").trim();
108
+ return buildChannelOutboundSessionRoute({
109
+ cfg: params.cfg,
110
+ agentId: params.agentId,
111
+ channel: "zalouser",
112
+ accountId: params.accountId,
113
+ peer: {
114
+ kind: isGroup ? "group" : "direct",
115
+ id: peerId,
116
+ },
117
+ chatType: isGroup ? "group" : "direct",
118
+ from: isGroup ? `zalouser:group:${peerId}` : `zalouser:${peerId}`,
119
+ to: `zalouser:${peerId}`,
120
+ });
121
+ }
@@ -0,0 +1,33 @@
1
+ import {
2
+ createDelegatedSetupWizardProxy,
3
+ createPatchedAccountSetupAdapter,
4
+ type ChannelSetupWizard,
5
+ } from "openclaw/plugin-sdk/setup-runtime";
6
+
7
+ const channel = "zalouser" as const;
8
+
9
+ export const zalouserSetupAdapter = createPatchedAccountSetupAdapter({
10
+ channelKey: channel,
11
+ validateInput: () => null,
12
+ buildPatch: () => ({}),
13
+ });
14
+
15
+ export function createZalouserSetupWizardProxy(
16
+ loadWizard: () => Promise<ChannelSetupWizard>,
17
+ ): ChannelSetupWizard {
18
+ return createDelegatedSetupWizardProxy({
19
+ channel,
20
+ loadWizard,
21
+ status: {
22
+ configuredLabel: "logged in",
23
+ unconfiguredLabel: "needs QR login",
24
+ configuredHint: "recommended · logged in",
25
+ unconfiguredHint: "recommended · QR login",
26
+ configuredScore: 1,
27
+ unconfiguredScore: 15,
28
+ },
29
+ credentials: [],
30
+ delegatePrepare: true,
31
+ delegateFinalize: true,
32
+ });
33
+ }
@@ -0,0 +1,363 @@
1
+ import {
2
+ createPluginSetupWizardConfigure,
3
+ createTestWizardPrompter,
4
+ runSetupWizardConfigure,
5
+ } from "openclaw/plugin-sdk/plugin-test-runtime";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import type { OpenClawConfig } from "../runtime-api.js";
8
+ import "./zalo-js.test-mocks.js";
9
+ import { zalouserSetupWizard } from "./setup-surface.js";
10
+ import { zalouserSetupPlugin } from "./setup-test-helpers.js";
11
+
12
+ const zalouserConfigure = createPluginSetupWizardConfigure(zalouserSetupPlugin);
13
+
14
+ async function runSetup(params: {
15
+ cfg?: OpenClawConfig;
16
+ prompter: ReturnType<typeof createTestWizardPrompter>;
17
+ options?: Record<string, unknown>;
18
+ forceAllowFrom?: boolean;
19
+ }) {
20
+ return await runSetupWizardConfigure({
21
+ configure: zalouserConfigure,
22
+ cfg: params.cfg,
23
+ prompter: params.prompter,
24
+ options: params.options,
25
+ forceAllowFrom: params.forceAllowFrom,
26
+ });
27
+ }
28
+
29
+ describe("zalouser setup wizard", () => {
30
+ function expectEnabledDefaultSetup(
31
+ result: Awaited<ReturnType<typeof runSetup>>,
32
+ dmPolicy?: "pairing" | "allowlist",
33
+ ) {
34
+ expect(result.accountId).toBe("default");
35
+ expect(result.cfg.channels?.zalouser?.enabled).toBe(true);
36
+ expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
37
+ if (dmPolicy) {
38
+ expect(result.cfg.channels?.zalouser?.dmPolicy).toBe(dmPolicy);
39
+ }
40
+ }
41
+
42
+ function createQuickstartPrompter(params?: {
43
+ note?: ReturnType<typeof createTestWizardPrompter>["note"];
44
+ seen?: string[];
45
+ dmPolicy?: "pairing" | "allowlist";
46
+ groupAccess?: boolean;
47
+ groupPolicy?: "allowlist";
48
+ textByMessage?: Record<string, string>;
49
+ }) {
50
+ const select = vi.fn(
51
+ async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
52
+ const first = options[0];
53
+ if (!first) {
54
+ throw new Error("no options");
55
+ }
56
+ params?.seen?.push(message);
57
+ if (message === "Zalo Personal DM policy" && params?.dmPolicy) {
58
+ return params.dmPolicy;
59
+ }
60
+ if (message === "Zalo groups access" && params?.groupPolicy) {
61
+ return params.groupPolicy;
62
+ }
63
+ return first.value;
64
+ },
65
+ ) as ReturnType<typeof createTestWizardPrompter>["select"];
66
+ const text = vi.fn(
67
+ async ({ message }: { message: string }) => params?.textByMessage?.[message] ?? "",
68
+ ) as ReturnType<typeof createTestWizardPrompter>["text"];
69
+ return createTestWizardPrompter({
70
+ ...(params?.note ? { note: params.note } : {}),
71
+ confirm: vi.fn(async ({ message }: { message: string }) => {
72
+ params?.seen?.push(message);
73
+ if (message === "Login via QR code now?") {
74
+ return false;
75
+ }
76
+ if (message === "Configure Zalo groups access?") {
77
+ return params?.groupAccess ?? false;
78
+ }
79
+ return false;
80
+ }),
81
+ select,
82
+ text,
83
+ });
84
+ }
85
+
86
+ it("enables the account without forcing QR login", async () => {
87
+ const prompter = createTestWizardPrompter({
88
+ confirm: vi.fn(async ({ message }: { message: string }) => {
89
+ if (message === "Login via QR code now?") {
90
+ return false;
91
+ }
92
+ if (message === "Configure Zalo groups access?") {
93
+ return false;
94
+ }
95
+ return false;
96
+ }),
97
+ });
98
+
99
+ const result = await runSetup({ prompter });
100
+
101
+ expect(result.accountId).toBe("default");
102
+ expect(result.cfg.channels?.zalouser?.enabled).toBe(true);
103
+ expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
104
+ });
105
+
106
+ it("prompts DM policy before group access in quickstart", async () => {
107
+ const seen: string[] = [];
108
+ const prompter = createQuickstartPrompter({ seen, dmPolicy: "pairing" });
109
+
110
+ const result = await runSetup({
111
+ prompter,
112
+ options: { quickstartDefaults: true },
113
+ });
114
+
115
+ expectEnabledDefaultSetup(result, "pairing");
116
+ expect(seen.indexOf("Zalo Personal DM policy")).toBeGreaterThanOrEqual(0);
117
+ expect(seen.indexOf("Configure Zalo groups access?")).toBeGreaterThanOrEqual(0);
118
+ expect(seen.indexOf("Zalo Personal DM policy")).toBeLessThan(
119
+ seen.indexOf("Configure Zalo groups access?"),
120
+ );
121
+ });
122
+
123
+ it("allows an empty quickstart DM allowlist with a warning", async () => {
124
+ const note = vi.fn(async (_message: string, _title?: string) => {});
125
+ const prompter = createQuickstartPrompter({
126
+ note,
127
+ dmPolicy: "allowlist",
128
+ textByMessage: {
129
+ "Zalouser allowFrom (name or user id)": "",
130
+ },
131
+ });
132
+
133
+ const result = await runSetup({
134
+ prompter,
135
+ options: { quickstartDefaults: true },
136
+ });
137
+
138
+ expectEnabledDefaultSetup(result, "allowlist");
139
+ expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]);
140
+ expect(
141
+ note.mock.calls.some(([message]) => message.includes("No DM allowlist entries added yet.")),
142
+ ).toBe(true);
143
+ });
144
+
145
+ it("allows an empty group allowlist with a warning", async () => {
146
+ const note = vi.fn(async (_message: string, _title?: string) => {});
147
+ const prompter = createQuickstartPrompter({
148
+ note,
149
+ groupAccess: true,
150
+ groupPolicy: "allowlist",
151
+ textByMessage: {
152
+ "Zalo groups allowlist (comma-separated)": "",
153
+ },
154
+ });
155
+
156
+ const result = await runSetup({ prompter });
157
+
158
+ expect(result.cfg.channels?.zalouser?.groupPolicy).toBe("allowlist");
159
+ expect(result.cfg.channels?.zalouser?.groups).toEqual({});
160
+ expect(
161
+ note.mock.calls.some(([message]) =>
162
+ message.includes("No group allowlist entries added yet."),
163
+ ),
164
+ ).toBe(true);
165
+ });
166
+
167
+ it("writes canonical enabled entries for configured groups", async () => {
168
+ const prompter = createQuickstartPrompter({
169
+ groupAccess: true,
170
+ groupPolicy: "allowlist",
171
+ textByMessage: {
172
+ "Zalo groups allowlist (comma-separated)": "Family, Work",
173
+ },
174
+ });
175
+
176
+ const result = await runSetup({ prompter });
177
+
178
+ expect(result.cfg.channels?.zalouser?.groups).toEqual({
179
+ Family: { enabled: true, requireMention: true },
180
+ Work: { enabled: true, requireMention: true },
181
+ });
182
+ });
183
+
184
+ it("preserves non-quickstart forceAllowFrom behavior", async () => {
185
+ const note = vi.fn(async (_message: string, _title?: string) => {});
186
+ const seen: string[] = [];
187
+ const prompter = createTestWizardPrompter({
188
+ note,
189
+ confirm: vi.fn(async ({ message }: { message: string }) => {
190
+ seen.push(message);
191
+ if (message === "Login via QR code now?") {
192
+ return false;
193
+ }
194
+ if (message === "Configure Zalo groups access?") {
195
+ return false;
196
+ }
197
+ return false;
198
+ }),
199
+ text: vi.fn(async ({ message }: { message: string }) => {
200
+ seen.push(message);
201
+ if (message === "Zalouser allowFrom (name or user id)") {
202
+ return "";
203
+ }
204
+ return "";
205
+ }) as ReturnType<typeof createTestWizardPrompter>["text"],
206
+ });
207
+
208
+ const result = await runSetup({ prompter, forceAllowFrom: true });
209
+
210
+ expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist");
211
+ expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]);
212
+ expect(seen).not.toContain("Zalo Personal DM policy");
213
+ expect(seen).toContain("Zalouser allowFrom (name or user id)");
214
+ expect(
215
+ note.mock.calls.some(([message]) => message.includes("No DM allowlist entries added yet.")),
216
+ ).toBe(true);
217
+ });
218
+
219
+ it("allowlists the plugin when a plugin allowlist already exists", async () => {
220
+ const prompter = createTestWizardPrompter({
221
+ confirm: vi.fn(async ({ message }: { message: string }) => {
222
+ if (message === "Login via QR code now?") {
223
+ return false;
224
+ }
225
+ if (message === "Configure Zalo groups access?") {
226
+ return false;
227
+ }
228
+ return false;
229
+ }),
230
+ });
231
+
232
+ const result = await runSetup({
233
+ cfg: {
234
+ plugins: {
235
+ allow: ["telegram"],
236
+ },
237
+ } as OpenClawConfig,
238
+ prompter,
239
+ });
240
+
241
+ expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
242
+ expect(result.cfg.plugins?.allow).toEqual(["telegram", "zalouser"]);
243
+ });
244
+
245
+ it("reads the named-account DM policy instead of the channel root", () => {
246
+ expect(
247
+ zalouserSetupWizard.dmPolicy?.getCurrent(
248
+ {
249
+ channels: {
250
+ zalouser: {
251
+ dmPolicy: "disabled",
252
+ accounts: {
253
+ work: {
254
+ profile: "work",
255
+ dmPolicy: "allowlist",
256
+ },
257
+ },
258
+ },
259
+ },
260
+ } as OpenClawConfig,
261
+ "work",
262
+ ),
263
+ ).toBe("allowlist");
264
+ });
265
+
266
+ it("reports account-scoped config keys for named accounts", () => {
267
+ expect(zalouserSetupWizard.dmPolicy?.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual(
268
+ {
269
+ policyKey: "channels.zalouser.accounts.work.dmPolicy",
270
+ allowFromKey: "channels.zalouser.accounts.work.allowFrom",
271
+ },
272
+ );
273
+ });
274
+
275
+ it("uses configured defaultAccount for omitted DM policy account context", () => {
276
+ const cfg = {
277
+ channels: {
278
+ zalouser: {
279
+ defaultAccount: "work",
280
+ dmPolicy: "disabled",
281
+ allowFrom: ["123456789"],
282
+ accounts: {
283
+ work: {
284
+ dmPolicy: "allowlist",
285
+ profile: "work-profile",
286
+ },
287
+ },
288
+ },
289
+ },
290
+ } as OpenClawConfig;
291
+
292
+ expect(zalouserSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
293
+ expect(zalouserSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
294
+ policyKey: "channels.zalouser.accounts.work.dmPolicy",
295
+ allowFromKey: "channels.zalouser.accounts.work.allowFrom",
296
+ });
297
+
298
+ const next = zalouserSetupWizard.dmPolicy?.setPolicy(cfg, "open");
299
+ expect(next?.channels?.zalouser?.dmPolicy).toBe("disabled");
300
+ const workAccount = next?.channels?.zalouser?.accounts?.work as
301
+ | { dmPolicy?: string; allowFrom?: Array<string | number> }
302
+ | undefined;
303
+ expect(workAccount?.dmPolicy).toBe("open");
304
+ });
305
+
306
+ it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => {
307
+ const next = zalouserSetupWizard.dmPolicy?.setPolicy(
308
+ {
309
+ channels: {
310
+ zalouser: {
311
+ allowFrom: ["123456789"],
312
+ accounts: {
313
+ work: {
314
+ profile: "work",
315
+ },
316
+ },
317
+ },
318
+ },
319
+ } as OpenClawConfig,
320
+ "open",
321
+ "work",
322
+ );
323
+
324
+ expect(next?.channels?.zalouser?.dmPolicy).toBeUndefined();
325
+ const workAccount = next?.channels?.zalouser?.accounts?.work as
326
+ | { dmPolicy?: string; allowFrom?: Array<string | number> }
327
+ | undefined;
328
+ expect(workAccount?.dmPolicy).toBe("open");
329
+ expect(workAccount?.allowFrom).toEqual(["123456789", "*"]);
330
+ });
331
+
332
+ it("shows the account-scoped current DM policy in quickstart notes", async () => {
333
+ const note = vi.fn(async (_message: string, _title?: string) => {});
334
+ const prompter = createQuickstartPrompter({ note, dmPolicy: "pairing" });
335
+
336
+ await runSetupWizardConfigure({
337
+ configure: zalouserConfigure,
338
+ cfg: {
339
+ channels: {
340
+ zalouser: {
341
+ dmPolicy: "disabled",
342
+ accounts: {
343
+ work: {
344
+ profile: "work",
345
+ dmPolicy: "allowlist",
346
+ allowFrom: ["123456789"],
347
+ },
348
+ },
349
+ },
350
+ },
351
+ } as OpenClawConfig,
352
+ prompter,
353
+ options: { quickstartDefaults: true },
354
+ accountOverrides: { zalouser: "work" },
355
+ });
356
+
357
+ expect(
358
+ note.mock.calls.some(([message]) =>
359
+ message.includes("Current: dmPolicy=allowlist, allowFrom=123456789"),
360
+ ),
361
+ ).toBe(true);
362
+ });
363
+ });