refactor: share channel setup status helpers

This commit is contained in:
Peter Steinberger 2026-03-23 01:51:38 +00:00
parent 583bea001c
commit 5c8ea0a175
18 changed files with 188 additions and 83 deletions

View File

@ -283,6 +283,11 @@ helpers from `openclaw/plugin-sdk/setup`: `createPromptParsedAllowFromForAccount
`createTopLevelChannelParsedAllowFromPrompt(...)`, and
`createNestedChannelParsedAllowFromPrompt(...)`.
For channel setup status blocks that only vary by labels, scores, and optional
extra lines, prefer `createStandardChannelSetupStatus(...)` from
`openclaw/plugin-sdk/setup` instead of hand-rolling the same `status` object in
each plugin.
For optional setup surfaces that should only appear in certain contexts, use
`createOptionalChannelSetupSurface` from `openclaw/plugin-sdk/channel-setup`:

View File

@ -1,6 +1,7 @@
import {
createAllowFromSection,
createPromptParsedAllowFromForAccount,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
type ChannelSetupDmPolicy,
@ -143,20 +144,21 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
channel,
stepOrder: "text-first",
status: {
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "configured",
unconfiguredHint: "iMessage via BlueBubbles app",
configuredScore: 1,
unconfiguredScore: 0,
resolveConfigured: ({ cfg }) =>
listBlueBubblesAccountIds(cfg).some((accountId) => {
const account = resolveBlueBubblesAccount({ cfg, accountId });
return account.configured;
}),
resolveStatusLines: ({ configured }) => [
`BlueBubbles: ${configured ? "configured" : "needs setup"}`,
],
...createStandardChannelSetupStatus({
channelLabel: "BlueBubbles",
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "configured",
unconfiguredHint: "iMessage via BlueBubbles app",
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg }) =>
listBlueBubblesAccountIds(cfg).some((accountId) => {
const account = resolveBlueBubblesAccount({ cfg, accountId });
return account.configured;
}),
}),
resolveSelectionHint: ({ configured }) =>
configured ? "configured" : "iMessage via BlueBubbles app",
},

View File

@ -7,6 +7,7 @@ import type {
ChannelSetupDmPolicy,
ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup-runtime";
import { createStandardChannelSetupStatus } from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import {
inspectDiscordSetupAccount,
@ -99,7 +100,8 @@ export function createDiscordSetupWizardBase(handlers: {
return {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Discord",
configuredLabel: "configured",
unconfiguredLabel: "needs token",
configuredHint: "configured",
@ -111,7 +113,7 @@ export function createDiscordSetupWizardBase(handlers: {
const account = inspectDiscordSetupAccount({ cfg, accountId });
return account.configured;
}),
},
}),
credentials: [
{
inputKey: "token",

View File

@ -2,6 +2,7 @@ import {
applySetupAccountConfigPatch,
createNestedChannelParsedAllowFromPrompt,
createNestedChannelDmPolicy,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
@ -51,22 +52,18 @@ export { googlechatSetupAdapter } from "./setup-core.js";
export const googlechatSetupWizard: ChannelSetupWizard = {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Google Chat",
configuredLabel: "configured",
unconfiguredLabel: "needs service account",
configuredHint: "configured",
unconfiguredHint: "needs auth",
includeStatusLine: true,
resolveConfigured: ({ cfg }) =>
listGoogleChatAccountIds(cfg).some(
(accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
),
resolveStatusLines: ({ cfg }) => {
const configured = listGoogleChatAccountIds(cfg).some(
(accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
);
return [`Google Chat: ${configured ? "configured" : "needs service account"}`];
},
},
}),
introNote: {
title: "Google Chat setup",
lines: [

View File

@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import {
createAllowFromSection,
createPromptParsedAllowFromForAccount,
createStandardChannelSetupStatus,
setSetupChannelEnabled,
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
@ -168,21 +169,20 @@ const ircDmPolicy: ChannelSetupDmPolicy = {
export const ircSetupWizard: ChannelSetupWizard = {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "IRC",
configuredLabel: "configured",
unconfiguredLabel: "needs host + nick",
configuredHint: "configured",
unconfiguredHint: "needs host + nick",
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg }) =>
listIrcAccountIds(cfg as CoreConfig).some(
(accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured,
),
resolveStatusLines: ({ configured }) => [
`IRC: ${configured ? "configured" : "needs host + nick"}`,
],
},
}),
introNote: {
title: "IRC setup",
lines: [

View File

@ -1,4 +1,8 @@
import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup";
import {
createAllowFromSection,
createStandardChannelSetupStatus,
createTopLevelChannelDmPolicy,
} from "openclaw/plugin-sdk/setup";
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
@ -47,20 +51,19 @@ export { lineSetupAdapter } from "./setup-core.js";
export const lineSetupWizard: ChannelSetupWizard = {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "LINE",
configuredLabel: "configured",
unconfiguredLabel: "needs token + secret",
configuredHint: "configured",
unconfiguredHint: "needs token + secret",
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg }) =>
listLineAccountIds(cfg).some((accountId) => isLineConfigured(cfg, accountId)),
resolveStatusLines: ({ cfg, configured }) => [
`LINE: ${configured ? "configured" : "needs token + secret"}`,
`Accounts: ${listLineAccountIds(cfg).length || 0}`,
],
},
resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listLineAccountIds(cfg).length || 0}`],
}),
introNote: {
title: "LINE Messaging API",
lines: LINE_SETUP_HELP_LINES,

View File

@ -1,5 +1,8 @@
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup";
import {
createStandardChannelSetupStatus,
formatDocsLink,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { listMattermostAccountIds } from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
import {
@ -19,7 +22,8 @@ export { mattermostSetupAdapter } from "./setup-core.js";
export const mattermostSetupWizard: ChannelSetupWizard = {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Mattermost",
configuredLabel: "configured",
unconfiguredLabel: "needs token + url",
configuredHint: "configured",
@ -30,7 +34,7 @@ export const mattermostSetupWizard: ChannelSetupWizard = {
listMattermostAccountIds(cfg).some((accountId) =>
isMattermostConfigured(resolveMattermostAccountWithSecrets(cfg, accountId)),
),
},
}),
introNote: {
title: "Mattermost bot token",
lines: [

View File

@ -2,6 +2,7 @@ import {
createTopLevelChannelAllowFromSetter,
createTopLevelChannelDmPolicy,
createTopLevelChannelGroupPolicySetter,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
@ -274,26 +275,19 @@ export const msteamsSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
resolveShouldPromptAccountIds: () => false,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "MS Teams",
configuredLabel: "configured",
unconfiguredLabel: "needs app credentials",
configuredHint: "configured",
unconfiguredHint: "needs app creds",
configuredScore: 2,
unconfiguredScore: 0,
resolveConfigured: ({ cfg }) => {
return (
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) ||
hasConfiguredMSTeamsCredentials(cfg.channels?.msteams)
);
},
resolveStatusLines: ({ cfg }) => {
const configured =
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) ||
hasConfiguredMSTeamsCredentials(cfg.channels?.msteams);
return [`MS Teams: ${configured ? "configured" : "needs app credentials"}`];
},
},
includeStatusLine: true,
resolveConfigured: ({ cfg }) =>
Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) ||
hasConfiguredMSTeamsCredentials(cfg.channels?.msteams),
}),
credentials: [],
finalize: async ({ cfg, prompter }) => {
const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams);

