import type { OpenClawConfig } from "../../config/config.js"; import type { DmPolicy, GroupPolicy } from "../../config/types.js"; import type { SecretInput } from "../../config/types.secrets.js"; import { promptSecretRefForSetup, resolveSecretInputModeForEnvSelection, } from "../../plugins/provider-auth-input.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; import { moveSingleAccountChannelSectionToDefaultAccount, patchScopedAccountConfig, } from "./setup-helpers.js"; import type { PromptAccountId, PromptAccountIdParams } from "./setup-wizard-types.js"; export const promptAccountId: PromptAccountId = async (params: PromptAccountIdParams) => { const existingIds = params.listAccountIds(params.cfg); const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID; const choice = await params.prompter.select({ message: `${params.label} account`, options: [ ...existingIds.map((id) => ({ value: id, label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id, })), { value: "__new__", label: "Add a new account" }, ], initialValue: initial, }); if (choice !== "__new__") { return normalizeAccountId(choice); } const entered = await params.prompter.text({ message: `New ${params.label} account id`, validate: (value) => (value?.trim() ? undefined : "Required"), }); const normalized = normalizeAccountId(String(entered)); if (String(entered).trim() !== normalized) { await params.prompter.note( `Normalized account id to "${normalized}".`, `${params.label} account`, ); } return normalized; }; export function addWildcardAllowFrom(allowFrom?: Array | null): string[] { const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean); if (!next.includes("*")) { next.push("*"); } return next; } export function mergeAllowFromEntries( current: Array | null | undefined, additions: Array, ): string[] { const merged = [...(current ?? []), ...additions].map((v) => String(v).trim()).filter(Boolean); return [...new Set(merged)]; } export function splitSetupEntries(raw: string): string[] { return raw .split(/[\n,;]+/g) .map((entry) => entry.trim()) .filter(Boolean); } type ParsedSetupEntry = { value: string } | { error: string }; export function parseSetupEntriesWithParser( raw: string, parseEntry: (entry: string) => ParsedSetupEntry, ): { entries: string[]; error?: string } { const parts = splitSetupEntries(String(raw ?? "")); const entries: string[] = []; for (const part of parts) { const parsed = parseEntry(part); if ("error" in parsed) { return { entries: [], error: parsed.error }; } entries.push(parsed.value); } return { entries: normalizeAllowFromEntries(entries) }; } export function parseSetupEntriesAllowingWildcard( raw: string, parseEntry: (entry: string) => ParsedSetupEntry, ): { entries: string[]; error?: string } { return parseSetupEntriesWithParser(raw, (entry) => { if (entry === "*") { return { value: "*" }; } return parseEntry(entry); }); } export function parseMentionOrPrefixedId(params: { value: string; mentionPattern: RegExp; prefixPattern?: RegExp; idPattern: RegExp; normalizeId?: (id: string) => string; }): string | null { const trimmed = params.value.trim(); if (!trimmed) { return null; } const mentionMatch = trimmed.match(params.mentionPattern); if (mentionMatch?.[1]) { return params.normalizeId ? params.normalizeId(mentionMatch[1]) : mentionMatch[1]; } const stripped = params.prefixPattern ? trimmed.replace(params.prefixPattern, "") : trimmed; if (!params.idPattern.test(stripped)) { return null; } return params.normalizeId ? params.normalizeId(stripped) : stripped; } export function normalizeAllowFromEntries( entries: Array, normalizeEntry?: (value: string) => string | null | undefined, ): string[] { const normalized = entries .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => { if (entry === "*") { return "*"; } if (!normalizeEntry) { return entry; } const value = normalizeEntry(entry); return typeof value === "string" ? value.trim() : ""; }) .filter(Boolean); return [...new Set(normalized)]; } export function resolveSetupAccountId(params: { accountId?: string; defaultAccountId: string; }): string { return params.accountId?.trim() ? normalizeAccountId(params.accountId) : params.defaultAccountId; } export async function resolveAccountIdForConfigure(params: { cfg: OpenClawConfig; prompter: WizardPrompter; label: string; accountOverride?: string; shouldPromptAccountIds: boolean; listAccountIds: (cfg: OpenClawConfig) => string[]; defaultAccountId: string; }): Promise { const override = params.accountOverride?.trim(); let accountId = override ? normalizeAccountId(override) : params.defaultAccountId; if (params.shouldPromptAccountIds && !override) { accountId = await promptAccountId({ cfg: params.cfg, prompter: params.prompter, label: params.label, currentId: accountId, listAccountIds: params.listAccountIds, defaultAccountId: params.defaultAccountId, }); } return accountId; } export function setAccountAllowFromForChannel(params: { cfg: OpenClawConfig; channel: "imessage" | "signal"; accountId: string; allowFrom: string[]; }): OpenClawConfig { const { cfg, channel, accountId, allowFrom } = params; return patchConfigForScopedAccount({ cfg, channel, accountId, patch: { allowFrom }, ensureEnabled: false, }); } function patchTopLevelChannelConfig(params: { cfg: OpenClawConfig; channel: string; enabled?: boolean; patch: Record; }): OpenClawConfig { const channelConfig = (params.cfg.channels?.[params.channel] as Record | undefined) ?? {}; return { ...params.cfg, channels: { ...params.cfg.channels, [params.channel]: { ...channelConfig, ...(params.enabled ? { enabled: true } : {}), ...params.patch, }, }, }; } export function setTopLevelChannelAllowFrom(params: { cfg: OpenClawConfig; channel: string; allowFrom: string[]; enabled?: boolean; }): OpenClawConfig { return patchTopLevelChannelConfig({ cfg: params.cfg, channel: params.channel, enabled: params.enabled, patch: { allowFrom: params.allowFrom }, }); } export function setTopLevelChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; channel: string; dmPolicy: DmPolicy; getAllowFrom?: (cfg: OpenClawConfig) => Array | undefined; }): OpenClawConfig { const channelConfig = (params.cfg.channels?.[params.channel] as Record | undefined) ?? {}; const existingAllowFrom = params.getAllowFrom?.(params.cfg) ?? (channelConfig.allowFrom as Array | undefined) ?? undefined; const allowFrom = params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; return patchTopLevelChannelConfig({ cfg: params.cfg, channel: params.channel, patch: { dmPolicy: params.dmPolicy, ...(allowFrom ? { allowFrom } : {}), }, }); } export function setTopLevelChannelGroupPolicy(params: { cfg: OpenClawConfig; channel: string; groupPolicy: GroupPolicy; enabled?: boolean; }): OpenClawConfig { return patchTopLevelChannelConfig({ cfg: params.cfg, channel: params.channel, enabled: params.enabled, patch: { groupPolicy: params.groupPolicy }, }); } export function setChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; channel: "imessage" | "signal" | "telegram"; dmPolicy: DmPolicy; }): OpenClawConfig { const { cfg, channel, dmPolicy } = params; const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.[channel]?.allowFrom) : undefined; return { ...cfg, channels: { ...cfg.channels, [channel]: { ...cfg.channels?.[channel], dmPolicy, ...(allowFrom ? { allowFrom } : {}), }, }, }; } export function setLegacyChannelDmPolicyWithAllowFrom(params: { cfg: OpenClawConfig; channel: LegacyDmChannel; dmPolicy: DmPolicy; }): OpenClawConfig { const channelConfig = (params.cfg.channels?.[params.channel] as | { allowFrom?: Array; dm?: { allowFrom?: Array }; } | undefined) ?? { allowFrom: undefined, dm: undefined, }; const existingAllowFrom = channelConfig.allowFrom ?? channelConfig.dm?.allowFrom; const allowFrom = params.dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined; return patchLegacyDmChannelConfig({ cfg: params.cfg, channel: params.channel, patch: { dmPolicy: params.dmPolicy, ...(allowFrom ? { allowFrom } : {}), }, }); } export function setLegacyChannelAllowFrom(params: { cfg: OpenClawConfig; channel: LegacyDmChannel; allowFrom: string[]; }): OpenClawConfig { return patchLegacyDmChannelConfig({ cfg: params.cfg, channel: params.channel, patch: { allowFrom: params.allowFrom }, }); } export function setAccountGroupPolicyForChannel(params: { cfg: OpenClawConfig; channel: "discord" | "slack"; accountId: string; groupPolicy: GroupPolicy; }): OpenClawConfig { return patchChannelConfigForAccount({ cfg: params.cfg, channel: params.channel, accountId: params.accountId, patch: { groupPolicy: params.groupPolicy }, }); } type AccountScopedChannel = "discord" | "slack" | "telegram" | "imessage" | "signal"; type LegacyDmChannel = "discord" | "slack"; export function patchLegacyDmChannelConfig(params: { cfg: OpenClawConfig; channel: LegacyDmChannel; patch: Record; }): OpenClawConfig { const { cfg, channel, patch } = params; const channelConfig = (cfg.channels?.[channel] as Record | undefined) ?? {}; const dmConfig = (channelConfig.dm as Record | undefined) ?? {}; return { ...cfg, channels: { ...cfg.channels, [channel]: { ...channelConfig, ...patch, dm: { ...dmConfig, enabled: typeof dmConfig.enabled === "boolean" ? dmConfig.enabled : true, }, }, }, }; } export function setSetupChannelEnabled( cfg: OpenClawConfig, channel: string, enabled: boolean, ): OpenClawConfig { const channelConfig = (cfg.channels?.[channel] as Record | undefined) ?? {}; return { ...cfg, channels: { ...cfg.channels, [channel]: { ...channelConfig, enabled, }, }, }; } function patchConfigForScopedAccount(params: { cfg: OpenClawConfig; channel: AccountScopedChannel; accountId: string; patch: Record; ensureEnabled: boolean; }): OpenClawConfig { const { cfg, channel, accountId, patch, ensureEnabled } = params; const seededCfg = accountId === DEFAULT_ACCOUNT_ID ? cfg : moveSingleAccountChannelSectionToDefaultAccount({ cfg, channelKey: channel, }); return patchScopedAccountConfig({ cfg: seededCfg, channelKey: channel, accountId, patch, ensureChannelEnabled: ensureEnabled, ensureAccountEnabled: ensureEnabled, }); } export function patchChannelConfigForAccount(params: { cfg: OpenClawConfig; channel: AccountScopedChannel; accountId: string; patch: Record; }): OpenClawConfig { return patchConfigForScopedAccount({ ...params, ensureEnabled: true, }); } export function applySingleTokenPromptResult(params: { cfg: OpenClawConfig; channel: "discord" | "telegram"; accountId: string; tokenPatchKey: "token" | "botToken"; tokenResult: { useEnv: boolean; token: SecretInput | null; }; }): OpenClawConfig { let next = params.cfg; if (params.tokenResult.useEnv) { next = patchChannelConfigForAccount({ cfg: next, channel: params.channel, accountId: params.accountId, patch: {}, }); } if (params.tokenResult.token) { next = patchChannelConfigForAccount({ cfg: next, channel: params.channel, accountId: params.accountId, patch: { [params.tokenPatchKey]: params.tokenResult.token }, }); } return next; } export function buildSingleChannelSecretPromptState(params: { accountConfigured: boolean; hasConfigToken: boolean; allowEnv: boolean; envValue?: string; }): { accountConfigured: boolean; hasConfigToken: boolean; canUseEnv: boolean; } { return { accountConfigured: params.accountConfigured, hasConfigToken: params.hasConfigToken, canUseEnv: params.allowEnv && Boolean(params.envValue?.trim()) && !params.hasConfigToken, }; } export async function promptSingleChannelToken(params: { prompter: Pick; accountConfigured: boolean; canUseEnv: boolean; hasConfigToken: boolean; envPrompt: string; keepPrompt: string; inputPrompt: string; }): Promise<{ useEnv: boolean; token: string | null }> { const promptToken = async (): Promise => String( await params.prompter.text({ message: params.inputPrompt, validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); if (params.canUseEnv) { const keepEnv = await params.prompter.confirm({ message: params.envPrompt, initialValue: true, }); if (keepEnv) { return { useEnv: true, token: null }; } return { useEnv: false, token: await promptToken() }; } if (params.hasConfigToken && params.accountConfigured) { const keep = await params.prompter.confirm({ message: params.keepPrompt, initialValue: true, }); if (keep) { return { useEnv: false, token: null }; } } return { useEnv: false, token: await promptToken() }; } export type SingleChannelSecretInputPromptResult = | { action: "keep" } | { action: "use-env" } | { action: "set"; value: SecretInput; resolvedValue: string }; export async function runSingleChannelSecretStep(params: { cfg: OpenClawConfig; prompter: Pick; providerHint: string; credentialLabel: string; secretInputMode?: "plaintext" | "ref"; accountConfigured: boolean; hasConfigToken: boolean; allowEnv: boolean; envValue?: string; envPrompt: string; keepPrompt: string; inputPrompt: string; preferredEnvVar?: string; onMissingConfigured?: () => Promise; applyUseEnv?: (cfg: OpenClawConfig) => OpenClawConfig | Promise; applySet?: ( cfg: OpenClawConfig, value: SecretInput, resolvedValue: string, ) => OpenClawConfig | Promise; }): Promise<{ cfg: OpenClawConfig; action: SingleChannelSecretInputPromptResult["action"]; resolvedValue?: string; }> { const promptState = buildSingleChannelSecretPromptState({ accountConfigured: params.accountConfigured, hasConfigToken: params.hasConfigToken, allowEnv: params.allowEnv, envValue: params.envValue, }); if (!promptState.accountConfigured && params.onMissingConfigured) { await params.onMissingConfigured(); } const result = await promptSingleChannelSecretInput({ cfg: params.cfg, prompter: params.prompter, providerHint: params.providerHint, credentialLabel: params.credentialLabel, secretInputMode: params.secretInputMode, accountConfigured: promptState.accountConfigured, canUseEnv: promptState.canUseEnv, hasConfigToken: promptState.hasConfigToken, envPrompt: params.envPrompt, keepPrompt: params.keepPrompt, inputPrompt: params.inputPrompt, preferredEnvVar: params.preferredEnvVar, }); if (result.action === "use-env") { return { cfg: params.applyUseEnv ? await params.applyUseEnv(params.cfg) : params.cfg, action: result.action, resolvedValue: params.envValue?.trim() || undefined, }; } if (result.action === "set") { return { cfg: params.applySet ? await params.applySet(params.cfg, result.value, result.resolvedValue) : params.cfg, action: result.action, resolvedValue: result.resolvedValue, }; } return { cfg: params.cfg, action: result.action, }; } export async function promptSingleChannelSecretInput(params: { cfg: OpenClawConfig; prompter: Pick; providerHint: string; credentialLabel: string; secretInputMode?: "plaintext" | "ref"; accountConfigured: boolean; canUseEnv: boolean; hasConfigToken: boolean; envPrompt: string; keepPrompt: string; inputPrompt: string; preferredEnvVar?: string; }): Promise { const selectedMode = await resolveSecretInputModeForEnvSelection({ prompter: params.prompter as WizardPrompter, explicitMode: params.secretInputMode, copy: { modeMessage: `How do you want to provide this ${params.credentialLabel}?`, plaintextLabel: `Enter ${params.credentialLabel}`, plaintextHint: "Stores the credential directly in OpenClaw config", refLabel: "Use external secret provider", refHint: "Stores a reference to env or configured external secret providers", }, }); if (selectedMode === "plaintext") { const plainResult = await promptSingleChannelToken({ prompter: params.prompter, accountConfigured: params.accountConfigured, canUseEnv: params.canUseEnv, hasConfigToken: params.hasConfigToken, envPrompt: params.envPrompt, keepPrompt: params.keepPrompt, inputPrompt: params.inputPrompt, }); if (plainResult.useEnv) { return { action: "use-env" }; } if (plainResult.token) { return { action: "set", value: plainResult.token, resolvedValue: plainResult.token }; } return { action: "keep" }; } if (params.hasConfigToken && params.accountConfigured) { const keep = await params.prompter.confirm({ message: params.keepPrompt, initialValue: true, }); if (keep) { return { action: "keep" }; } } const resolved = await promptSecretRefForSetup({ provider: params.providerHint, config: params.cfg, prompter: params.prompter as WizardPrompter, preferredEnvVar: params.preferredEnvVar, copy: { sourceMessage: `Where is this ${params.credentialLabel} stored?`, envVarPlaceholder: params.preferredEnvVar ?? "OPENCLAW_SECRET", envVarFormatError: 'Use an env var name like "OPENCLAW_SECRET" (uppercase letters, numbers, underscores).', noProvidersMessage: "No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.", }, }); return { action: "set", value: resolved.ref, resolvedValue: resolved.resolvedValue, }; } type ParsedAllowFromResult = { entries: string[]; error?: string }; export async function promptParsedAllowFromForScopedChannel(params: { cfg: OpenClawConfig; channel: "imessage" | "signal"; accountId?: string; defaultAccountId: string; prompter: Pick; noteTitle: string; noteLines: string[]; message: string; placeholder: string; parseEntries: (raw: string) => ParsedAllowFromResult; getExistingAllowFrom: (params: { cfg: OpenClawConfig; accountId: string; }) => Array; }): Promise { const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: params.defaultAccountId, }); const existing = params.getExistingAllowFrom({ cfg: params.cfg, accountId, }); await params.prompter.note(params.noteLines.join("\n"), params.noteTitle); const entry = await params.prompter.text({ message: params.message, placeholder: params.placeholder, initialValue: existing[0] ? String(existing[0]) : undefined, validate: (value) => { const raw = String(value ?? "").trim(); if (!raw) { return "Required"; } return params.parseEntries(raw).error; }, }); const parsed = params.parseEntries(String(entry)); const unique = mergeAllowFromEntries(undefined, parsed.entries); return setAccountAllowFromForChannel({ cfg: params.cfg, channel: params.channel, accountId, allowFrom: unique, }); } export async function noteChannelLookupSummary(params: { prompter: Pick; label: string; resolvedSections: Array<{ title: string; values: string[] }>; unresolved?: string[]; }): Promise { const lines: string[] = []; for (const section of params.resolvedSections) { if (section.values.length === 0) { continue; } lines.push(`${section.title}: ${section.values.join(", ")}`); } if (params.unresolved && params.unresolved.length > 0) { lines.push(`Unresolved (kept as typed): ${params.unresolved.join(", ")}`); } if (lines.length > 0) { await params.prompter.note(lines.join("\n"), params.label); } } export async function noteChannelLookupFailure(params: { prompter: Pick; label: string; error: unknown; }): Promise { await params.prompter.note( `Channel lookup failed; keeping entries as typed. ${String(params.error)}`, params.label, ); } type AllowFromResolution = { input: string; resolved: boolean; id?: string | null; }; export async function promptResolvedAllowFrom(params: { prompter: WizardPrompter; existing: Array; token?: string | null; message: string; placeholder: string; label: string; parseInputs: (value: string) => string[]; parseId: (value: string) => string | null; invalidWithoutTokenNote: string; resolveEntries: (params: { token: string; entries: string[] }) => Promise; }): Promise { while (true) { const entry = await params.prompter.text({ message: params.message, placeholder: params.placeholder, initialValue: params.existing[0] ? String(params.existing[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); const parts = params.parseInputs(String(entry)); if (!params.token) { const ids = parts.map(params.parseId).filter(Boolean) as string[]; if (ids.length !== parts.length) { await params.prompter.note(params.invalidWithoutTokenNote, params.label); continue; } return mergeAllowFromEntries(params.existing, ids); } const results = await params .resolveEntries({ token: params.token, entries: parts, }) .catch(() => null); if (!results) { await params.prompter.note("Failed to resolve usernames. Try again.", params.label); continue; } const unresolved = results.filter((res) => !res.resolved || !res.id); if (unresolved.length > 0) { await params.prompter.note( `Could not resolve: ${unresolved.map((res) => res.input).join(", ")}`, params.label, ); continue; } const ids = results.map((res) => res.id as string); return mergeAllowFromEntries(params.existing, ids); } } export async function promptLegacyChannelAllowFrom(params: { cfg: OpenClawConfig; channel: LegacyDmChannel; prompter: WizardPrompter; existing: Array; token?: string | null; noteTitle: string; noteLines: string[]; message: string; placeholder: string; parseId: (value: string) => string | null; invalidWithoutTokenNote: string; resolveEntries: (params: { token: string; entries: string[] }) => Promise; }): Promise { await params.prompter.note(params.noteLines.join("\n"), params.noteTitle); const unique = await promptResolvedAllowFrom({ prompter: params.prompter, existing: params.existing, token: params.token, message: params.message, placeholder: params.placeholder, label: params.noteTitle, parseInputs: splitSetupEntries, parseId: params.parseId, invalidWithoutTokenNote: params.invalidWithoutTokenNote, resolveEntries: params.resolveEntries, }); return setLegacyChannelAllowFrom({ cfg: params.cfg, channel: params.channel, allowFrom: unique, }); }