diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index a8745591db3..51dc7811764 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -7,7 +7,6 @@ import { formatAllowFromLowercase, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildTokenChannelStatusSummary, clearAccountEntryFields, @@ -19,7 +18,6 @@ import { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, looksLikeTelegramTargetId, - migrateBaseNameToDefaultAccount, normalizeAccountId, normalizeTelegramMessagingTarget, PAIRING_APPROVED_MESSAGE, @@ -32,7 +30,6 @@ import { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, sendTelegramPayloadMessages, - telegramOnboardingAdapter, TelegramConfigSchema, type ChannelMessageActionAdapter, type ChannelPlugin, @@ -45,6 +42,7 @@ import { resolveOutboundSendDep, } from "../../../src/infra/outbound/send-deps.js"; import { getTelegramRuntime } from "./runtime.js"; +import { telegramSetupAdapter, telegramSetupWizard } from "./setup-surface.js"; type TelegramSendFn = ReturnType< typeof getTelegramRuntime @@ -186,7 +184,7 @@ export const telegramPlugin: ChannelPlugin entry.replace(/^(telegram|tg):/i, ""), @@ -297,81 +295,7 @@ export const telegramPlugin: ChannelPlugin listTelegramDirectoryGroupsFromConfig(params), }, actions: telegramMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: "telegram", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "TELEGRAM_BOT_TOKEN can only be used for the default account."; - } - if (!input.useEnv && !input.token && !input.tokenFile) { - return "Telegram requires token or --token-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: "telegram", - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: "telegram", - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - ...(input.useEnv - ? {} - : input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - telegram: { - ...next.channels?.telegram, - enabled: true, - accounts: { - ...next.channels?.telegram?.accounts, - [accountId]: { - ...next.channels?.telegram?.accounts?.[accountId], - enabled: true, - ...(input.tokenFile - ? { tokenFile: input.tokenFile } - : input.token - ? { botToken: input.token } - : {}), - }, - }, - }, - }, - }; - }, - }, + setup: telegramSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/telegram/src/onboarding.ts b/extensions/telegram/src/onboarding.ts index f5911e304ed..340319a864a 100644 --- a/extensions/telegram/src/onboarding.ts +++ b/extensions/telegram/src/onboarding.ts @@ -1,256 +1,6 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../../../src/channels/plugins/onboarding-types.js"; -import { - applySingleTokenPromptResult, - patchChannelConfigForAccount, - promptResolvedAllowFrom, - promptSingleChannelSecretInput, - resolveAccountIdForConfigure, - resolveOnboardingAccountId, - setChannelDmPolicyWithAllowFrom, - setOnboardingChannelEnabled, - splitOnboardingEntries, -} from "../../../src/channels/plugins/onboarding/helpers.js"; -import { formatCliCommand } from "../../../src/cli/command-format.js"; -import type { OpenClawConfig } from "../../../src/config/config.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 type { WizardPrompter } from "../../../src/wizard/prompts.js"; -import { inspectTelegramAccount } from "./account-inspect.js"; -import { - listTelegramAccountIds, - resolveDefaultTelegramAccountId, - resolveTelegramAccount, -} from "./accounts.js"; -import { fetchTelegramChatId } from "./api-fetch.js"; - -const channel = "telegram" as const; - -async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Open Telegram and chat with @BotFather", - "2) Run /newbot (or /mybots)", - "3) Copy the token (looks like 123456:ABC...)", - "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", - ].join("\n"), - "Telegram bot token", - ); -} - -async function noteTelegramUserIdHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, - "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", - "3) Third-party: DM @userinfobot or @getidsbot", - `Docs: ${formatDocsLink("/telegram")}`, - "Website: https://openclaw.ai", - ].join("\n"), - "Telegram user id", - ); -} - -export function normalizeTelegramAllowFromInput(raw: string): string { - return raw - .trim() - .replace(/^(telegram|tg):/i, "") - .trim(); -} - -export function parseTelegramAllowFromId(raw: string): string | null { - const stripped = normalizeTelegramAllowFromInput(raw); - return /^\d+$/.test(stripped) ? stripped : null; -} - -async function promptTelegramAllowFrom(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId: string; - tokenOverride?: string; -}): Promise { - const { cfg, prompter, accountId } = params; - const resolved = resolveTelegramAccount({ cfg, accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await noteTelegramUserIdHelp(prompter); - - const token = params.tokenOverride?.trim() || resolved.token; - if (!token) { - await prompter.note("Telegram token missing; username lookup is unavailable.", "Telegram"); - } - const unique = await promptResolvedAllowFrom({ - prompter, - existing: existingAllowFrom, - token, - message: "Telegram allowFrom (numeric sender id; @username resolves to id)", - placeholder: "@username", - label: "Telegram allowlist", - parseInputs: splitOnboardingEntries, - parseId: parseTelegramAllowFromId, - invalidWithoutTokenNote: - "Telegram token missing; use numeric sender ids (usernames require a bot token).", - resolveEntries: async ({ token: tokenValue, entries }) => { - const results = await Promise.all( - entries.map(async (entry) => { - const numericId = parseTelegramAllowFromId(entry); - if (numericId) { - return { input: entry, resolved: true, id: numericId }; - } - const stripped = normalizeTelegramAllowFromInput(entry); - if (!stripped) { - return { input: entry, resolved: false, id: null }; - } - const username = stripped.startsWith("@") ? stripped : `@${stripped}`; - const id = await fetchTelegramChatId({ token: tokenValue, chatId: username }); - return { input: entry, resolved: Boolean(id), id }; - }), - ); - return results; - }, - }); - - return patchChannelConfigForAccount({ - cfg, - channel: "telegram", - accountId, - patch: { dmPolicy: "allowlist", allowFrom: unique }, - }); -} - -async function promptTelegramAllowFromForAccount(params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = resolveOnboardingAccountId({ - accountId: params.accountId, - defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), - }); - return promptTelegramAllowFrom({ - cfg: params.cfg, - prompter: params.prompter, - accountId, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Telegram", - channel, - policyKey: "channels.telegram.dmPolicy", - allowFromKey: "channels.telegram.allowFrom", - getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => - setChannelDmPolicyWithAllowFrom({ - cfg, - channel: "telegram", - dmPolicy: policy, - }), - promptAllowFrom: promptTelegramAllowFromForAccount, -}; - -export const telegramOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listTelegramAccountIds(cfg).some((accountId) => { - const account = inspectTelegramAccount({ cfg, accountId }); - return account.configured; - }); - return { - channel, - configured, - statusLines: [`Telegram: ${configured ? "configured" : "needs token"}`], - selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly", - quickstartScore: configured ? 1 : 10, - }; - }, - configure: async ({ - cfg, - prompter, - options, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg); - const telegramAccountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Telegram", - accountOverride: accountOverrides.telegram, - shouldPromptAccountIds, - listAccountIds: listTelegramAccountIds, - defaultAccountId: defaultTelegramAccountId, - }); - - let next = cfg; - const resolvedAccount = resolveTelegramAccount({ - cfg: next, - accountId: telegramAccountId, - }); - const hasConfiguredBotToken = hasConfiguredSecretInput(resolvedAccount.config.botToken); - const hasConfigToken = - hasConfiguredBotToken || Boolean(resolvedAccount.config.tokenFile?.trim()); - const accountConfigured = Boolean(resolvedAccount.token) || hasConfigToken; - const allowEnv = telegramAccountId === DEFAULT_ACCOUNT_ID; - const canUseEnv = - allowEnv && !hasConfigToken && Boolean(process.env.TELEGRAM_BOT_TOKEN?.trim()); - - if (!accountConfigured) { - await noteTelegramTokenHelp(prompter); - } - - const tokenResult = await promptSingleChannelSecretInput({ - cfg: next, - prompter, - providerHint: "telegram", - credentialLabel: "Telegram bot token", - secretInputMode: options?.secretInputMode, - accountConfigured, - canUseEnv, - hasConfigToken, - envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", - keepPrompt: "Telegram token already configured. Keep it?", - inputPrompt: "Enter Telegram bot token", - preferredEnvVar: allowEnv ? "TELEGRAM_BOT_TOKEN" : undefined, - }); - - let resolvedTokenForAllowFrom: string | undefined; - if (tokenResult.action === "use-env") { - next = applySingleTokenPromptResult({ - cfg: next, - channel: "telegram", - accountId: telegramAccountId, - tokenPatchKey: "botToken", - tokenResult: { useEnv: true, token: null }, - }); - resolvedTokenForAllowFrom = process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined; - } else if (tokenResult.action === "set") { - next = applySingleTokenPromptResult({ - cfg: next, - channel: "telegram", - accountId: telegramAccountId, - tokenPatchKey: "botToken", - tokenResult: { useEnv: false, token: tokenResult.value }, - }); - resolvedTokenForAllowFrom = tokenResult.resolvedValue; - } - - if (forceAllowFrom) { - next = await promptTelegramAllowFrom({ - cfg: next, - prompter, - accountId: telegramAccountId, - tokenOverride: resolvedTokenForAllowFrom, - }); - } - - return { cfg: next, accountId: telegramAccountId }; - }, - dmPolicy, - disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), -}; +export { + normalizeTelegramAllowFromInput, + parseTelegramAllowFromId, + telegramOnboardingAdapter, + telegramSetupWizard, +} from "./setup-surface.js"; diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts new file mode 100644 index 00000000000..f2708999fee --- /dev/null +++ b/extensions/telegram/src/setup-surface.ts @@ -0,0 +1,312 @@ +import { + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, +} from "../../../src/channels/plugins/onboarding-types.js"; +import { + patchChannelConfigForAccount, + promptResolvedAllowFrom, + resolveOnboardingAccountId, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import { + buildChannelOnboardingAdapterFromSetupWizard, + type ChannelSetupWizard, +} from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { getChatChannelMeta } from "../../../src/channels/registry.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.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 { inspectTelegramAccount } from "./account-inspect.js"; +import { + listTelegramAccountIds, + resolveDefaultTelegramAccountId, + resolveTelegramAccount, +} from "./accounts.js"; +import { fetchTelegramChatId } from "./api-fetch.js"; + +const channel = "telegram" as const; + +const TELEGRAM_TOKEN_HELP_LINES = [ + "1) Open Telegram and chat with @BotFather", + "2) Run /newbot (or /mybots)", + "3) Copy the token (looks like 123456:ABC...)", + "Tip: you can also set TELEGRAM_BOT_TOKEN in your env.", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", +]; + +const TELEGRAM_USER_ID_HELP_LINES = [ + `1) DM your bot, then read from.id in \`${formatCliCommand("openclaw logs --follow")}\` (safest)`, + "2) Or call https://api.telegram.org/bot/getUpdates and read message.from.id", + "3) Third-party: DM @userinfobot or @getidsbot", + `Docs: ${formatDocsLink("/telegram")}`, + "Website: https://openclaw.ai", +]; + +export function normalizeTelegramAllowFromInput(raw: string): string { + return raw + .trim() + .replace(/^(telegram|tg):/i, "") + .trim(); +} + +export function parseTelegramAllowFromId(raw: string): string | null { + const stripped = normalizeTelegramAllowFromInput(raw); + return /^\d+$/.test(stripped) ? stripped : null; +} + +async function resolveTelegramAllowFromEntries(params: { + entries: string[]; + credentialValue?: string; +}) { + return await Promise.all( + params.entries.map(async (entry) => { + const numericId = parseTelegramAllowFromId(entry); + if (numericId) { + return { input: entry, resolved: true, id: numericId }; + } + const stripped = normalizeTelegramAllowFromInput(entry); + if (!stripped || !params.credentialValue?.trim()) { + return { input: entry, resolved: false, id: null }; + } + const username = stripped.startsWith("@") ? stripped : `@${stripped}`; + const id = await fetchTelegramChatId({ + token: params.credentialValue, + chatId: username, + }); + return { input: entry, resolved: Boolean(id), id }; + }), + ); +} + +async function promptTelegramAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: Parameters>[0]["prompter"]; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultTelegramAccountId(params.cfg), + }); + const resolved = resolveTelegramAccount({ cfg: params.cfg, accountId }); + await params.prompter.note(TELEGRAM_USER_ID_HELP_LINES.join("\n"), "Telegram user id"); + if (!resolved.token?.trim()) { + await params.prompter.note( + "Telegram token missing; username lookup is unavailable.", + "Telegram", + ); + } + const unique = await promptResolvedAllowFrom({ + prompter: params.prompter, + existing: resolved.config.allowFrom ?? [], + token: resolved.token, + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + label: "Telegram allowlist", + parseInputs: splitOnboardingEntries, + parseId: parseTelegramAllowFromId, + invalidWithoutTokenNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + resolveEntries: async ({ entries, token }) => + resolveTelegramAllowFromEntries({ + credentialValue: token, + entries, + }), + }); + return patchChannelConfigForAccount({ + cfg: params.cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom: unique }, + }); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Telegram", + channel, + policyKey: "channels.telegram.dmPolicy", + allowFromKey: "channels.telegram.allowFrom", + getCurrent: (cfg) => cfg.channels?.telegram?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptTelegramAllowFromForAccount, +}; + +export const telegramSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "TELEGRAM_BOT_TOKEN can only be used for the default account."; + } + if (!input.useEnv && !input.token && !input.tokenFile) { + return "Telegram 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; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + ...(input.useEnv + ? {} + : input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + telegram: { + ...next.channels?.telegram, + enabled: true, + accounts: { + ...next.channels?.telegram?.accounts, + [accountId]: { + ...next.channels?.telegram?.accounts?.[accountId], + enabled: true, + ...(input.tokenFile + ? { tokenFile: input.tokenFile } + : input.token + ? { botToken: input.token } + : {}), + }, + }, + }, + }, + }; + }, +}; + +export const telegramSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token", + configuredHint: "recommended · configured", + unconfiguredHint: "recommended · newcomer-friendly", + configuredScore: 1, + unconfiguredScore: 10, + resolveConfigured: ({ cfg }) => + listTelegramAccountIds(cfg).some((accountId) => { + const account = inspectTelegramAccount({ cfg, accountId }); + return account.configured; + }), + }, + credential: { + inputKey: "token", + providerHint: channel, + credentialLabel: "Telegram bot token", + preferredEnvVar: "TELEGRAM_BOT_TOKEN", + helpTitle: "Telegram bot token", + helpLines: TELEGRAM_TOKEN_HELP_LINES, + envPrompt: "TELEGRAM_BOT_TOKEN detected. Use env var?", + keepPrompt: "Telegram token already configured. Keep it?", + inputPrompt: "Enter Telegram bot token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveTelegramAccount({ cfg, accountId }); + const hasConfiguredBotToken = hasConfiguredSecretInput(resolved.config.botToken); + const hasConfiguredValue = + hasConfiguredBotToken || Boolean(resolved.config.tokenFile?.trim()); + return { + accountConfigured: Boolean(resolved.token) || hasConfiguredValue, + hasConfiguredValue, + resolvedValue: resolved.token?.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.TELEGRAM_BOT_TOKEN?.trim() || undefined + : undefined, + }; + }, + }, + allowFrom: { + helpTitle: "Telegram user id", + helpLines: TELEGRAM_USER_ID_HELP_LINES, + message: "Telegram allowFrom (numeric sender id; @username resolves to id)", + placeholder: "@username", + invalidWithoutCredentialNote: + "Telegram token missing; use numeric sender ids (usernames require a bot token).", + parseInputs: splitOnboardingEntries, + parseId: parseTelegramAllowFromId, + resolveEntries: async ({ credentialValue, entries }) => + resolveTelegramAllowFromEntries({ + credentialValue, + entries, + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchChannelConfigForAccount({ + cfg, + channel, + accountId, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; + +const telegramSetupPlugin = { + id: channel, + meta: { + ...getChatChannelMeta(channel), + quickstartAllowFrom: true, + }, + config: { + listAccountIds: listTelegramAccountIds, + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => + resolveTelegramAccount({ cfg, accountId }), + resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveTelegramAccount({ cfg, accountId }).config.allowFrom, + }, + setup: telegramSetupAdapter, +} as const; + +export const telegramOnboardingAdapter: ChannelOnboardingAdapter = + buildChannelOnboardingAdapterFromSetupWizard({ + plugin: telegramSetupPlugin, + wizard: telegramSetupWizard, + }); diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts new file mode 100644 index 00000000000..6653c21ee73 --- /dev/null +++ b/src/channels/plugins/setup-wizard.ts @@ -0,0 +1,281 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { resolveChannelDefaultAccountId } from "./helpers.js"; +import type { + ChannelOnboardingAdapter, + ChannelOnboardingDmPolicy, + ChannelOnboardingStatus, + ChannelOnboardingStatusContext, +} from "./onboarding-types.js"; +import { + promptResolvedAllowFrom, + resolveAccountIdForConfigure, + runSingleChannelSecretStep, + splitOnboardingEntries, +} from "./onboarding/helpers.js"; +import type { ChannelSetupInput } from "./types.core.js"; +import type { ChannelPlugin } from "./types.js"; + +export type ChannelSetupWizardStatus = { + configuredLabel: string; + unconfiguredLabel: string; + configuredHint?: string; + unconfiguredHint?: string; + configuredScore?: number; + unconfiguredScore?: number; + resolveConfigured: (params: { cfg: OpenClawConfig }) => boolean | Promise; +}; + +export type ChannelSetupWizardCredentialState = { + accountConfigured: boolean; + hasConfiguredValue: boolean; + resolvedValue?: string; + envValue?: string; +}; + +export type ChannelSetupWizardCredential = { + inputKey: keyof ChannelSetupInput; + providerHint: string; + credentialLabel: string; + preferredEnvVar?: string; + helpTitle?: string; + helpLines?: string[]; + envPrompt: string; + keepPrompt: string; + inputPrompt: string; + allowEnv?: (params: { cfg: OpenClawConfig; accountId: string }) => boolean; + inspect: (params: { + cfg: OpenClawConfig; + accountId: string; + }) => ChannelSetupWizardCredentialState; +}; + +export type ChannelSetupWizardAllowFromEntry = { + input: string; + resolved: boolean; + id: string | null; +}; + +export type ChannelSetupWizardAllowFrom = { + helpTitle?: string; + helpLines?: string[]; + message: string; + placeholder?: string; + invalidWithoutCredentialNote?: string; + parseInputs?: (raw: string) => string[]; + parseId: (raw: string) => string | null; + resolveEntries?: (params: { + cfg: OpenClawConfig; + accountId: string; + credentialValue?: string; + entries: string[]; + }) => Promise; + apply: (params: { + cfg: OpenClawConfig; + accountId: string; + allowFrom: string[]; + }) => OpenClawConfig | Promise; +}; + +export type ChannelSetupWizard = { + channel: string; + status: ChannelSetupWizardStatus; + credential: ChannelSetupWizardCredential; + dmPolicy?: ChannelOnboardingDmPolicy; + allowFrom?: ChannelSetupWizardAllowFrom; + disable?: (cfg: OpenClawConfig) => OpenClawConfig; + onAccountRecorded?: ChannelOnboardingAdapter["onAccountRecorded"]; +}; + +type ChannelSetupWizardPlugin = Pick; + +async function buildStatus( + plugin: ChannelSetupWizardPlugin, + wizard: ChannelSetupWizard, + ctx: ChannelOnboardingStatusContext, +): Promise { + const configured = await wizard.status.resolveConfigured({ cfg: ctx.cfg }); + return { + channel: plugin.id, + configured, + statusLines: [ + `${plugin.meta.label}: ${configured ? wizard.status.configuredLabel : wizard.status.unconfiguredLabel}`, + ], + selectionHint: configured ? wizard.status.configuredHint : wizard.status.unconfiguredHint, + quickstartScore: configured ? wizard.status.configuredScore : wizard.status.unconfiguredScore, + }; +} + +function applySetupInput(params: { + plugin: ChannelSetupWizardPlugin; + cfg: OpenClawConfig; + accountId: string; + input: ChannelSetupInput; +}) { + const setup = params.plugin.setup; + if (!setup?.applyAccountConfig) { + throw new Error(`${params.plugin.id} does not support setup`); + } + const resolvedAccountId = + setup.resolveAccountId?.({ + cfg: params.cfg, + accountId: params.accountId, + input: params.input, + }) ?? params.accountId; + const validationError = setup.validateInput?.({ + cfg: params.cfg, + accountId: resolvedAccountId, + input: params.input, + }); + if (validationError) { + throw new Error(validationError); + } + let next = setup.applyAccountConfig({ + cfg: params.cfg, + accountId: resolvedAccountId, + input: params.input, + }); + if (params.input.name?.trim() && setup.applyAccountName) { + next = setup.applyAccountName({ + cfg: next, + accountId: resolvedAccountId, + name: params.input.name, + }); + } + return { + cfg: next, + accountId: resolvedAccountId, + }; +} + +export function buildChannelOnboardingAdapterFromSetupWizard(params: { + plugin: ChannelSetupWizardPlugin; + wizard: ChannelSetupWizard; +}): ChannelOnboardingAdapter { + const { plugin, wizard } = params; + return { + channel: plugin.id, + getStatus: async (ctx) => buildStatus(plugin, wizard, ctx), + configure: async ({ + cfg, + prompter, + options, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom, + }) => { + const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg }); + const accountId = await resolveAccountIdForConfigure({ + cfg, + prompter, + label: plugin.meta.label, + accountOverride: accountOverrides[plugin.id], + shouldPromptAccountIds, + listAccountIds: plugin.config.listAccountIds, + defaultAccountId, + }); + + let next = cfg; + let credentialState = wizard.credential.inspect({ cfg: next, accountId }); + let resolvedCredentialValue = credentialState.resolvedValue?.trim() || undefined; + const allowEnv = wizard.credential.allowEnv?.({ cfg: next, accountId }) ?? false; + + const credentialResult = await runSingleChannelSecretStep({ + cfg: next, + prompter, + providerHint: wizard.credential.providerHint, + credentialLabel: wizard.credential.credentialLabel, + secretInputMode: options?.secretInputMode, + accountConfigured: credentialState.accountConfigured, + hasConfigToken: credentialState.hasConfiguredValue, + allowEnv, + envValue: credentialState.envValue, + envPrompt: wizard.credential.envPrompt, + keepPrompt: wizard.credential.keepPrompt, + inputPrompt: wizard.credential.inputPrompt, + preferredEnvVar: wizard.credential.preferredEnvVar, + onMissingConfigured: + wizard.credential.helpLines && wizard.credential.helpLines.length > 0 + ? async () => { + await prompter.note( + wizard.credential.helpLines!.join("\n"), + wizard.credential.helpTitle ?? wizard.credential.credentialLabel, + ); + } + : undefined, + applyUseEnv: async (currentCfg) => + applySetupInput({ + plugin, + cfg: currentCfg, + accountId, + input: { + [wizard.credential.inputKey]: undefined, + useEnv: true, + }, + }).cfg, + applySet: async (currentCfg, value, resolvedValue) => { + resolvedCredentialValue = resolvedValue; + return applySetupInput({ + plugin, + cfg: currentCfg, + accountId, + input: { + [wizard.credential.inputKey]: value, + useEnv: false, + }, + }).cfg; + }, + }); + + next = credentialResult.cfg; + credentialState = wizard.credential.inspect({ cfg: next, accountId }); + resolvedCredentialValue = + credentialResult.resolvedValue?.trim() || + credentialState.resolvedValue?.trim() || + undefined; + + if (forceAllowFrom && wizard.allowFrom) { + if (wizard.allowFrom.helpLines && wizard.allowFrom.helpLines.length > 0) { + await prompter.note( + wizard.allowFrom.helpLines.join("\n"), + wizard.allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, + ); + } + const existingAllowFrom = + plugin.config.resolveAllowFrom?.({ + cfg: next, + accountId, + }) ?? []; + const unique = await promptResolvedAllowFrom({ + prompter, + existing: existingAllowFrom, + token: resolvedCredentialValue, + message: wizard.allowFrom.message, + placeholder: wizard.allowFrom.placeholder, + label: wizard.allowFrom.helpTitle ?? `${plugin.meta.label} allowlist`, + parseInputs: wizard.allowFrom.parseInputs ?? splitOnboardingEntries, + parseId: wizard.allowFrom.parseId, + invalidWithoutTokenNote: wizard.allowFrom.invalidWithoutCredentialNote, + resolveEntries: wizard.allowFrom.resolveEntries + ? async ({ entries }) => + wizard.allowFrom!.resolveEntries!({ + cfg: next, + accountId, + credentialValue: resolvedCredentialValue, + entries, + }) + : undefined, + }); + next = await wizard.allowFrom.apply({ + cfg: next, + accountId, + allowFrom: unique, + }); + } + + return { cfg: next, accountId }; + }, + dmPolicy: wizard.dmPolicy, + disable: wizard.disable, + onAccountRecorded: wizard.onAccountRecorded, + }; +} diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index a0d5aabadc7..3c821ab601b 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -1,4 +1,5 @@ import type { ChannelOnboardingAdapter } from "./onboarding-types.js"; +import type { ChannelSetupWizard } from "./setup-wizard.js"; import type { ChannelAuthAdapter, ChannelCommandAdapter, @@ -58,6 +59,7 @@ export type ChannelPlugin; configSchema?: ChannelConfigSchema; setup?: ChannelSetupAdapter; diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index 3f7bea2da19..536d745a446 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -1,10 +1,25 @@ import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js"; import type { ChannelChoice } from "../onboard-types.js"; import type { ChannelOnboardingAdapter } from "./types.js"; +const setupWizardAdapters = new WeakMap(); + function resolveChannelOnboardingAdapter( plugin: (typeof listChannelSetupPlugins)[number], ): ChannelOnboardingAdapter | undefined { + if (plugin.setupWizard) { + const cached = setupWizardAdapters.get(plugin); + if (cached) { + return cached; + } + const adapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin, + wizard: plugin.setupWizard, + }); + setupWizardAdapters.set(plugin, adapter); + return adapter; + } if (plugin.onboarding) { return plugin.onboarding; }