View File

@ -2,9 +2,12 @@ import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup";
import {
createStandardChannelSetupStatus,
formatDocsLink,
setSetupChannelEnabled,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js";
import {
clearNextcloudTalkAccountFields,
@ -22,7 +25,8 @@ const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials";
export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
channel,
stepOrder: "text-first",
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Nextcloud Talk",
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "configured",
@ -34,7 +38,7 @@ export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
return Boolean(account.secret && account.baseUrl);
}),
},
}),
introNote: {
title: "Nextcloud Talk bot setup",
lines: [

View File

@ -4,6 +4,7 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import {
createTopLevelChannelParsedAllowFromPrompt,
createTopLevelChannelDmPolicy,
createStandardChannelSetupStatus,
mergeAllowFromEntries,
parseSetupEntriesWithParser,
patchTopLevelChannelConfigSection,
@ -139,22 +140,21 @@ export const nostrSetupWizard: ChannelSetupWizard = {
channel,
resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID,
resolveShouldPromptAccountIds: () => false,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Nostr",
configuredLabel: "configured",
unconfiguredLabel: "needs private key",
configuredHint: "configured",
unconfiguredHint: "needs private key",
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg }) => resolveNostrAccount({ cfg }).configured,
resolveStatusLines: ({ cfg, configured }) => {
resolveExtraStatusLines: ({ cfg }) => {
const account = resolveNostrAccount({ cfg });
return [
`Nostr: ${configured ? "configured" : "needs private key"}`,
`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`,
];
return [`Relays: ${account.relays.length || DEFAULT_RELAYS.length}`];
},
},
}),
introNote: {
title: "Nostr setup",
lines: NOSTR_SETUP_HELP_LINES,

View File

@ -3,6 +3,7 @@ import {
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createLegacyCompatChannelDmPolicy,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
createEnvPatchedAccountSetupAdapter,
hasConfiguredSecretInput,
@ -119,7 +120,8 @@ export function createSlackSetupWizardBase(handlers: {
return {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Slack",
configuredLabel: "configured",
unconfiguredLabel: "needs tokens",
configuredHint: "configured",
@ -131,7 +133,7 @@ export function createSlackSetupWizardBase(handlers: {
const account = inspectSlackAccount({ cfg, accountId });
return account.configured;
}),
},
}),
introNote: {
title: "Slack socket mode tokens",
lines: buildSlackSetupLines(),

View File

@ -1,5 +1,6 @@
import {
createAllowFromSection,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
mergeAllowFromEntries,
@ -176,20 +177,19 @@ export const synologyChatSetupAdapter: ChannelSetupAdapter = {
export const synologyChatSetupWizard: ChannelSetupWizard = {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Synology Chat",
configuredLabel: "configured",
unconfiguredLabel: "needs token + incoming webhook",
configuredHint: "configured",
unconfiguredHint: "needs token + incoming webhook",
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg }) =>
listAccountIds(cfg).some((accountId) => isSynologyChatConfigured(cfg, accountId)),
resolveStatusLines: ({ cfg, configured }) => [
`Synology Chat: ${configured ? "configured" : "needs token + incoming webhook"}`,
`Accounts: ${listAccountIds(cfg).length || 0}`,
],
},
resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listAccountIds(cfg).length || 0}`],
}),
introNote: {
title: "Synology Chat webhook setup",
lines: SYNOLOGY_SETUP_HELP_LINES,

View File

@ -1,5 +1,6 @@
import {
createAllowFromSection,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
hasConfiguredSecretInput,
type OpenClawConfig,
@ -92,7 +93,8 @@ const dmPolicy: ChannelSetupDmPolicy = {
export const telegramSetupWizard: ChannelSetupWizard = {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Telegram",
configuredLabel: "configured",
unconfiguredLabel: "needs token",
configuredHint: "recommended · configured",
@ -104,7 +106,7 @@ export const telegramSetupWizard: ChannelSetupWizard = {
const account = inspectTelegramAccount({ cfg, accountId });
return account.configured;
}),
},
}),
prepare: async ({ cfg, accountId, credentialValues }) => ({
cfg: ensureTelegramDefaultGroupMentionGate(cfg, accountId),
credentialValues,

View File

@ -1,6 +1,7 @@
import {
buildSingleChannelSecretPromptState,
createTopLevelChannelDmPolicy,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
hasConfiguredSecretInput,
@ -195,13 +196,15 @@ export { zaloSetupAdapter } from "./setup-core.js";
export const zaloSetupWizard: ChannelSetupWizard = {
channel,
status: {
status: createStandardChannelSetupStatus({
channelLabel: "Zalo",
configuredLabel: "configured",
unconfiguredLabel: "needs token",
configuredHint: "recommended · configured",
unconfiguredHint: "recommended · newcomer-friendly",
configuredScore: 1,
unconfiguredScore: 10,
includeStatusLine: true,
resolveConfigured: ({ cfg }) =>
listZaloAccountIds(cfg).some((accountId) => {
const account = resolveZaloAccount({
@ -215,11 +218,7 @@ export const zaloSetupWizard: ChannelSetupWizard = {
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;

View File

@ -14,6 +14,7 @@ import {
createLegacyCompatChannelDmPolicy,
createNestedChannelParsedAllowFromPrompt,
createPromptParsedAllowFromForAccount,
createStandardChannelSetupStatus,
createNestedChannelAllowFromSetter,
createNestedChannelDmPolicy,
createNestedChannelDmPolicySetter,
@ -1816,6 +1817,46 @@ describe("normalizeAllowFromEntries", () => {
});
});
describe("createStandardChannelSetupStatus", () => {
it("returns the shared status fields without status lines by default", async () => {
const status = createStandardChannelSetupStatus({
channelLabel: "Demo",
configuredLabel: "configured",
unconfiguredLabel: "needs token",
configuredHint: "ready",
unconfiguredHint: "missing token",
configuredScore: 2,
unconfiguredScore: 0,
resolveConfigured: ({ cfg }) => Boolean(cfg.channels?.demo),
});
expect(status.configuredHint).toBe("ready");
expect(status.unconfiguredHint).toBe("missing token");
expect(status.configuredScore).toBe(2);
expect(status.unconfiguredScore).toBe(0);
expect(await status.resolveConfigured({ cfg: { channels: { demo: {} } } })).toBe(true);
expect(status.resolveStatusLines).toBeUndefined();
});
it("builds the default status line plus extra lines when requested", async () => {
const status = createStandardChannelSetupStatus({
channelLabel: "Demo",
configuredLabel: "configured",
unconfiguredLabel: "needs token",
includeStatusLine: true,
resolveConfigured: ({ cfg }) => Boolean(cfg.channels?.demo),
resolveExtraStatusLines: ({ configured }) => [`Configured: ${configured ? "yes" : "no"}`],
});
expect(
await status.resolveStatusLines?.({
cfg: { channels: { demo: {} } },
configured: true,
}),
).toEqual(["Demo: configured", "Configured: yes"]);
});
});
describe("resolveSetupAccountId", () => {
it("normalizes provided account ids", () => {
expect(

View File

@ -13,7 +13,11 @@ import type {
PromptAccountId,
PromptAccountIdParams,
} from "./setup-wizard-types.js";
import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry } from "./setup-wizard.js";
import type {
ChannelSetupWizard,
ChannelSetupWizardAllowFromEntry,
ChannelSetupWizardStatus,
} from "./setup-wizard.js";
let providerAuthInputPromise:
| Promise<Pick<typeof import("../../plugins/provider-auth-ref.js"), "promptSecretRefForSetup">>
@ -156,6 +160,50 @@ export function normalizeAllowFromEntries(
return [...new Set(normalized)];
}
export function createStandardChannelSetupStatus(params: {
channelLabel: string;
configuredLabel: string;
unconfiguredLabel: string;
configuredHint?: string;
unconfiguredHint?: string;
configuredScore?: number;
unconfiguredScore?: number;
includeStatusLine?: boolean;
resolveConfigured: ChannelSetupWizardStatus["resolveConfigured"];
resolveExtraStatusLines?: (params: {
cfg: OpenClawConfig;
configured: boolean;
}) => string[] | Promise<string[]>;
}): ChannelSetupWizardStatus {
const status: ChannelSetupWizardStatus = {
configuredLabel: params.configuredLabel,
unconfiguredLabel: params.unconfiguredLabel,
resolveConfigured: params.resolveConfigured,
...(params.configuredHint ? { configuredHint: params.configuredHint } : {}),
...(params.unconfiguredHint ? { unconfiguredHint: params.unconfiguredHint } : {}),
...(typeof params.configuredScore === "number"
? { configuredScore: params.configuredScore }
: {}),
...(typeof params.unconfiguredScore === "number"
? { unconfiguredScore: params.unconfiguredScore }
: {}),
};
if (params.includeStatusLine || params.resolveExtraStatusLines) {
status.resolveStatusLines = async ({ cfg, configured }) => {
const lines = params.includeStatusLine
? [
`${params.channelLabel}: ${configured ? params.configuredLabel : params.unconfiguredLabel}`,
]
: [];
const extraLines = (await params.resolveExtraStatusLines?.({ cfg, configured })) ?? [];
return [...lines, ...extraLines];
};
}
return status;
}
export function resolveSetupAccountId(params: {
accountId?: string;
defaultAccountId: string;

View File

@ -15,6 +15,7 @@ export {
createAccountScopedAllowFromSection,
createAccountScopedGroupAccessSection,
createLegacyCompatChannelDmPolicy,
createStandardChannelSetupStatus,
parseMentionOrPrefixedId,
patchChannelConfigForAccount,
promptLegacyChannelAllowFromForAccount,

View File

@ -42,6 +42,7 @@ export {
createLegacyCompatChannelDmPolicy,
createNestedChannelParsedAllowFromPrompt,
createPromptParsedAllowFromForAccount,
createStandardChannelSetupStatus,
createNestedChannelAllowFromSetter,
createNestedChannelDmPolicy,
createNestedChannelDmPolicySetter,