diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index ecfd27194b7..7d8560d5182 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -25,6 +25,7 @@ import { FeishuConfigSchema } from "./config-schema.js"; import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { getFeishuRuntime } from "./runtime.js"; +import { feishuSetupAdapter, feishuSetupWizard } from "./setup-surface.js"; import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; @@ -43,44 +44,6 @@ async function loadFeishuChannelRuntime() { return await import("./channel.runtime.js"); } -const feishuOnboarding = { - channel: "feishu", - getStatus: async (ctx) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.getStatus(ctx), - configure: async (ctx) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.configure(ctx), - dmPolicy: { - label: "Feishu", - channel: "feishu", - policyKey: "channels.feishu.dmPolicy", - allowFromKey: "channels.feishu.allowFrom", - getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - dmPolicy: policy, - }, - }, - }), - promptAllowFrom: async ({ cfg, prompter, accountId }) => - (await loadFeishuChannelRuntime()).feishuOnboardingAdapter.dmPolicy!.promptAllowFrom!({ - cfg, - prompter, - accountId, - }), - }, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - feishu: { ...cfg.channels?.feishu, enabled: false }, - }, - }), -} satisfies ChannelPlugin["onboarding"]; - function setFeishuNamedAccountEnabled( cfg: ClawdbotConfig, accountId: string, @@ -429,28 +392,8 @@ export const feishuPlugin: ChannelPlugin = { }); }, }, - setup: { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg, accountId }) => { - const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; - - if (isDefault) { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - enabled: true, - }, - }, - }; - } - - return setFeishuNamedAccountEnabled(cfg, accountId, true); - }, - }, - onboarding: feishuOnboarding, + setup: feishuSetupAdapter, + setupWizard: feishuSetupWizard, messaging: { normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined, targetResolver: { diff --git a/extensions/feishu/src/onboarding.status.test.ts b/extensions/feishu/src/onboarding.status.test.ts index eda2bafa242..4f3b853a1e2 100644 --- a/extensions/feishu/src/onboarding.status.test.ts +++ b/extensions/feishu/src/onboarding.status.test.ts @@ -1,10 +1,16 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu"; import { describe, expect, it } from "vitest"; -import { feishuOnboardingAdapter } from "./onboarding.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { feishuPlugin } from "./channel.js"; -describe("feishu onboarding status", () => { +const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: feishuPlugin, + wizard: feishuPlugin.setupWizard!, +}); + +describe("feishu setup wizard status", () => { it("treats SecretRef appSecret as configured when appId is present", async () => { - const status = await feishuOnboardingAdapter.getStatus({ + const status = await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { diff --git a/extensions/feishu/src/onboarding.test.ts b/extensions/feishu/src/onboarding.test.ts index d3ace4faae0..2a444964442 100644 --- a/extensions/feishu/src/onboarding.test.ts +++ b/extensions/feishu/src/onboarding.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; vi.mock("./probe.js", () => ({ probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })), })); -import { feishuOnboardingAdapter } from "./onboarding.js"; +import { feishuPlugin } from "./channel.js"; const baseConfigureContext = { runtime: {} as never, @@ -42,7 +43,7 @@ async function withEnvVars(values: Record, run: () = } async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) { - return await feishuOnboardingAdapter.getStatus({ + return await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { @@ -55,7 +56,12 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st }); } -describe("feishuOnboardingAdapter.configure", () => { +const feishuConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: feishuPlugin, + wizard: feishuPlugin.setupWizard!, +}); + +describe("feishu setup wizard", () => { it("does not throw when config appId/appSecret are SecretRef objects", async () => { const text = vi .fn() @@ -73,7 +79,7 @@ describe("feishuOnboardingAdapter.configure", () => { } as never; await expect( - feishuOnboardingAdapter.configure({ + feishuConfigureAdapter.configure({ cfg: { channels: { feishu: { @@ -89,9 +95,9 @@ describe("feishuOnboardingAdapter.configure", () => { }); }); -describe("feishuOnboardingAdapter.getStatus", () => { +describe("feishu setup wizard status", () => { it("does not fallback to top-level appId when account explicitly sets empty appId", async () => { - const status = await feishuOnboardingAdapter.getStatus({ + const status = await feishuConfigureAdapter.getStatus({ cfg: { channels: { feishu: { diff --git a/extensions/feishu/src/onboarding.ts b/extensions/feishu/src/setup-surface.ts similarity index 62% rename from extensions/feishu/src/onboarding.ts rename to extensions/feishu/src/setup-surface.ts index 24d3bbcc413..1191a08e4e9 100644 --- a/extensions/feishu/src/onboarding.ts +++ b/extensions/feishu/src/setup-surface.ts @@ -1,24 +1,22 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - ClawdbotConfig, - DmPolicy, - SecretInput, - WizardPrompter, -} from "openclaw/plugin-sdk/feishu"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, - DEFAULT_ACCOUNT_ID, - formatDocsLink, - hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitOnboardingEntries, -} from "openclaw/plugin-sdk/feishu"; -import { resolveFeishuCredentials } from "./accounts.js"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listFeishuAccountIds, resolveFeishuCredentials } from "./accounts.js"; import { probeFeishu } from "./probe.js"; import type { FeishuConfig } from "./types.js"; @@ -32,26 +30,117 @@ function normalizeString(value: unknown): string | undefined { return trimmed || undefined; } -function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "feishu", - dmPolicy, - }) as ClawdbotConfig; +function setFeishuNamedAccountEnabled( + cfg: OpenClawConfig, + accountId: string, + enabled: boolean, +): OpenClawConfig { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...feishuCfg, + accounts: { + ...feishuCfg?.accounts, + [accountId]: { + ...feishuCfg?.accounts?.[accountId], + enabled, + }, + }, + }, + }, + }; } -function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig { +function setFeishuDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as OpenClawConfig; +} + +function setFeishuAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { return setTopLevelChannelAllowFrom({ cfg, - channel: "feishu", + channel, allowFrom, - }) as ClawdbotConfig; + }) as OpenClawConfig; +} + +function setFeishuGroupPolicy( + cfg: OpenClawConfig, + groupPolicy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + return setTopLevelChannelGroupPolicy({ + cfg, + channel, + groupPolicy, + enabled: true, + }) as OpenClawConfig; +} + +function setFeishuGroupAllowFrom(cfg: OpenClawConfig, groupAllowFrom: string[]): OpenClawConfig { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + groupAllowFrom, + }, + }, + }; +} + +function isFeishuConfigured(cfg: OpenClawConfig): boolean { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + + const isAppIdConfigured = (value: unknown): boolean => { + const asString = normalizeString(value); + if (asString) { + return true; + } + if (!value || typeof value !== "object") { + return false; + } + const rec = value as Record; + const source = normalizeString(rec.source)?.toLowerCase(); + const id = normalizeString(rec.id); + if (source === "env" && id) { + return Boolean(normalizeString(process.env[id])); + } + return hasConfiguredSecretInput(value); + }; + + const topLevelConfigured = Boolean( + isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret), + ); + + const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { + if (!account || typeof account !== "object") { + return false; + } + const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); + const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); + const accountAppIdConfigured = hasOwnAppId + ? isAppIdConfigured((account as Record).appId) + : isAppIdConfigured(feishuCfg?.appId); + const accountSecretConfigured = hasOwnAppSecret + ? hasConfiguredSecretInput((account as Record).appSecret) + : hasConfiguredSecretInput(feishuCfg?.appSecret); + return Boolean(accountAppIdConfigured && accountSecretConfigured); + }); + + return topLevelConfigured || accountConfigured; } async function promptFeishuAllowFrom(params: { - cfg: ClawdbotConfig; - prompter: WizardPrompter; -}): Promise { + cfg: OpenClawConfig; + prompter: Parameters>[0]["prompter"]; +}): Promise { const existing = params.cfg.channels?.feishu?.allowFrom ?? []; await params.prompter.note( [ @@ -82,7 +171,9 @@ async function promptFeishuAllowFrom(params: { } } -async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise { +async function noteFeishuCredentialHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "1) Go to Feishu Open Platform (open.feishu.cn)", @@ -98,131 +189,82 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise } async function promptFeishuAppId(params: { - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; initialValue?: string; }): Promise { - const appId = String( + return String( await params.prompter.text({ message: "Enter Feishu App ID", initialValue: params.initialValue, validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - return appId; } -function setFeishuGroupPolicy( - cfg: ClawdbotConfig, - groupPolicy: "open" | "allowlist" | "disabled", -): ClawdbotConfig { - return setTopLevelChannelGroupPolicy({ - cfg, - channel: "feishu", - groupPolicy, - enabled: true, - }) as ClawdbotConfig; -} - -function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig { - return { - ...cfg, - channels: { - ...cfg.channels, - feishu: { - ...cfg.channels?.feishu, - groupAllowFrom, - }, - }, - }; -} - -const dmPolicy: ChannelOnboardingDmPolicy = { +const feishuDmPolicy: ChannelOnboardingDmPolicy = { label: "Feishu", channel, policyKey: "channels.feishu.dmPolicy", allowFromKey: "channels.feishu.allowFrom", getCurrent: (cfg) => (cfg.channels?.feishu as FeishuConfig | undefined)?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setFeishuDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: promptFeishuAllowFrom, }; -export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; - - const isAppIdConfigured = (value: unknown): boolean => { - const asString = normalizeString(value); - if (asString) { - return true; - } - if (!value || typeof value !== "object") { - return false; - } - const rec = value as Record; - const source = normalizeString(rec.source)?.toLowerCase(); - const id = normalizeString(rec.id); - if (source === "env" && id) { - return Boolean(normalizeString(process.env[id])); - } - return hasConfiguredSecretInput(value); - }; - - const topLevelConfigured = Boolean( - isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret), - ); - - const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => { - if (!account || typeof account !== "object") { - return false; - } - const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId"); - const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret"); - const accountAppIdConfigured = hasOwnAppId - ? isAppIdConfigured((account as Record).appId) - : isAppIdConfigured(feishuCfg?.appId); - const accountSecretConfigured = hasOwnAppSecret - ? hasConfiguredSecretInput((account as Record).appSecret) - : hasConfiguredSecretInput(feishuCfg?.appSecret); - return Boolean(accountAppIdConfigured && accountSecretConfigured); - }); - - const configured = topLevelConfigured || accountConfigured; - const resolvedCredentials = resolveFeishuCredentials(feishuCfg, { - allowUnresolvedSecretRef: true, - }); - - // Try to probe if configured - let probeResult = null; - if (configured && resolvedCredentials) { - try { - probeResult = await probeFeishu(resolvedCredentials); - } catch { - // Ignore probe errors - } +export const feishuSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg, accountId }) => { + const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID; + if (isDefault) { + return { + ...cfg, + channels: { + ...cfg.channels, + feishu: { + ...cfg.channels?.feishu, + enabled: true, + }, + }, + }; } - - const statusLines: string[] = []; - if (!configured) { - statusLines.push("Feishu: needs app credentials"); - } else if (probeResult?.ok) { - statusLines.push( - `Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`, - ); - } else { - statusLines.push("Feishu: configured (connection not verified)"); - } - - return { - channel, - configured, - statusLines, - selectionHint: configured ? "configured" : "needs app creds", - quickstartScore: configured ? 2 : 0, - }; + return setFeishuNamedAccountEnabled(cfg, accountId, true); }, +}; - configure: async ({ cfg, prompter }) => { +export const feishuSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs app credentials", + configuredHint: "configured", + unconfiguredHint: "needs app creds", + configuredScore: 2, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => isFeishuConfigured(cfg), + resolveStatusLines: async ({ cfg, configured }) => { + const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; + const resolvedCredentials = resolveFeishuCredentials(feishuCfg, { + allowUnresolvedSecretRef: true, + }); + let probeResult = null; + if (configured && resolvedCredentials) { + try { + probeResult = await probeFeishu(resolvedCredentials); + } catch {} + } + if (!configured) { + return ["Feishu: needs app credentials"]; + } + if (probeResult?.ok) { + return [`Feishu: connected as ${probeResult.botName ?? probeResult.botOpenId ?? "bot"}`]; + } + return ["Feishu: configured (connection not verified)"]; + }, + }, + credentials: [], + finalize: async ({ cfg, prompter, options }) => { const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined; const resolved = resolveFeishuCredentials(feishuCfg, { allowUnresolvedSecretRef: true, @@ -252,6 +294,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "feishu", credentialLabel: "App Secret", + secretInputMode: options?.secretInputMode, accountConfigured: appSecretPromptState.accountConfigured, canUseEnv: appSecretPromptState.canUseEnv, hasConfigToken: appSecretPromptState.hasConfigToken, @@ -293,7 +336,6 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; - // Test connection try { const probe = await probeFeishu({ appId, @@ -340,19 +382,17 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { if (connectionMode === "webhook") { const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined) ?.verificationToken; - const verificationTokenPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: hasConfiguredSecretInput(currentVerificationToken), - hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), - allowEnv: false, - }); const verificationTokenResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu-webhook", credentialLabel: "verification token", - accountConfigured: verificationTokenPromptState.accountConfigured, - canUseEnv: verificationTokenPromptState.canUseEnv, - hasConfigToken: verificationTokenPromptState.hasConfigToken, + secretInputMode: options?.secretInputMode, + ...buildSingleChannelSecretPromptState({ + accountConfigured: hasConfiguredSecretInput(currentVerificationToken), + hasConfigToken: hasConfiguredSecretInput(currentVerificationToken), + allowEnv: false, + }), envPrompt: "", keepPrompt: "Feishu verification token already configured. Keep it?", inputPrompt: "Enter Feishu verification token", @@ -370,20 +410,19 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } + const currentEncryptKey = (next.channels?.feishu as FeishuConfig | undefined)?.encryptKey; - const encryptKeyPromptState = buildSingleChannelSecretPromptState({ - accountConfigured: hasConfiguredSecretInput(currentEncryptKey), - hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), - allowEnv: false, - }); const encryptKeyResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "feishu-webhook", credentialLabel: "encrypt key", - accountConfigured: encryptKeyPromptState.accountConfigured, - canUseEnv: encryptKeyPromptState.canUseEnv, - hasConfigToken: encryptKeyPromptState.hasConfigToken, + secretInputMode: options?.secretInputMode, + ...buildSingleChannelSecretPromptState({ + accountConfigured: hasConfiguredSecretInput(currentEncryptKey), + hasConfigToken: hasConfiguredSecretInput(currentEncryptKey), + allowEnv: false, + }), envPrompt: "", keepPrompt: "Feishu encrypt key already configured. Keep it?", inputPrompt: "Enter Feishu encrypt key", @@ -401,6 +440,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }, }; } + const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath; const webhookPath = String( await prompter.text({ @@ -421,7 +461,6 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { }; } - // Domain selection const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu"; const domain = await prompter.select({ message: "Which Feishu domain?", @@ -431,21 +470,18 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { ], initialValue: currentDomain, }); - if (domain) { - next = { - ...next, - channels: { - ...next.channels, - feishu: { - ...next.channels?.feishu, - domain: domain as "feishu" | "lark", - }, + next = { + ...next, + channels: { + ...next.channels, + feishu: { + ...next.channels?.feishu, + domain: domain as "feishu" | "lark", }, - }; - } + }, + }; - // Group policy - const groupPolicy = await prompter.select({ + const groupPolicy = (await prompter.select({ message: "Group chat policy", options: [ { value: "allowlist", label: "Allowlist - only respond in specific groups" }, @@ -453,12 +489,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { { value: "disabled", label: "Disabled - don't respond in groups" }, ], initialValue: (next.channels?.feishu as FeishuConfig | undefined)?.groupPolicy ?? "allowlist", - }); - if (groupPolicy) { - next = setFeishuGroupPolicy(next, groupPolicy as "open" | "allowlist" | "disabled"); - } + })) as "allowlist" | "open" | "disabled"; + next = setFeishuGroupPolicy(next, groupPolicy); - // Group allowlist if needed if (groupPolicy === "allowlist") { const existing = (next.channels?.feishu as FeishuConfig | undefined)?.groupAllowFrom ?? []; const entry = await prompter.text({ @@ -474,11 +507,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = { } } - return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; + return { cfg: next }; }, - - dmPolicy, - + dmPolicy: feishuDmPolicy, disable: (cfg) => ({ ...cfg, channels: { diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index b374ecfbd63..adba1f8bd93 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -13,8 +13,6 @@ import type { OpenClawConfig, } from "openclaw/plugin-sdk/zalo"; import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, buildBaseAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, @@ -23,9 +21,7 @@ import { deleteAccountFromConfigSection, chunkTextForOutbound, formatAllowFromLowercase, - migrateBaseNameToDefaultAccount, listDirectoryUserEntriesFromAllowFrom, - normalizeAccountId, isNumericTargetId, PAIRING_APPROVED_MESSAGE, resolveOutboundMediaUrls, @@ -40,11 +36,11 @@ import { } from "./accounts.js"; import { zaloMessageActions } from "./actions.js"; import { ZaloConfigSchema } from "./config-schema.js"; -import { zaloOnboardingAdapter } from "./onboarding.js"; import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { sendMessageZalo } from "./send.js"; +import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js"; import { collectZaloStatusIssues } from "./status-issues.js"; const meta = { @@ -92,7 +88,8 @@ export const zaloDock: ChannelDock = { export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, - onboarding: zaloOnboardingAdapter, + setup: zaloSetupAdapter, + setupWizard: zaloSetupWizard, capabilities: { chatTypes: ["direct", "group"], media: true, @@ -212,53 +209,6 @@ export const zaloPlugin: ChannelPlugin = { }, listGroups: async () => [], }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalo", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "ZALO_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Zalo requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalo", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "zalo", - }) - : namedConfig; - const patch = input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: "zalo", - accountId, - patch, - }); - }, - }, pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), diff --git a/extensions/zalo/src/onboarding.status.test.ts b/extensions/zalo/src/onboarding.status.test.ts index fed5ea95f89..4db31735c94 100644 --- a/extensions/zalo/src/onboarding.status.test.ts +++ b/extensions/zalo/src/onboarding.status.test.ts @@ -1,10 +1,16 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/zalo"; import { describe, expect, it } from "vitest"; -import { zaloOnboardingAdapter } from "./onboarding.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { zaloPlugin } from "./channel.js"; -describe("zalo onboarding status", () => { +const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zaloPlugin, + wizard: zaloPlugin.setupWizard!, +}); + +describe("zalo setup wizard status", () => { it("treats SecretRef botToken as configured", async () => { - const status = await zaloOnboardingAdapter.getStatus({ + const status = await zaloConfigureAdapter.getStatus({ cfg: { channels: { zalo: { diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts new file mode 100644 index 00000000000..2353a66e453 --- /dev/null +++ b/extensions/zalo/src/setup-surface.test.ts @@ -0,0 +1,60 @@ +import type { OpenClawConfig, RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/zalo"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { zaloPlugin } from "./channel.js"; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async () => "plaintext") as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const zaloConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zaloPlugin, + wizard: zaloPlugin.setupWizard!, +}); + +describe("zalo setup wizard", () => { + it("configures a polling token flow", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Zalo bot token") { + return "12345689:abc-xyz"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Use webhook mode for Zalo?") { + return false; + } + return false; + }), + }); + + const runtime: RuntimeEnv = createRuntimeEnv(); + + const result = await zaloConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: { secretInputMode: "plaintext" }, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalo?.enabled).toBe(true); + expect(result.cfg.channels?.zalo?.botToken).toBe("12345689:abc-xyz"); + expect(result.cfg.channels?.zalo?.webhookUrl).toBeUndefined(); + }); +}); diff --git a/extensions/zalo/src/onboarding.ts b/extensions/zalo/src/setup-surface.ts similarity index 65% rename from extensions/zalo/src/onboarding.ts rename to extensions/zalo/src/setup-surface.ts index 4c6f7cbe4de..643c2f6ff76 100644 --- a/extensions/zalo/src/onboarding.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -1,21 +1,23 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - SecretInput, - WizardPrompter, -} from "openclaw/plugin-sdk/zalo"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { buildSingleChannelSecretPromptState, - DEFAULT_ACCOUNT_ID, - hasConfiguredSecretInput, mergeAllowFromEntries, - normalizeAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/zalo"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; const channel = "zalo" as const; @@ -28,7 +30,7 @@ function setZaloDmPolicy( ) { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "zalo", + channel, dmPolicy, }) as OpenClawConfig; } @@ -108,14 +110,16 @@ function setZaloUpdateMode( } as OpenClawConfig; } -async function noteZaloTokenHelp(prompter: WizardPrompter): Promise { +async function noteZaloTokenHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "1) Open Zalo Bot Platform: https://bot.zaloplatforms.com", "2) Create a bot and get the token", "3) Token looks like 12345689:abc-xyz", "Tip: you can also set ZALO_BOT_TOKEN in your env.", - "Docs: https://docs.openclaw.ai/channels/zalo", + `Docs: ${formatDocsLink("/channels/zalo", "zalo")}`, ].join("\n"), "Zalo bot token", ); @@ -123,7 +127,7 @@ async function noteZaloTokenHelp(prompter: WizardPrompter): Promise { async function promptZaloAllowFrom(params: { cfg: OpenClawConfig; - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -183,76 +187,111 @@ async function promptZaloAllowFrom(params: { } as OpenClawConfig; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const zaloDmPolicy: ChannelOnboardingDmPolicy = { label: "Zalo", channel, policyKey: "channels.zalo.dmPolicy", allowFromKey: "channels.zalo.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing", - setPolicy: (cfg, policy) => setZaloDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultZaloAccountId(cfg); - return promptZaloAllowFrom({ - cfg: cfg, + : resolveDefaultZaloAccountId(cfg as OpenClawConfig); + return await promptZaloAllowFrom({ + cfg: cfg as OpenClawConfig, prompter, accountId: id, }); }, }; -export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - dmPolicy, - getStatus: async ({ cfg }) => { - const configured = listZaloAccountIds(cfg).some((accountId) => { - const account = resolveZaloAccount({ - cfg: cfg, - accountId, - allowUnresolvedSecretRef: true, - }); - return ( - Boolean(account.token) || - hasConfiguredSecretInput(account.config.botToken) || - Boolean(account.config.tokenFile?.trim()) - ); - }); - return { - channel, - configured, - statusLines: [`Zalo: ${configured ? "configured" : "needs token"}`], - selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly", - quickstartScore: configured ? 1 : 10, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg); - const zaloAccountId = await resolveAccountIdForConfigure({ +export const zaloSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ cfg, - prompter, - label: "Zalo", - accountOverride: accountOverrides.zalo, - shouldPromptAccountIds, - listAccountIds: listZaloAccountIds, - defaultAccountId: defaultZaloAccountId, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "ZALO_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Zalo requires token or --token-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + const patch = input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch, + }); + }, +}; +export const zaloSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "recommended · configured", + unconfiguredHint: "recommended · newcomer-friendly", + configuredScore: 1, + unconfiguredScore: 10, + resolveConfigured: ({ cfg }) => + listZaloAccountIds(cfg).some((accountId) => { + const account = resolveZaloAccount({ + cfg, + accountId, + allowUnresolvedSecretRef: true, + }); + return ( + Boolean(account.token) || + hasConfiguredSecretInput(account.config.botToken) || + Boolean(account.config.tokenFile?.trim()) + ); + }), + resolveStatusLines: ({ cfg, configured }) => { + void cfg; + return [`Zalo: ${configured ? "configured" : "needs token"}`]; + }, + }, + credentials: [], + finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => { let next = cfg; const resolvedAccount = resolveZaloAccount({ cfg: next, - accountId: zaloAccountId, + accountId, allowUnresolvedSecretRef: true, }); const accountConfigured = Boolean(resolvedAccount.token); - const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID; + const allowEnv = accountId === DEFAULT_ACCOUNT_ID; const hasConfigToken = Boolean( hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile, ); @@ -261,6 +300,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "zalo", credentialLabel: "bot token", + secretInputMode: options?.secretInputMode, accountConfigured, hasConfigToken, allowEnv, @@ -270,43 +310,43 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { inputPrompt: "Enter Zalo bot token", preferredEnvVar: "ZALO_BOT_TOKEN", onMissingConfigured: async () => await noteZaloTokenHelp(prompter), - applyUseEnv: async (cfg) => - zaloAccountId === DEFAULT_ACCOUNT_ID + applyUseEnv: async (currentCfg) => + accountId === DEFAULT_ACCOUNT_ID ? ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, }, }, } as OpenClawConfig) - : cfg, - applySet: async (cfg, value) => - zaloAccountId === DEFAULT_ACCOUNT_ID + : currentCfg, + applySet: async (currentCfg, value) => + accountId === DEFAULT_ACCOUNT_ID ? ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, botToken: value, }, }, } as OpenClawConfig) : ({ - ...cfg, + ...currentCfg, channels: { - ...cfg.channels, + ...currentCfg.channels, zalo: { - ...cfg.channels?.zalo, + ...currentCfg.channels?.zalo, enabled: true, accounts: { - ...cfg.channels?.zalo?.accounts, - [zaloAccountId]: { - ...cfg.channels?.zalo?.accounts?.[zaloAccountId], + ...currentCfg.channels?.zalo?.accounts, + [accountId]: { + ...currentCfg.channels?.zalo?.accounts?.[accountId], enabled: true, botToken: value, }, @@ -337,11 +377,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { return "/zalo-webhook"; } })(); + let webhookSecretResult = await promptSingleChannelSecretInput({ cfg: next, prompter, providerHint: "zalo-webhook", credentialLabel: "webhook secret", + secretInputMode: options?.secretInputMode, ...buildSingleChannelSecretPromptState({ accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret), hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret), @@ -363,6 +405,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { prompter, providerHint: "zalo-webhook", credentialLabel: "webhook secret", + secretInputMode: options?.secretInputMode, ...buildSingleChannelSecretPromptState({ accountConfigured: false, hasConfigToken: false, @@ -386,24 +429,25 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = { ).trim(); next = setZaloUpdateMode( next, - zaloAccountId, + accountId, "webhook", webhookUrl, webhookSecret, webhookPath || undefined, ); } else { - next = setZaloUpdateMode(next, zaloAccountId, "polling"); + next = setZaloUpdateMode(next, accountId, "polling"); } if (forceAllowFrom) { next = await promptZaloAllowFrom({ cfg: next, prompter, - accountId: zaloAccountId, + accountId, }); } - return { cfg: next, accountId: zaloAccountId }; + return { cfg: next }; }, + dmPolicy: zaloDmPolicy, }; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 81fce5e3ab9..b7d103e9b6e 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -14,8 +14,6 @@ import type { GroupToolPolicyConfig, } from "openclaw/plugin-sdk/zalouser"; import { - applyAccountNameToChannelSection, - applySetupAccountConfigPatch, buildChannelSendResult, buildBaseAccountStatusSnapshot, buildChannelConfigSchema, @@ -24,7 +22,6 @@ import { formatAllowFromLowercase, isDangerousNameMatchingEnabled, isNumericTargetId, - migrateBaseNameToDefaultAccount, normalizeAccountId, sendPayloadWithChunkedTextAndMedia, setAccountEnabledInConfigSection, @@ -41,11 +38,11 @@ import { import { ZalouserConfigSchema } from "./config-schema.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; import { resolveZalouserReactionMessageIds } from "./message-sid.js"; -import { zalouserOnboardingAdapter } from "./onboarding.js"; import { probeZalouser } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; +import { zalouserSetupAdapter, zalouserSetupWizard } from "./setup-surface.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; import { listZaloFriendsMatching, @@ -332,7 +329,8 @@ export const zalouserDock: ChannelDock = { export const zalouserPlugin: ChannelPlugin = { id: "zalouser", meta, - onboarding: zalouserOnboardingAdapter, + setup: zalouserSetupAdapter, + setupWizard: zalouserSetupWizard, capabilities: { chatTypes: ["direct", "group"], media: true, @@ -407,38 +405,6 @@ export const zalouserPlugin: ChannelPlugin = { resolveReplyToMode: () => "off", }, actions: zalouserMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalouser", - accountId, - name, - }), - validateInput: () => null, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "zalouser", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "zalouser", - }) - : namedConfig; - return applySetupAccountConfigPatch({ - cfg: next, - channelKey: "zalouser", - accountId, - patch: {}, - }); - }, - }, messaging: { normalizeTarget: (raw) => normalizePrefixedTarget(raw), targetResolver: { diff --git a/extensions/zalouser/src/setup-surface.test.ts b/extensions/zalouser/src/setup-surface.test.ts new file mode 100644 index 00000000000..d28fd8f0ccc --- /dev/null +++ b/extensions/zalouser/src/setup-surface.test.ts @@ -0,0 +1,86 @@ +import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/zalouser"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; + +vi.mock("./zalo-js.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkZaloAuthenticated: vi.fn(async () => false), + logoutZaloProfile: vi.fn(async () => {}), + startZaloQrLogin: vi.fn(async () => ({ + message: "qr pending", + qrDataUrl: undefined, + })), + waitForZaloQrLogin: vi.fn(async () => ({ + connected: false, + message: "login pending", + })), + resolveZaloAllowFromEntries: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + resolveZaloGroupsByEntries: vi.fn(async ({ entries }: { entries: string[] }) => + entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })), + ), + }; +}); + +import { zalouserPlugin } from "./channel.js"; + +const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { + const first = params.options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; +}; + +function createPrompter(overrides: Partial): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: selectFirstOption as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const zalouserConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: zalouserPlugin, + wizard: zalouserPlugin.setupWizard!, +}); + +describe("zalouser setup wizard", () => { + it("enables the account without forcing QR login", async () => { + const runtime = createRuntimeEnv(); + const prompter = createPrompter({ + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Login via QR code now?") { + return false; + } + if (message === "Configure Zalo groups access?") { + return false; + } + return false; + }), + }); + + const result = await zalouserConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.zalouser?.enabled).toBe(true); + }); +}); diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/setup-surface.ts similarity index 57% rename from extensions/zalouser/src/onboarding.ts rename to extensions/zalouser/src/setup-surface.ts index d5b828b6711..b091ed37947 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/setup-surface.ts @@ -1,19 +1,20 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - WizardPrompter, -} from "openclaw/plugin-sdk/zalouser"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; import { - DEFAULT_ACCOUNT_ID, - formatResolvedUnresolvedNote, mergeAllowFromEntries, - normalizeAccountId, - patchScopedAccountConfig, - promptChannelAccessConfig, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, -} from "openclaw/plugin-sdk/zalouser"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + applySetupAccountConfigPatch, + migrateBaseNameToDefaultAccount, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; import { listZalouserAccountIds, resolveDefaultZalouserAccountId, @@ -52,19 +53,42 @@ function setZalouserDmPolicy( ): OpenClawConfig { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "zalouser", + channel, dmPolicy, }) as OpenClawConfig; } -async function noteZalouserHelp(prompter: WizardPrompter): Promise { +function setZalouserGroupPolicy( + cfg: OpenClawConfig, + accountId: string, + groupPolicy: "open" | "allowlist" | "disabled", +): OpenClawConfig { + return setZalouserAccountScopedConfig(cfg, accountId, { + groupPolicy, + }); +} + +function setZalouserGroupAllowlist( + cfg: OpenClawConfig, + accountId: string, + groupKeys: string[], +): OpenClawConfig { + const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); + return setZalouserAccountScopedConfig(cfg, accountId, { + groups, + }); +} + +async function noteZalouserHelp( + prompter: Parameters>[0]["prompter"], +): Promise { await prompter.note( [ "Zalo Personal Account login via QR code.", "", "This plugin uses zca-js directly (no external CLI dependency).", "", - "Docs: https://docs.openclaw.ai/channels/zalouser", + `Docs: ${formatDocsLink("/channels/zalouser", "zalouser")}`, ].join("\n"), "Zalo Personal Setup", ); @@ -72,7 +96,7 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise { async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; - prompter: WizardPrompter; + prompter: Parameters>[0]["prompter"]; accountId: string; }): Promise { const { cfg, prompter, accountId } = params; @@ -125,94 +149,90 @@ async function promptZalouserAllowFrom(params: { } } -function setZalouserGroupPolicy( - cfg: OpenClawConfig, - accountId: string, - groupPolicy: "open" | "allowlist" | "disabled", -): OpenClawConfig { - return setZalouserAccountScopedConfig(cfg, accountId, { - groupPolicy, - }); -} - -function setZalouserGroupAllowlist( - cfg: OpenClawConfig, - accountId: string, - groupKeys: string[], -): OpenClawConfig { - const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); - return setZalouserAccountScopedConfig(cfg, accountId, { - groups, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { +const zalouserDmPolicy: ChannelOnboardingDmPolicy = { label: "Zalo Personal", channel, policyKey: "channels.zalouser.dmPolicy", allowFromKey: "channels.zalouser.allowFrom", getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as "pairing", - setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg, policy), + setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy), promptAllowFrom: async ({ cfg, prompter, accountId }) => { const id = accountId && normalizeAccountId(accountId) ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultZalouserAccountId(cfg); - return promptZalouserAllowFrom({ - cfg, + : resolveDefaultZalouserAccountId(cfg as OpenClawConfig); + return await promptZalouserAllowFrom({ + cfg: cfg as OpenClawConfig, prompter, accountId: id, }); }, }; -export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - dmPolicy, - getStatus: async ({ cfg }) => { - const ids = listZalouserAccountIds(cfg); - let configured = false; - for (const accountId of ids) { - const account = resolveZalouserAccountSync({ cfg, accountId }); - const isAuth = await checkZcaAuthenticated(account.profile); - if (isAuth) { - configured = true; - break; - } - } - return { - channel, - configured, - statusLines: [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`], - selectionHint: configured ? "recommended · logged in" : "recommended · QR login", - quickstartScore: configured ? 1 : 15, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultAccountId = resolveDefaultZalouserAccountId(cfg); - const accountId = await resolveAccountIdForConfigure({ +export const zalouserSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ cfg, - prompter, - label: "Zalo Personal", - accountOverride: accountOverrides.zalouser, - shouldPromptAccountIds, - listAccountIds: listZalouserAccountIds, - defaultAccountId, + channelKey: channel, + accountId, + name, + }), + validateInput: () => null, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + return applySetupAccountConfigPatch({ + cfg: next, + channelKey: channel, + accountId, + patch: {}, + }); + }, +}; +export const zalouserSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "logged in", + unconfiguredLabel: "needs QR login", + configuredHint: "recommended · logged in", + unconfiguredHint: "recommended · QR login", + configuredScore: 1, + unconfiguredScore: 15, + resolveConfigured: async ({ cfg }) => { + const ids = listZalouserAccountIds(cfg); + for (const accountId of ids) { + const account = resolveZalouserAccountSync({ cfg, accountId }); + if (await checkZcaAuthenticated(account.profile)) { + return true; + } + } + return false; + }, + resolveStatusLines: async ({ cfg, configured }) => { + void cfg; + return [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`]; + }, + }, + prepare: async ({ cfg, accountId, prompter }) => { let next = cfg; const account = resolveZalouserAccountSync({ cfg: next, accountId }); const alreadyAuthenticated = await checkZcaAuthenticated(account.profile); if (!alreadyAuthenticated) { await noteZalouserHelp(prompter); - const wantsLogin = await prompter.confirm({ message: "Login via QR code now?", initialValue: true, @@ -280,6 +300,56 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { { profile: account.profile, enabled: true }, ); + return { cfg: next }; + }, + credentials: [], + groupAccess: { + label: "Zalo groups", + placeholder: "Family, Work, 123456789", + currentPolicy: ({ cfg, accountId }) => + resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }) => + Object.keys(resolveZalouserAccountSync({ cfg, accountId }).config.groups ?? {}), + updatePrompt: ({ cfg, accountId }) => + Boolean(resolveZalouserAccountSync({ cfg, accountId }).config.groups), + setPolicy: ({ cfg, accountId, policy }) => + setZalouserGroupPolicy(cfg as OpenClawConfig, accountId, policy), + resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => { + if (entries.length === 0) { + return []; + } + const updatedAccount = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId }); + try { + const resolved = await resolveZaloGroupsByEntries({ + profile: updatedAccount.profile, + entries, + }); + const resolvedIds = resolved + .filter((entry) => entry.resolved && entry.id) + .map((entry) => entry.id as string); + const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input); + const keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; + const resolution = formatResolvedUnresolvedNote({ + resolved: resolvedIds, + unresolved, + }); + if (resolution) { + await prompter.note(resolution, "Zalo groups"); + } + return keys; + } catch (err) { + await prompter.note( + `Group lookup failed; keeping entries as typed. ${String(err)}`, + "Zalo groups", + ); + return entries.map((entry) => entry.trim()).filter(Boolean); + } + }, + applyAllowlist: ({ cfg, accountId, resolved }) => + setZalouserGroupAllowlist(cfg as OpenClawConfig, accountId, resolved as string[]), + }, + finalize: async ({ cfg, accountId, forceAllowFrom, prompter }) => { + let next = cfg; if (forceAllowFrom) { next = await promptZalouserAllowFrom({ cfg: next, @@ -287,54 +357,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { accountId, }); } - - const updatedAccount = resolveZalouserAccountSync({ cfg: next, accountId }); - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "Zalo groups", - currentPolicy: updatedAccount.config.groupPolicy ?? "allowlist", - currentEntries: Object.keys(updatedAccount.config.groups ?? {}), - placeholder: "Family, Work, 123456789", - updatePrompt: Boolean(updatedAccount.config.groups), - }); - - if (accessConfig) { - if (accessConfig.policy !== "allowlist") { - next = setZalouserGroupPolicy(next, accountId, accessConfig.policy); - } else { - let keys = accessConfig.entries; - if (accessConfig.entries.length > 0) { - try { - const resolved = await resolveZaloGroupsByEntries({ - profile: updatedAccount.profile, - entries: accessConfig.entries, - }); - const resolvedIds = resolved - .filter((entry) => entry.resolved && entry.id) - .map((entry) => entry.id as string); - const unresolved = resolved - .filter((entry) => !entry.resolved) - .map((entry) => entry.input); - keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)]; - const resolution = formatResolvedUnresolvedNote({ - resolved: resolvedIds, - unresolved, - }); - if (resolution) { - await prompter.note(resolution, "Zalo groups"); - } - } catch (err) { - await prompter.note( - `Group lookup failed; keeping entries as typed. ${String(err)}`, - "Zalo groups", - ); - } - } - next = setZalouserGroupPolicy(next, accountId, "allowlist"); - next = setZalouserGroupAllowlist(next, accountId, keys); - } - } - - return { cfg: next, accountId }; + return { cfg: next }; }, + dmPolicy: zalouserDmPolicy, }; diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 772cde76ff2..65f0773105b 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -13,10 +13,6 @@ export { logTypingFailure } from "../channels/logging.js"; export type { AllowlistMatch } from "../channels/plugins/allowlist-match.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { createActionGate } from "../agents/tools/common.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -66,6 +62,10 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export { evaluateSenderGroupAccessForPolicy } from "./group-access.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export { + feishuSetupAdapter, + feishuSetupWizard, +} from "../../extensions/feishu/src/setup-surface.js"; export { buildAgentMediaPayload } from "./agent-media-payload.js"; export { readJsonFileWithFallback } from "./json-store.js"; export { createScopedPairingAccess } from "./pairing-access.js"; diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index e13529f8c42..4323ae4eb6e 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -11,10 +11,6 @@ export { export { listDirectoryUserEntriesFromAllowFrom } from "../channels/plugins/directory-config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -22,7 +18,6 @@ export { promptAccountId, promptSingleChannelSecretInput, runSingleChannelSecretStep, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; @@ -69,6 +64,7 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./allow-from.js"; +export { zaloSetupAdapter, zaloSetupWizard } from "../../extensions/zalo/src/setup-surface.js"; export { resolveDirectDmAuthorizationOutcome, resolveSenderCommandAuthorizationWithRuntime, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index 4b8ef88d06d..47fc787570c 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -11,16 +11,10 @@ export { } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId, - resolveAccountIdForConfigure, setTopLevelChannelDmPolicyWithAllowFrom, } from "../channels/plugins/onboarding/helpers.js"; export { @@ -61,6 +55,10 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; +export { + zalouserSetupAdapter, + zalouserSetupWizard, +} from "../../extensions/zalouser/src/setup-surface.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy,