@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,109 @@
1
+ import {
2
+ collectConditionalChannelFieldAssignments,
3
+ getChannelSurface,
4
+ hasOwnProperty,
5
+ type ResolverContext,
6
+ type SecretDefaults,
7
+ type SecretTargetRegistryEntry,
8
+ } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
9
+
10
+ export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [
11
+ {
12
+ id: "channels.zalo.accounts.*.botToken",
13
+ targetType: "channels.zalo.accounts.*.botToken",
14
+ configFile: "openclaw.json",
15
+ pathPattern: "channels.zalo.accounts.*.botToken",
16
+ secretShape: "secret_input",
17
+ expectedResolvedValue: "string",
18
+ includeInPlan: true,
19
+ includeInConfigure: true,
20
+ includeInAudit: true,
21
+ },
22
+ {
23
+ id: "channels.zalo.accounts.*.webhookSecret",
24
+ targetType: "channels.zalo.accounts.*.webhookSecret",
25
+ configFile: "openclaw.json",
26
+ pathPattern: "channels.zalo.accounts.*.webhookSecret",
27
+ secretShape: "secret_input",
28
+ expectedResolvedValue: "string",
29
+ includeInPlan: true,
30
+ includeInConfigure: true,
31
+ includeInAudit: true,
32
+ },
33
+ {
34
+ id: "channels.zalo.botToken",
35
+ targetType: "channels.zalo.botToken",
36
+ configFile: "openclaw.json",
37
+ pathPattern: "channels.zalo.botToken",
38
+ secretShape: "secret_input",
39
+ expectedResolvedValue: "string",
40
+ includeInPlan: true,
41
+ includeInConfigure: true,
42
+ includeInAudit: true,
43
+ },
44
+ {
45
+ id: "channels.zalo.webhookSecret",
46
+ targetType: "channels.zalo.webhookSecret",
47
+ configFile: "openclaw.json",
48
+ pathPattern: "channels.zalo.webhookSecret",
49
+ secretShape: "secret_input",
50
+ expectedResolvedValue: "string",
51
+ includeInPlan: true,
52
+ includeInConfigure: true,
53
+ includeInAudit: true,
54
+ },
55
+ ];
56
+
57
+ export function collectRuntimeConfigAssignments(params: {
58
+ config: { channels?: Record<string, unknown> };
59
+ defaults?: SecretDefaults;
60
+ context: ResolverContext;
61
+ }): void {
62
+ const resolved = getChannelSurface(params.config, "zalo");
63
+ if (!resolved) {
64
+ return;
65
+ }
66
+ const { channel: zalo, surface } = resolved;
67
+ collectConditionalChannelFieldAssignments({
68
+ channelKey: "zalo",
69
+ field: "botToken",
70
+ channel: zalo,
71
+ surface,
72
+ defaults: params.defaults,
73
+ context: params.context,
74
+ topLevelActiveWithoutAccounts: true,
75
+ topLevelInheritedAccountActive: ({ account, enabled }) =>
76
+ enabled && !hasOwnProperty(account, "botToken"),
77
+ accountActive: ({ enabled }) => enabled,
78
+ topInactiveReason: "no enabled Zalo surface inherits this top-level botToken.",
79
+ accountInactiveReason: "Zalo account is disabled.",
80
+ });
81
+ const baseWebhookUrl = typeof zalo.webhookUrl === "string" ? zalo.webhookUrl.trim() : "";
82
+ const accountWebhookUrl = (account: Record<string, unknown>) =>
83
+ hasOwnProperty(account, "webhookUrl")
84
+ ? typeof account.webhookUrl === "string"
85
+ ? account.webhookUrl.trim()
86
+ : ""
87
+ : baseWebhookUrl;
88
+ collectConditionalChannelFieldAssignments({
89
+ channelKey: "zalo",
90
+ field: "webhookSecret",
91
+ channel: zalo,
92
+ surface,
93
+ defaults: params.defaults,
94
+ context: params.context,
95
+ topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0,
96
+ topLevelInheritedAccountActive: ({ account, enabled }) =>
97
+ enabled && !hasOwnProperty(account, "webhookSecret") && accountWebhookUrl(account).length > 0,
98
+ accountActive: ({ account, enabled }) => enabled && accountWebhookUrl(account).length > 0,
99
+ topInactiveReason:
100
+ "no enabled Zalo webhook surface inherits this top-level webhookSecret (webhook mode is not active).",
101
+ accountInactiveReason:
102
+ "Zalo account is disabled or webhook mode is not active for this account.",
103
+ });
104
+ }
105
+
106
+ export const channelSecrets = {
107
+ secretTargetRegistryEntries,
108
+ collectRuntimeConfigAssignments,
109
+ };
@@ -1,13 +1,5 @@
1
- import {
2
- buildSecretInputSchema,
3
- hasConfiguredSecretInput,
4
- normalizeResolvedSecretInputString,
5
- normalizeSecretInputString,
6
- } from "openclaw/plugin-sdk/zalo";
7
-
8
1
  export {
9
2
  buildSecretInputSchema,
10
- hasConfiguredSecretInput,
11
3
  normalizeResolvedSecretInputString,
12
4
  normalizeSecretInputString,
13
- };
5
+ } from "openclaw/plugin-sdk/secret-input";
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const sendMessageMock = vi.fn();
4
+ const sendPhotoMock = vi.fn();
5
+ const resolveZaloProxyFetchMock = vi.fn();
6
+
7
+ vi.mock("./api.js", () => ({
8
+ sendMessage: (...args: unknown[]) => sendMessageMock(...args),
9
+ sendPhoto: (...args: unknown[]) => sendPhotoMock(...args),
10
+ }));
11
+
12
+ vi.mock("./proxy.js", () => ({
13
+ resolveZaloProxyFetch: (...args: unknown[]) => resolveZaloProxyFetchMock(...args),
14
+ }));
15
+
16
+ import { sendMessageZalo, sendPhotoZalo } from "./send.js";
17
+
18
+ describe("zalo send", () => {
19
+ beforeEach(() => {
20
+ sendMessageMock.mockReset();
21
+ sendPhotoMock.mockReset();
22
+ resolveZaloProxyFetchMock.mockReset();
23
+ resolveZaloProxyFetchMock.mockReturnValue(undefined);
24
+ });
25
+
26
+ it("sends text messages through the message API", async () => {
27
+ sendMessageMock.mockResolvedValueOnce({
28
+ ok: true,
29
+ result: { message_id: "z-msg-1" },
30
+ });
31
+
32
+ const result = await sendMessageZalo("dm-chat-1", "hello there", {
33
+ token: "zalo-token",
34
+ });
35
+
36
+ expect(sendMessageMock).toHaveBeenCalledWith(
37
+ "zalo-token",
38
+ {
39
+ chat_id: "dm-chat-1",
40
+ text: "hello there",
41
+ },
42
+ undefined,
43
+ );
44
+ expect(sendPhotoMock).not.toHaveBeenCalled();
45
+ expect(result).toEqual({ ok: true, messageId: "z-msg-1" });
46
+ });
47
+
48
+ it("routes media-bearing sends through the photo API and uses text as caption", async () => {
49
+ sendPhotoMock.mockResolvedValueOnce({
50
+ ok: true,
51
+ result: { message_id: "z-photo-1" },
52
+ });
53
+
54
+ const result = await sendMessageZalo("dm-chat-2", "caption text", {
55
+ token: "zalo-token",
56
+ mediaUrl: "https://example.com/photo.jpg",
57
+ caption: "ignored fallback caption",
58
+ });
59
+
60
+ expect(sendPhotoMock).toHaveBeenCalledWith(
61
+ "zalo-token",
62
+ {
63
+ chat_id: "dm-chat-2",
64
+ photo: "https://example.com/photo.jpg",
65
+ caption: "caption text",
66
+ },
67
+ undefined,
68
+ );
69
+ expect(sendMessageMock).not.toHaveBeenCalled();
70
+ expect(result).toEqual({ ok: true, messageId: "z-photo-1" });
71
+ });
72
+
73
+ it("fails fast for missing token or blank photo URLs", async () => {
74
+ await expect(sendMessageZalo("dm-chat-3", "hello", {})).resolves.toEqual({
75
+ ok: false,
76
+ error: "No Zalo bot token configured",
77
+ });
78
+
79
+ await expect(
80
+ sendPhotoZalo("dm-chat-4", " ", {
81
+ token: "zalo-token",
82
+ }),
83
+ ).resolves.toEqual({
84
+ ok: false,
85
+ error: "No photo URL provided",
86
+ });
87
+
88
+ expect(sendMessageMock).not.toHaveBeenCalled();
89
+ expect(sendPhotoMock).not.toHaveBeenCalled();
90
+ });
91
+
92
+ it("sends cfg-backed media directly without hosted-media rewrites", async () => {
93
+ sendPhotoMock.mockResolvedValueOnce({
94
+ ok: true,
95
+ result: { message_id: "z-photo-2" },
96
+ });
97
+
98
+ const result = await sendPhotoZalo("dm-chat-5", "https://example.com/photo.jpg", {
99
+ cfg: {
100
+ channels: {
101
+ zalo: {
102
+ botToken: "zalo-token",
103
+ webhookUrl: "https://gateway.example.com/zalo-webhook",
104
+ },
105
+ },
106
+ } as never,
107
+ });
108
+
109
+ expect(sendPhotoMock).toHaveBeenCalledWith(
110
+ "zalo-token",
111
+ {
112
+ chat_id: "dm-chat-5",
113
+ photo: "https://example.com/photo.jpg",
114
+ caption: undefined,
115
+ },
116
+ undefined,
117
+ );
118
+ expect(result).toEqual({ ok: true, messageId: "z-photo-2" });
119
+ });
120
+ });
package/src/send.ts CHANGED
@@ -1,11 +1,12 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
2
3
  import { resolveZaloAccount } from "./accounts.js";
