mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 20:40:45 +00:00
refactor: move feishu zalo zalouser to setup wizard
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
@@ -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, ""),
|
||||
|
||||
@@ -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: {
|
||||
|
||||
60
extensions/zalo/src/setup-surface.test.ts
Normal file
60
extensions/zalo/src/setup-surface.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
86
extensions/zalouser/src/setup-surface.test.ts
Normal file
86
extensions/zalouser/src/setup-surface.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user