From e6c5ce136e1d60cf5b9dc14154db01b9d31af28e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 27 Mar 2026 03:41:34 +0000 Subject: [PATCH] refactor: share zod setup validators across channels --- extensions/bluebubbles/src/setup-core.ts | 36 ++++++++++------ extensions/googlechat/src/setup-core.ts | 36 +++++++++++----- extensions/irc/src/setup-core.ts | 20 ++++----- extensions/line/src/setup-core.ts | 46 ++++++++++++--------- extensions/mattermost/src/setup-core.ts | 42 ++++++++++++------- extensions/nextcloud-talk/src/setup-core.ts | 39 +++++++++++------ extensions/signal/src/setup-core.ts | 39 +++++++++++------ extensions/tlon/src/setup-core.ts | 46 +++++++++++++-------- extensions/zalo/src/setup-core.ts | 36 +++++++++++----- src/channels/plugins/setup-helpers.ts | 19 +++++++++ src/plugin-sdk/setup.ts | 1 + 11 files changed, 243 insertions(+), 117 deletions(-) diff --git a/extensions/bluebubbles/src/setup-core.ts b/extensions/bluebubbles/src/setup-core.ts index df8cf016b0b..874dc105065 100644 --- a/extensions/bluebubbles/src/setup-core.ts +++ b/extensions/bluebubbles/src/setup-core.ts @@ -1,4 +1,5 @@ import { + createZodSetupInputValidator, createTopLevelChannelDmPolicySetter, normalizeAccountId, patchScopedAccountConfig, @@ -7,6 +8,7 @@ import { type DmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; +import { z } from "zod"; import { applyBlueBubblesConnectionConfig } from "./config-apply.js"; const channel = "bluebubbles" as const; @@ -14,6 +16,13 @@ const setBlueBubblesTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({ channel, }); +const BlueBubblesSetupInputSchema = z + .object({ + httpUrl: z.string().optional(), + password: z.string().optional(), + }) + .passthrough(); + export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig { return setBlueBubblesTopLevelDmPolicy(cfg, dmPolicy); } @@ -42,18 +51,21 @@ export const blueBubblesSetupAdapter: ChannelSetupAdapter = { accountId, name, }), - validateInput: ({ input }) => { - if (!input.httpUrl && !input.password) { - return "BlueBubbles requires --http-url and --password."; - } - if (!input.httpUrl) { - return "BlueBubbles requires --http-url."; - } - if (!input.password) { - return "BlueBubbles requires --password."; - } - return null; - }, + validateInput: createZodSetupInputValidator({ + schema: BlueBubblesSetupInputSchema, + validate: ({ input }) => { + if (!input.httpUrl && !input.password) { + return "BlueBubbles requires --http-url and --password."; + } + if (!input.httpUrl) { + return "BlueBubbles requires --http-url."; + } + if (!input.password) { + return "BlueBubbles requires --password."; + } + return null; + }, + }), applyAccountConfig: ({ cfg, accountId, input }) => { const next = prepareScopedSetupConfig({ cfg, diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts index 5643ec4c291..a9beb887a28 100644 --- a/extensions/googlechat/src/setup-core.ts +++ b/extensions/googlechat/src/setup-core.ts @@ -1,18 +1,34 @@ -import { createPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { + createPatchedAccountSetupAdapter, + createZodSetupInputValidator, + DEFAULT_ACCOUNT_ID, +} from "openclaw/plugin-sdk/setup"; +import { z } from "zod"; const channel = "googlechat" as const; +const GoogleChatSetupInputSchema = z + .object({ + useEnv: z.boolean().optional(), + token: z.string().optional(), + tokenFile: z.string().optional(), + }) + .passthrough(); + export const googlechatSetupAdapter = createPatchedAccountSetupAdapter({ channelKey: channel, - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Google Chat requires --token (service account JSON) or --token-file."; - } - return null; - }, + validateInput: createZodSetupInputValidator({ + schema: GoogleChatSetupInputSchema, + validate: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Google Chat requires --token (service account JSON) or --token-file."; + } + return null; + }, + }), buildPatch: (input) => { const patch = input.useEnv ? {} diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index f2e83e9838f..3e246269ba5 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -3,10 +3,12 @@ import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { applyAccountNameToChannelSection, + createZodSetupInputValidator, createTopLevelChannelAllowFromSetter, createTopLevelChannelDmPolicySetter, patchScopedAccountConfig, } from "openclaw/plugin-sdk/setup"; +import { z } from "zod"; import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; const channel = "irc" as const; @@ -28,6 +30,13 @@ type IrcSetupInput = ChannelSetupInput & { password?: string; }; +const IrcSetupInputSchema = z + .object({ + host: z.string().trim().min(1, "IRC requires host."), + nick: z.string().trim().min(1, "IRC requires nick."), + }) + .passthrough() as z.ZodType; + export function parsePort(raw: string, fallback: number): number { const trimmed = raw.trim(); if (!trimmed) { @@ -101,16 +110,7 @@ export const ircSetupAdapter: ChannelSetupAdapter = { accountId, name, }), - validateInput: ({ input }) => { - const setupInput = input as IrcSetupInput; - if (!setupInput.host?.trim()) { - return "IRC requires host."; - } - if (!setupInput.nick?.trim()) { - return "IRC requires nick."; - } - return null; - }, + validateInput: createZodSetupInputValidator({ schema: IrcSetupInputSchema }), applyAccountConfig: ({ cfg, accountId, input }) => { const setupInput = input as IrcSetupInput; const namedConfig = applyAccountNameToChannelSection({ diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 33f320d077a..370e23d2b97 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,4 +1,6 @@ import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup"; +import { createZodSetupInputValidator } from "openclaw/plugin-sdk/setup"; +import { z } from "zod"; import { hasLineCredentials, parseLineAllowFromId } from "./account-helpers.js"; import { DEFAULT_ACCOUNT_ID, @@ -10,6 +12,16 @@ import { const channel = "line" as const; +const LineSetupInputSchema = z + .object({ + useEnv: z.boolean().optional(), + channelAccessToken: z.string().optional(), + channelSecret: z.string().optional(), + tokenFile: z.string().optional(), + secretFile: z.string().optional(), + }) + .passthrough(); + export function patchLineAccountConfig(params: { cfg: OpenClawConfig; accountId: string; @@ -80,25 +92,21 @@ export const lineSetupAdapter: ChannelSetupAdapter = { accountId, patch: name?.trim() ? { name: name.trim() } : {}, }), - validateInput: ({ accountId, input }) => { - const typedInput = input as { - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; - } - if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { - return "LINE requires channelAccessToken or --token-file (or --use-env)."; - } - if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { - return "LINE requires channelSecret or --secret-file (or --use-env)."; - } - return null; - }, + validateInput: createZodSetupInputValidator({ + schema: LineSetupInputSchema, + validate: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.channelAccessToken && !input.tokenFile) { + return "LINE requires channelAccessToken or --token-file (or --use-env)."; + } + if (!input.useEnv && !input.channelSecret && !input.secretFile) { + return "LINE requires channelSecret or --secret-file (or --use-env)."; + } + return null; + }, + }), applyAccountConfig: ({ cfg, accountId, input }) => { const typedInput = input as { useEnv?: boolean; diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 14576f4f5d4..f016add37c2 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,4 +1,6 @@ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup"; +import { createZodSetupInputValidator } from "openclaw/plugin-sdk/setup"; +import { z } from "zod"; import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js"; import { normalizeMattermostBaseUrl } from "./mattermost/client.js"; import { @@ -13,6 +15,15 @@ import { hasConfiguredSecretInput } from "./secret-input.js"; const channel = "mattermost" as const; +const MattermostSetupInputSchema = z + .object({ + useEnv: z.boolean().optional(), + botToken: z.string().optional(), + token: z.string().optional(), + httpUrl: z.string().optional(), + }) + .passthrough(); + export function isMattermostConfigured(account: ResolvedMattermostAccount): boolean { const tokenConfigured = Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken); @@ -36,20 +47,23 @@ export const mattermostSetupAdapter: ChannelSetupAdapter = { accountId, name, }), - validateInput: ({ accountId, input }) => { - const token = input.botToken ?? input.token; - const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "Mattermost env vars can only be used for the default account."; - } - if (!input.useEnv && (!token || !baseUrl)) { - return "Mattermost requires --bot-token and --http-url (or --use-env)."; - } - if (input.httpUrl && !baseUrl) { - return "Mattermost --http-url must include a valid base URL."; - } - return null; - }, + validateInput: createZodSetupInputValidator({ + schema: MattermostSetupInputSchema, + validate: ({ accountId, input }) => { + const token = input.botToken ?? input.token; + const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Mattermost env vars can only be used for the default account."; + } + if (!input.useEnv && (!token || !baseUrl)) { + return "Mattermost requires --bot-token and --http-url (or --use-env)."; + } + if (input.httpUrl && !baseUrl) { + return "Mattermost --http-url must include a valid base URL."; + } + return null; + }, + }), applyAccountConfig: ({ cfg, accountId, input }) => { const token = input.botToken ?? input.token; const baseUrl = normalizeMattermostBaseUrl(input.httpUrl); diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 1059cd0a63a..1a99c70c264 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { applyAccountNameToChannelSection, + createZodSetupInputValidator, patchScopedAccountConfig, } from "openclaw/plugin-sdk/setup"; import { @@ -16,6 +17,7 @@ import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { z } from "zod"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, @@ -32,6 +34,15 @@ type NextcloudSetupInput = ChannelSetupInput & { }; type NextcloudTalkSection = NonNullable["nextcloud-talk"]; +const NextcloudSetupInputSchema = z + .object({ + useEnv: z.boolean().optional(), + baseUrl: z.string().optional(), + secret: z.string().optional(), + secretFile: z.string().optional(), + }) + .passthrough() as z.ZodType; + export function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { return value?.trim().replace(/\/+$/, "") ?? ""; } @@ -181,19 +192,21 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { accountId, name, }), - validateInput: ({ accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; - } - if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { - return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; - } - if (!setupInput.baseUrl) { - return "Nextcloud Talk requires --base-url."; - } - return null; - }, + validateInput: createZodSetupInputValidator({ + schema: NextcloudSetupInputSchema, + validate: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; + } + if (!input.useEnv && !input.secret && !input.secretFile) { + return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; + } + if (!input.baseUrl) { + return "Nextcloud Talk requires --base-url."; + } + return null; + }, + }), applyAccountConfig: ({ cfg, accountId, input }) => { const setupInput = input as NextcloudSetupInput; const namedConfig = applyAccountNameToChannelSection({ diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 95c5f53c7bd..f7c1c4d21eb 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -3,6 +3,7 @@ import { createDelegatedSetupWizardProxy, createDelegatedTextInputShouldPrompt, createPatchedAccountSetupAdapter, + createZodSetupInputValidator, createTopLevelChannelDmPolicy, normalizeE164, parseSetupEntriesAllowingWildcard, @@ -18,6 +19,7 @@ import type { ChannelSetupWizardTextInput, } from "openclaw/plugin-sdk/setup"; import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import { z } from "zod"; import { listSignalAccountIds, resolveDefaultSignalAccountId, @@ -31,6 +33,16 @@ const DIGITS_ONLY = /^\d+$/; const INVALID_SIGNAL_ACCOUNT_ERROR = "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; +const SignalSetupInputSchema = z + .object({ + signalNumber: z.string().optional(), + cliPath: z.string().optional(), + httpUrl: z.string().optional(), + httpHost: z.string().optional(), + httpPort: z.string().optional(), + }) + .passthrough(); + export function normalizeSignalAccountInput(value: string | null | undefined): string | null { const trimmed = value?.trim(); if (!trimmed) { @@ -184,18 +196,21 @@ export const signalCompletionNote = { export const signalSetupAdapter: ChannelSetupAdapter = createPatchedAccountSetupAdapter({ channelKey: channel, - validateInput: ({ input }) => { - if ( - !input.signalNumber && - !input.httpUrl && - !input.httpHost && - !input.httpPort && - !input.cliPath - ) { - return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; - } - return null; - }, + validateInput: createZodSetupInputValidator({ + schema: SignalSetupInputSchema, + validate: ({ input }) => { + if ( + !input.signalNumber && + !input.httpUrl && + !input.httpHost && + !input.httpPort && + !input.cliPath + ) { + return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; + } + return null; + }, + }), buildPatch: (input) => buildSignalSetupPatch(input), }); diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index da5546e51e9..349ae69e008 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -4,11 +4,13 @@ import { normalizeAccountId, patchScopedAccountConfig, prepareScopedSetupConfig, + createZodSetupInputValidator, type ChannelSetupAdapter, type ChannelSetupInput, type ChannelSetupWizard, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; +import { z } from "zod"; import { buildTlonAccountFields } from "./account-fields.js"; import { normalizeShip } from "./targets.js"; import { listTlonAccountIds, resolveTlonAccount, type TlonResolvedAccount } from "./types.js"; @@ -29,6 +31,14 @@ export type TlonSetupInput = ChannelSetupInput & { ownerShip?: string; }; +const TlonSetupInputSchema = z + .object({ + ship: z.string().optional(), + url: z.string().optional(), + code: z.string().optional(), + }) + .passthrough() as z.ZodType; + function isConfigured(account: TlonResolvedAccount): boolean { return Boolean(account.ship && account.url && account.code); } @@ -186,23 +196,25 @@ export const tlonSetupAdapter: ChannelSetupAdapter = { accountId, name, }), - validateInput: ({ cfg, accountId, input }) => { - const setupInput = input as TlonSetupInput; - const resolved = resolveTlonAccount(cfg, accountId ?? undefined); - const ship = setupInput.ship?.trim() || resolved.ship; - const url = setupInput.url?.trim() || resolved.url; - const code = setupInput.code?.trim() || resolved.code; - if (!ship) { - return "Tlon requires --ship."; - } - if (!url) { - return "Tlon requires --url."; - } - if (!code) { - return "Tlon requires --code."; - } - return null; - }, + validateInput: createZodSetupInputValidator({ + schema: TlonSetupInputSchema, + validate: ({ cfg, accountId, input }) => { + const resolved = resolveTlonAccount(cfg, accountId ?? undefined); + const ship = input.ship?.trim() || resolved.ship; + const url = input.url?.trim() || resolved.url; + const code = input.code?.trim() || resolved.code; + if (!ship) { + return "Tlon requires --ship."; + } + if (!url) { + return "Tlon requires --url."; + } + if (!code) { + return "Tlon requires --code."; + } + return null; + }, + }), applyAccountConfig: ({ cfg, accountId, input }) => applyTlonSetupConfig({ cfg, diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index 218ff32cf19..405c533d805 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -1,18 +1,34 @@ -import { createPatchedAccountSetupAdapter, DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup"; +import { + createPatchedAccountSetupAdapter, + createZodSetupInputValidator, + DEFAULT_ACCOUNT_ID, +} from "openclaw/plugin-sdk/setup"; +import { z } from "zod"; const channel = "zalo" as const; +const ZaloSetupInputSchema = z + .object({ + useEnv: z.boolean().optional(), + token: z.string().optional(), + tokenFile: z.string().optional(), + }) + .passthrough(); + export const zaloSetupAdapter = createPatchedAccountSetupAdapter({ channelKey: channel, - 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; - }, + validateInput: createZodSetupInputValidator({ + schema: ZaloSetupInputSchema, + validate: ({ 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; + }, + }), buildPatch: (input) => input.useEnv ? {} diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 0a872d3d8e0..fb5de6b903b 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -1,3 +1,4 @@ +import type { ZodType } from "zod"; import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import type { ChannelSetupAdapter } from "./types.adapters.js"; @@ -205,6 +206,24 @@ export function createPatchedAccountSetupAdapter(params: { }; } +export function createZodSetupInputValidator(params: { + schema: ZodType; + validate?: (params: { cfg: OpenClawConfig; accountId: string; input: T }) => string | null; +}): NonNullable { + return (inputParams) => { + const parsed = params.schema.safeParse(inputParams.input); + if (!parsed.success) { + return parsed.error.issues[0]?.message ?? "invalid input"; + } + return ( + params.validate?.({ + ...inputParams, + input: parsed.data, + }) ?? null + ); + }; +} + export function createEnvPatchedAccountSetupAdapter(params: { channelKey: string; alwaysUseAccounts?: boolean; diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index e83c3d264a4..d3f58884ed1 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -29,6 +29,7 @@ export { applySetupAccountConfigPatch, createEnvPatchedAccountSetupAdapter, createPatchedAccountSetupAdapter, + createZodSetupInputValidator, migrateBaseNameToDefaultAccount, patchScopedAccountConfig, prepareScopedSetupConfig,