refactor: move feishu zalo zalouser to setup wizard

This commit is contained in:
Peter Steinberger
2026-03-15 18:22:57 -07:00
parent 71a69e5337
commit 40be12db96
14 changed files with 675 additions and 560 deletions

View File

@@ -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<ResolvedFeishuAccount>["onboarding"];
function setFeishuNamedAccountEnabled(
cfg: ClawdbotConfig,
accountId: string,
@@ -429,28 +392,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
});
},
},
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: {

View File

@@ -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: {

View File

@@ -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<string, string | undefined>, 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: {

View File

@@ -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<string, unknown>;
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<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
: hasConfiguredSecretInput(feishuCfg?.appSecret);
return Boolean(accountAppIdConfigured && accountSecretConfigured);
});
return topLevelConfigured || accountConfigured;
}
async function promptFeishuAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
}): Promise<ClawdbotConfig> {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelOnboardingDmPolicy["promptAllowFrom"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
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<void> {
async function noteFeishuCredentialHelp(
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
): Promise<void> {
await prompter.note(
[
"1) Go to Feishu Open Platform (open.feishu.cn)",
@@ -98,131 +189,82 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
}
async function promptFeishuAppId(params: {
prompter: WizardPrompter;
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
initialValue?: string;
}): Promise<string> {
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<string, unknown>;
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<string, unknown>).appId)
: isAppIdConfigured(feishuCfg?.appId);
const accountSecretConfigured = hasOwnAppSecret
? hasConfiguredSecretInput((account as Record<string, unknown>).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: {

View File

@@ -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<ResolvedZaloAccount> = {
id: "zalo",
meta,
onboarding: zaloOnboardingAdapter,
setup: zaloSetupAdapter,
setupWizard: zaloSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
@@ -212,53 +209,6 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
},
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, ""),

View File

@@ -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: {

View File

@@ -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>): 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();
});
});

View File

@@ -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<void> {
async function noteZaloTokenHelp(
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
): Promise<void> {
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<void> {
async function promptZaloAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
prompter: Parameters<NonNullable<ChannelOnboardingDmPolicy["promptAllowFrom"]>>[0]["prompter"];
accountId: string;
}): Promise<OpenClawConfig> {
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,
};

View File

@@ -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<ResolvedZalouserAccount> = {
id: "zalouser",
meta,
onboarding: zalouserOnboardingAdapter,
setup: zalouserSetupAdapter,
setupWizard: zalouserSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
@@ -407,38 +405,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
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: {

View File

@@ -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<typeof import("./zalo-js.js")>();
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 <T>(params: { options: Array<{ value: T }> }): Promise<T> => {
const first = params.options[0];
if (!first) {
throw new Error("no options");
}
return first.value;
};
function createPrompter(overrides: Partial<WizardPrompter>): 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);
});
});

View File

@@ -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<void> {
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<NonNullable<ChannelSetupWizard["prepare"]>>[0]["prompter"],
): Promise<void> {
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<void> {
async function promptZalouserAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
prompter: Parameters<NonNullable<ChannelOnboardingDmPolicy["promptAllowFrom"]>>[0]["prompter"];
accountId: string;
}): Promise<OpenClawConfig> {
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,
};

View File

@@ -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";

View File

@@ -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,

View File

@@ -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,