3
4
  import type { ZaloFetch } from "./api.js";
4
5
  import { sendMessage, sendPhoto } from "./api.js";
5
6
  import { resolveZaloProxyFetch } from "./proxy.js";
6
7
  import { resolveZaloToken } from "./token.js";
7
8
 
8
- export type ZaloSendOptions = {
9
+ type ZaloSendOptions = {
9
10
  token?: string;
10
11
  accountId?: string;
11
12
  cfg?: OpenClawConfig;
@@ -15,7 +16,7 @@ export type ZaloSendOptions = {
15
16
  proxy?: string;
16
17
  };
17
18
 
18
- export type ZaloSendResult = {
19
+ type ZaloSendResult = {
19
20
  ok: boolean;
20
21
  messageId?: string;
21
22
  error?: string;
@@ -39,7 +40,7 @@ async function runZaloSend(
39
40
  const result = toZaloSendResult(await send());
40
41
  return result.ok ? result : { ok: false, error: failureMessage };
41
42
  } catch (err) {
42
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
43
+ return { ok: false, error: formatErrorMessage(err) };
43
44
  }
44
45
  }
45
46
 
@@ -138,14 +139,15 @@ export async function sendPhotoZalo(
138
139
  }
139
140
 
140
141
  return await runZaloSend("Failed to send photo", () =>
141
- sendPhoto(
142
- context.token,
143
- {
144
- chat_id: context.chatId,
145
- photo: photoUrl.trim(),
146
- caption: options.caption?.slice(0, 2000),
147
- },
148
- context.fetcher,
149
- ),
142
+ (async () =>
143
+ sendPhoto(
144
+ context.token,
145
+ {
146
+ chat_id: context.chatId,
147
+ photo: photoUrl.trim(),
148
+ caption: options.caption?.slice(0, 2000),
149
+ },
150
+ context.fetcher,
151
+ ))(),
150
152
  );
151
153
  }
@@ -0,0 +1,32 @@
1
+ import {
2
+ buildChannelOutboundSessionRoute,
3
+ stripChannelTargetPrefix,
4
+ stripTargetKindPrefix,
5
+ type ChannelOutboundSessionRouteParams,
6
+ } from "openclaw/plugin-sdk/core";
7
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
8
+
9
+ export function resolveZaloOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
10
+ const trimmed = stripChannelTargetPrefix(params.target, "zalo", "zl");
11
+ if (!trimmed) {
12
+ return null;
13
+ }
14
+ const isGroup = normalizeLowercaseStringOrEmpty(trimmed).startsWith("group:");
15
+ const peerId = stripTargetKindPrefix(trimmed);
16
+ if (!peerId) {
17
+ return null;
18
+ }
19
+ return buildChannelOutboundSessionRoute({
20
+ cfg: params.cfg,
21
+ agentId: params.agentId,
22
+ channel: "zalo",
23
+ accountId: params.accountId,
24
+ peer: {
25
+ kind: isGroup ? "group" : "direct",
26
+ id: peerId,
27
+ },
28
+ chatType: isGroup ? "group" : "direct",
29
+ from: isGroup ? `zalo:group:${peerId}` : `zalo:${peerId}`,
30
+ to: `zalo:${peerId}`,
31
+ });
32
+ }
@@ -0,0 +1,94 @@
1
+ import {
2
+ DEFAULT_ACCOUNT_ID,
3
+ formatDocsLink,
4
+ mergeAllowFromEntries,
5
+ type ChannelSetupDmPolicy,
6
+ type ChannelSetupWizard,
7
+ type OpenClawConfig,
8
+ } from "openclaw/plugin-sdk/setup";
9
+ import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
10
+
11
+ type ZaloAccountSetupConfig = {
12
+ enabled?: boolean;
13
+ };
14
+
15
+ export async function noteZaloTokenHelp(
16
+ prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
17
+ ): Promise<void> {
18
+ await prompter.note(
19
+ [
20
+ "1) Open Zalo Bot Platform: https://bot.zaloplatforms.com",
21
+ "2) Create a bot and get the token",
22
+ "3) Token looks like 12345689:abc-xyz",
23
+ "Tip: you can also set ZALO_BOT_TOKEN in your env.",
24
+ `Docs: ${formatDocsLink("/channels/zalo", "zalo")}`,
25
+ ].join("\n"),
26
+ "Zalo bot token",
27
+ );
28
+ }
29
+
30
+ export async function promptZaloAllowFrom(params: {
31
+ cfg: OpenClawConfig;
32
+ prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
33
+ accountId?: string;
34
+ }): Promise<OpenClawConfig> {
35
+ const { cfg, prompter } = params;
36
+ const accountId = params.accountId ?? resolveDefaultZaloAccountId(cfg);
37
+ const resolved = resolveZaloAccount({ cfg, accountId });
38
+ const existingAllowFrom = resolved.config.allowFrom ?? [];
39
+ const entry = await prompter.text({
40
+ message: "Zalo allowFrom (user id)",
41
+ placeholder: "123456789",
42
+ initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
43
+ validate: (value) => {
44
+ const raw = (value ?? "").trim();
45
+ if (!raw) {
46
+ return "Required";
47
+ }
48
+ if (!/^\d+$/.test(raw)) {
49
+ return "Use a numeric Zalo user id";
50
+ }
51
+ return undefined;
52
+ },
53
+ });
54
+ const normalized = entry.trim();
55
+ const unique = mergeAllowFromEntries(existingAllowFrom, [normalized]);
56
+
57
+ if (accountId === DEFAULT_ACCOUNT_ID) {
58
+ return {
59
+ ...cfg,
60
+ channels: {
61
+ ...cfg.channels,
62
+ zalo: {
63
+ ...cfg.channels?.zalo,
64
+ enabled: true,
65
+ dmPolicy: "allowlist",
66
+ allowFrom: unique,
67
+ },
68
+ },
69
+ } as OpenClawConfig;
70
+ }
71
+
72
+ const currentAccount = cfg.channels?.zalo?.accounts?.[accountId] as
73
+ | ZaloAccountSetupConfig
74
+ | undefined;
75
+ return {
76
+ ...cfg,
77
+ channels: {
78
+ ...cfg.channels,
79
+ zalo: {
80
+ ...cfg.channels?.zalo,
81
+ enabled: true,
82
+ accounts: {
83
+ ...cfg.channels?.zalo?.accounts,
84
+ [accountId]: {
85
+ ...currentAccount,
86
+ enabled: currentAccount?.enabled ?? true,
87
+ dmPolicy: "allowlist",
88
+ allowFrom: unique,
89
+ },
90
+ },
91
+ },
92
+ },
93
+ } as OpenClawConfig;
94
+ }
@@ -0,0 +1,149 @@
1
+ import {
2
+ addWildcardAllowFrom,
3
+ createDelegatedSetupWizardProxy,
4
+ createPatchedAccountSetupAdapter,
5
+ createSetupInputPresenceValidator,
6
+ DEFAULT_ACCOUNT_ID,
7
+ normalizeAccountId,
8
+ type ChannelSetupDmPolicy,
9
+ type ChannelSetupWizard,
10
+ } from "openclaw/plugin-sdk/setup";
11
+ import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
12
+ import { promptZaloAllowFrom } from "./setup-allow-from.js";
13
+
14
+ const channel = "zalo" as const;
15
+
16
+ type ZaloAccountSetupConfig = {
17
+ enabled?: boolean;
18
+ dmPolicy?: string;
19
+ allowFrom?: Array<string | number> | ReadonlyArray<string | number>;
20
+ };
21
+
22
+ export const zaloSetupAdapter = createPatchedAccountSetupAdapter({
23
+ channelKey: channel,
24
+ validateInput: createSetupInputPresenceValidator({
25
+ defaultAccountOnlyEnvError: "ZALO_BOT_TOKEN can only be used for the default account.",
26
+ whenNotUseEnv: [
27
+ {
28
+ someOf: ["token", "tokenFile"],
29
+ message: "Zalo requires token or --token-file (or --use-env).",
30
+ },
31
+ ],
32
+ }),
33
+ buildPatch: (input) =>
34
+ input.useEnv
35
+ ? {}
36
+ : input.tokenFile
37
+ ? { tokenFile: input.tokenFile }
38
+ : input.token
39
+ ? { botToken: input.token }
40
+ : {},
41
+ });
42
+
43
+ export const zaloDmPolicy: ChannelSetupDmPolicy = {
44
+ label: "Zalo",
45
+ channel,
46
+ policyKey: "channels.zalo.dmPolicy",
47
+ allowFromKey: "channels.zalo.allowFrom",
48
+ resolveConfigKeys: (cfg, accountId) =>
49
+ (accountId ?? resolveDefaultZaloAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
50
+ ? {
51
+ policyKey: `channels.zalo.accounts.${accountId ?? resolveDefaultZaloAccountId(cfg)}.dmPolicy`,
52
+ allowFromKey: `channels.zalo.accounts.${accountId ?? resolveDefaultZaloAccountId(cfg)}.allowFrom`,
53
+ }
54
+ : {
55
+ policyKey: "channels.zalo.dmPolicy",
56
+ allowFromKey: "channels.zalo.allowFrom",
57
+ },
58
+ getCurrent: (cfg, accountId) =>
59
+ resolveZaloAccount({
60
+ cfg: cfg,
61
+ accountId: accountId ?? resolveDefaultZaloAccountId(cfg),
62
+ }).config.dmPolicy ?? "pairing",
63
+ setPolicy: (cfg, policy, accountId) => {
64
+ const resolvedAccountId =
65
+ accountId && normalizeAccountId(accountId)
66
+ ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
67
+ : resolveDefaultZaloAccountId(cfg);
68
+ const resolved = resolveZaloAccount({
69
+ cfg: cfg,
70
+ accountId: resolvedAccountId,
71
+ });
72
+ if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
73
+ return {
74
+ ...cfg,
75
+ channels: {
76
+ ...cfg.channels,
77
+ zalo: {
78
+ ...cfg.channels?.zalo,
79
+ enabled: true,
80
+ dmPolicy: policy,
81
+ ...(policy === "open"
82
+ ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
83
+ : {}),
84
+ },
85
+ },
86
+ };
87
+ }
88
+ const currentAccount = cfg.channels?.zalo?.accounts?.[resolvedAccountId] as
89
+ | ZaloAccountSetupConfig
90
+ | undefined;
91
+ return {
92
+ ...cfg,
93
+ channels: {
94
+ ...cfg.channels,
95
+ zalo: {
96
+ ...cfg.channels?.zalo,
97
+ enabled: true,
98
+ accounts: {
99
+ ...cfg.channels?.zalo?.accounts,
100
+ [resolvedAccountId]: {
101
+ ...currentAccount,
102
+ enabled: currentAccount?.enabled ?? true,
103
+ dmPolicy: policy,
104
+ ...(policy === "open"
105
+ ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
106
+ : {}),
107
+ },
108
+ },
109
+ },
110
+ },
111
+ };
112
+ },
113
+ promptAllowFrom: async ({ cfg, prompter, accountId }) =>
114
+ promptZaloAllowFrom({
115
+ cfg,
116
+ prompter,
117
+ accountId: accountId ?? resolveDefaultZaloAccountId(cfg),
118
+ }),
119
+ };
120
+
121
+ export function createZaloSetupWizardProxy(
122
+ loadWizard: () => Promise<ChannelSetupWizard>,
123
+ ): ChannelSetupWizard {
124
+ return createDelegatedSetupWizardProxy({
125
+ channel,
126
+ loadWizard,
127
+ status: {
128
+ configuredLabel: "configured",
129
+ unconfiguredLabel: "needs token",
130
+ configuredHint: "recommended · configured",
131
+ unconfiguredHint: "recommended · newcomer-friendly",
132
+ configuredScore: 1,
133
+ unconfiguredScore: 10,
134
+ },
135
+ credentials: [],
136
+ delegateFinalize: true,
137
+ dmPolicy: zaloDmPolicy,
138
+ disable: (cfg) => ({
139
+ ...cfg,
140
+ channels: {
141
+ ...cfg.channels,
142
+ zalo: {
143
+ ...cfg.channels?.zalo,
144
+ enabled: false,
145
+ },
146
+ },
147
+ }),
148
+ });
149
+ }
@@ -1,10 +1,19 @@
1
- import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo";
1
+ import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
2
2
  import { describe, expect, it } from "vitest";
3
- import { zaloOnboardingAdapter } from "./onboarding.js";
3
+ import type { OpenClawConfig } from "../runtime-api.js";
4
+ import { zaloSetupWizard } from "./setup-surface.js";
4
5
 
5
- describe("zalo onboarding status", () => {
6
+ const zaloGetStatus = createPluginSetupWizardStatus({
7
+ id: "zalo",
8
+ meta: {
9
+ label: "Zalo",
10
+ },
11
+ setupWizard: zaloSetupWizard,
12
+ } as never);
13
+
14
+ describe("zalo setup wizard status", () => {
6
15
  it("treats SecretRef botToken as configured", async () => {
7
- const status = await zaloOnboardingAdapter.getStatus({
16
+ const status = await zaloGetStatus({
8
17
  cfg: {
9
18
  channels: {
10
19
  zalo: {