refactor: share zod setup validators across channels

This commit is contained in:
Peter Steinberger
2026-03-27 03:41:34 +00:00
parent 1d1f36adff
commit e6c5ce136e
11 changed files with 243 additions and 117 deletions

View File

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

View File

@@ -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
? {}

View File

@@ -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<IrcSetupInput>;
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({

View File

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

View File

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

View File

@@ -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<CoreConfig["channels"]>["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<NextcloudSetupInput>;
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({

View File

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

View File

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

View File

@@ -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
? {}

View File

@@ -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<T extends ChannelSetupInput>(params: {
schema: ZodType<T>;
validate?: (params: { cfg: OpenClawConfig; accountId: string; input: T }) => string | null;
}): NonNullable<ChannelSetupAdapter["validateInput"]> {
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;

View File

@@ -29,6 +29,7 @@ export {
applySetupAccountConfigPatch,
createEnvPatchedAccountSetupAdapter,
createPatchedAccountSetupAdapter,
createZodSetupInputValidator,
migrateBaseNameToDefaultAccount,
patchScopedAccountConfig,
prepareScopedSetupConfig,