diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 62d64fb0866..b1fd0fc89d8 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -32,11 +32,11 @@ import { isChannelTarget, normalizeIrcAllowEntry, } from "./normalize.js"; -import { ircOnboardingAdapter } from "./onboarding.js"; import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; import { probeIrc } from "./probe.js"; import { getIrcRuntime } from "./runtime.js"; import { sendMessageIrc } from "./send.js"; +import { ircSetupAdapter, ircSetupWizard } from "./setup-surface.js"; import type { CoreConfig, IrcProbe } from "./types.js"; const meta = getChatChannelMeta("irc"); @@ -66,7 +66,8 @@ export const ircPlugin: ChannelPlugin = { ...meta, quickstartAllowFrom: true, }, - onboarding: ircOnboardingAdapter, + setup: ircSetupAdapter, + setupWizard: ircSetupWizard, pairing: { idLabel: "ircUser", normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry), diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/onboarding.test.ts index 613503700f3..38738d1e484 100644 --- a/extensions/irc/src/onboarding.test.ts +++ b/extensions/irc/src/onboarding.test.ts @@ -1,7 +1,8 @@ import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/irc"; import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; -import { ircOnboardingAdapter } from "./onboarding.js"; +import { ircPlugin } from "./channel.js"; import type { CoreConfig } from "./types.js"; const selectFirstOption = async (params: { options: Array<{ value: T }> }): Promise => { @@ -26,7 +27,12 @@ function createPrompter(overrides: Partial): WizardPrompter { }; } -describe("irc onboarding", () => { +const ircConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: ircPlugin, + wizard: ircPlugin.setupWizard!, +}); + +describe("irc setup wizard", () => { it("configures host and nick via onboarding prompts", async () => { const prompter = createPrompter({ text: vi.fn(async ({ message }: { message: string }) => { @@ -66,7 +72,7 @@ describe("irc onboarding", () => { const runtime: RuntimeEnv = createRuntimeEnv(); - const result = await ircOnboardingAdapter.configure({ + const result = await ircConfigureAdapter.configure({ cfg: {} as CoreConfig, runtime, prompter, @@ -97,7 +103,7 @@ describe("irc onboarding", () => { confirm: vi.fn(async () => false), }); - const promptAllowFrom = ircOnboardingAdapter.dmPolicy?.promptAllowFrom; + const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom; expect(promptAllowFrom).toBeTypeOf("function"); const cfg: CoreConfig = { diff --git a/extensions/irc/src/onboarding.ts b/extensions/irc/src/onboarding.ts deleted file mode 100644 index 5e7c80c94d7..00000000000 --- a/extensions/irc/src/onboarding.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, - patchScopedAccountConfig, - promptChannelAccessConfig, - resolveAccountIdForConfigure, - setTopLevelChannelAllowFrom, - setTopLevelChannelDmPolicyWithAllowFrom, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type DmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/irc"; -import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; -import { - isChannelTarget, - normalizeIrcAllowEntry, - normalizeIrcMessagingTarget, -} from "./normalize.js"; -import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; - -const channel = "irc" as const; - -function parseListInput(raw: string): string[] { - return raw - .split(/[\n,;]+/g) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function parsePort(raw: string, fallback: number): number { - const trimmed = raw.trim(); - if (!trimmed) { - return fallback; - } - const parsed = Number.parseInt(trimmed, 10); - if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { - return fallback; - } - return parsed; -} - -function normalizeGroupEntry(raw: string): string | null { - const trimmed = raw.trim(); - if (!trimmed) { - return null; - } - if (trimmed === "*") { - return "*"; - } - const normalized = normalizeIrcMessagingTarget(trimmed) ?? trimmed; - if (isChannelTarget(normalized)) { - return normalized; - } - return `#${normalized.replace(/^#+/, "")}`; -} - -function updateIrcAccountConfig( - cfg: CoreConfig, - accountId: string, - patch: Partial, -): CoreConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch, - ensureChannelEnabled: false, - ensureAccountEnabled: false, - }) as CoreConfig; -} - -function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "irc", - dmPolicy, - }) as CoreConfig; -} - -function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { - return setTopLevelChannelAllowFrom({ - cfg, - channel: "irc", - allowFrom, - }) as CoreConfig; -} - -function setIrcNickServ( - cfg: CoreConfig, - accountId: string, - nickserv?: IrcNickServConfig, -): CoreConfig { - return updateIrcAccountConfig(cfg, accountId, { nickserv }); -} - -function setIrcGroupAccess( - cfg: CoreConfig, - accountId: string, - policy: "open" | "allowlist" | "disabled", - entries: string[], -): CoreConfig { - if (policy !== "allowlist") { - return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); - } - const normalizedEntries = [ - ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), - ]; - const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); - return updateIrcAccountConfig(cfg, accountId, { - enabled: true, - groupPolicy: "allowlist", - groups, - }); -} - -async function noteIrcSetupHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "IRC needs server host + bot nick.", - "Recommended: TLS on port 6697.", - "Optional: NickServ identify/register can be configured in onboarding.", - 'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.', - 'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).', - "Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.", - `Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`, - ].join("\n"), - "IRC setup", - ); -} - -async function promptIrcAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const existing = params.cfg.channels?.irc?.allowFrom ?? []; - - await params.prompter.note( - [ - "Allowlist IRC DMs by sender.", - "Examples:", - "- alice", - "- alice!ident@example.org", - "Multiple entries: comma-separated.", - ].join("\n"), - "IRC allowlist", - ); - - const raw = await params.prompter.text({ - message: "IRC allowFrom (nick or nick!user@host)", - placeholder: "alice, bob!ident@example.org", - initialValue: existing[0] ? String(existing[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - - const parsed = parseListInput(String(raw)); - const normalized = [ - ...new Set( - parsed - .map((entry) => normalizeIrcAllowEntry(entry)) - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ]; - return setIrcAllowFrom(params.cfg, normalized); -} - -async function promptIrcNickServConfig(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId }); - const existing = resolved.config.nickserv; - const hasExisting = Boolean(existing?.password || existing?.passwordFile); - const wants = await params.prompter.confirm({ - message: hasExisting ? "Update NickServ settings?" : "Configure NickServ identify/register?", - initialValue: hasExisting, - }); - if (!wants) { - return params.cfg; - } - - const service = String( - await params.prompter.text({ - message: "NickServ service nick", - initialValue: existing?.service || "NickServ", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const useEnvPassword = - params.accountId === DEFAULT_ACCOUNT_ID && - Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) && - !(existing?.password || existing?.passwordFile) - ? await params.prompter.confirm({ - message: "IRC_NICKSERV_PASSWORD detected. Use env var?", - initialValue: true, - }) - : false; - - const password = useEnvPassword - ? undefined - : String( - await params.prompter.text({ - message: "NickServ password (blank to disable NickServ auth)", - validate: () => undefined, - }), - ).trim(); - - if (!password && !useEnvPassword) { - return setIrcNickServ(params.cfg, params.accountId, { - enabled: false, - service, - }); - } - - const register = await params.prompter.confirm({ - message: "Send NickServ REGISTER on connect?", - initialValue: existing?.register ?? false, - }); - const registerEmail = register - ? String( - await params.prompter.text({ - message: "NickServ register email", - initialValue: - existing?.registerEmail || - (params.accountId === DEFAULT_ACCOUNT_ID - ? process.env.IRC_NICKSERV_REGISTER_EMAIL - : undefined), - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim() - : undefined; - - return setIrcNickServ(params.cfg, params.accountId, { - enabled: true, - service, - ...(password ? { password } : {}), - register, - ...(registerEmail ? { registerEmail } : {}), - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "IRC", - channel, - policyKey: "channels.irc.dmPolicy", - allowFromKey: "channels.irc.allowFrom", - getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy), - promptAllowFrom: promptIrcAllowFrom, -}; - -export const ircOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const coreCfg = cfg as CoreConfig; - const configured = listIrcAccountIds(coreCfg).some( - (accountId) => resolveIrcAccount({ cfg: coreCfg, accountId }).configured, - ); - return { - channel, - configured, - statusLines: [`IRC: ${configured ? "configured" : "needs host + nick"}`], - selectionHint: configured ? "configured" : "needs host + nick", - quickstartScore: configured ? 1 : 0, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - let next = cfg as CoreConfig; - const defaultAccountId = resolveDefaultIrcAccountId(next); - const accountId = await resolveAccountIdForConfigure({ - cfg: next, - prompter, - label: "IRC", - accountOverride: accountOverrides.irc, - shouldPromptAccountIds, - listAccountIds: listIrcAccountIds, - defaultAccountId, - }); - - const resolved = resolveIrcAccount({ cfg: next, accountId }); - const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID; - const envHost = isDefaultAccount ? process.env.IRC_HOST?.trim() : ""; - const envNick = isDefaultAccount ? process.env.IRC_NICK?.trim() : ""; - const envReady = Boolean(envHost && envNick); - - if (!resolved.configured) { - await noteIrcSetupHelp(prompter); - } - - let useEnv = false; - if (envReady && isDefaultAccount && !resolved.config.host && !resolved.config.nick) { - useEnv = await prompter.confirm({ - message: "IRC_HOST and IRC_NICK detected. Use env vars?", - initialValue: true, - }); - } - - if (useEnv) { - next = updateIrcAccountConfig(next, accountId, { enabled: true }); - } else { - const host = String( - await prompter.text({ - message: "IRC server host", - initialValue: resolved.config.host || envHost || undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const tls = await prompter.confirm({ - message: "Use TLS for IRC?", - initialValue: resolved.config.tls ?? true, - }); - const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667); - const portInput = await prompter.text({ - message: "IRC server port", - initialValue: String(defaultPort), - validate: (value) => { - const parsed = Number.parseInt(String(value ?? "").trim(), 10); - return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535 - ? undefined - : "Use a port between 1 and 65535"; - }, - }); - const port = parsePort(String(portInput), defaultPort); - - const nick = String( - await prompter.text({ - message: "IRC nick", - initialValue: resolved.config.nick || envNick || undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const username = String( - await prompter.text({ - message: "IRC username", - initialValue: resolved.config.username || nick || "openclaw", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const realname = String( - await prompter.text({ - message: "IRC real name", - initialValue: resolved.config.realname || "OpenClaw", - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - - const channelsRaw = await prompter.text({ - message: "Auto-join IRC channels (optional, comma-separated)", - placeholder: "#openclaw, #ops", - initialValue: (resolved.config.channels ?? []).join(", "), - }); - const channels = [ - ...new Set( - parseListInput(String(channelsRaw)) - .map((entry) => normalizeGroupEntry(entry)) - .filter((entry): entry is string => Boolean(entry && entry !== "*")) - .filter((entry) => isChannelTarget(entry)), - ), - ]; - - next = updateIrcAccountConfig(next, accountId, { - enabled: true, - host, - port, - tls, - nick, - username, - realname, - channels: channels.length > 0 ? channels : undefined, - }); - } - - const afterConfig = resolveIrcAccount({ cfg: next, accountId }); - const accessConfig = await promptChannelAccessConfig({ - prompter, - label: "IRC channels", - currentPolicy: afterConfig.config.groupPolicy ?? "allowlist", - currentEntries: Object.keys(afterConfig.config.groups ?? {}), - placeholder: "#openclaw, #ops, *", - updatePrompt: Boolean(afterConfig.config.groups), - }); - if (accessConfig) { - next = setIrcGroupAccess(next, accountId, accessConfig.policy, accessConfig.entries); - - // Mention gating: groups/channels are mention-gated by default. Make this explicit in onboarding. - const wantsMentions = await prompter.confirm({ - message: "Require @mention to reply in IRC channels?", - initialValue: true, - }); - if (!wantsMentions) { - const resolvedAfter = resolveIrcAccount({ cfg: next, accountId }); - const groups = resolvedAfter.config.groups ?? {}; - const patched = Object.fromEntries( - Object.entries(groups).map(([key, value]) => [key, { ...value, requireMention: false }]), - ); - next = updateIrcAccountConfig(next, accountId, { groups: patched }); - } - } - - if (forceAllowFrom) { - next = await promptIrcAllowFrom({ cfg: next, prompter, accountId }); - } - next = await promptIrcNickServConfig({ - cfg: next, - prompter, - accountId, - }); - - await prompter.note( - [ - "Next: restart gateway and verify status.", - "Command: openclaw channels status --probe", - `Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`, - ].join("\n"), - "IRC next steps", - ); - - return { cfg: next, accountId }; - }, - dmPolicy, - disable: (cfg) => ({ - ...(cfg as CoreConfig), - channels: { - ...(cfg as CoreConfig).channels, - irc: { - ...(cfg as CoreConfig).channels?.irc, - enabled: false, - }, - }, - }), -}; diff --git a/extensions/irc/src/setup-surface.ts b/extensions/irc/src/setup-surface.ts new file mode 100644 index 00000000000..aaee61a9532 --- /dev/null +++ b/extensions/irc/src/setup-surface.ts @@ -0,0 +1,586 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + resolveOnboardingAccountId, + setOnboardingChannelEnabled, + setTopLevelChannelAllowFrom, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; +import { + isChannelTarget, + normalizeIrcAllowEntry, + normalizeIrcMessagingTarget, +} from "./normalize.js"; +import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; + +const channel = "irc" as const; +const USE_ENV_FLAG = "__ircUseEnv"; +const TLS_FLAG = "__ircTls"; + +type IrcSetupInput = ChannelSetupInput & { + host?: string; + port?: number | string; + tls?: boolean; + nick?: string; + username?: string; + realname?: string; + channels?: string[]; + password?: string; +}; + +function parseListInput(raw: string): string[] { + return raw + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parsePort(raw: string, fallback: number): number { + const trimmed = raw.trim(); + if (!trimmed) { + return fallback; + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) { + return fallback; + } + return parsed; +} + +function normalizeGroupEntry(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return "*"; + } + const normalized = normalizeIrcMessagingTarget(trimmed) ?? trimmed; + if (isChannelTarget(normalized)) { + return normalized; + } + return `#${normalized.replace(/^#+/, "")}`; +} + +function updateIrcAccountConfig( + cfg: CoreConfig, + accountId: string, + patch: Partial, +): CoreConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch, + ensureChannelEnabled: false, + ensureAccountEnabled: false, + }) as CoreConfig; +} + +function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig { + return setTopLevelChannelAllowFrom({ + cfg, + channel, + allowFrom, + }) as CoreConfig; +} + +function setIrcNickServ( + cfg: CoreConfig, + accountId: string, + nickserv?: IrcNickServConfig, +): CoreConfig { + return updateIrcAccountConfig(cfg, accountId, { nickserv }); +} + +function setIrcGroupAccess( + cfg: CoreConfig, + accountId: string, + policy: "open" | "allowlist" | "disabled", + entries: string[], +): CoreConfig { + if (policy !== "allowlist") { + return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy }); + } + const normalizedEntries = [ + ...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)), + ]; + const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}])); + return updateIrcAccountConfig(cfg, accountId, { + enabled: true, + groupPolicy: "allowlist", + groups, + }); +} + +async function promptIrcAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const existing = params.cfg.channels?.irc?.allowFrom ?? []; + + await params.prompter.note( + [ + "Allowlist IRC DMs by sender.", + "Examples:", + "- alice", + "- alice!ident@example.org", + "Multiple entries: comma-separated.", + ].join("\n"), + "IRC allowlist", + ); + + const raw = await params.prompter.text({ + message: "IRC allowFrom (nick or nick!user@host)", + placeholder: "alice, bob!ident@example.org", + initialValue: existing[0] ? String(existing[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + + const parsed = parseListInput(String(raw)); + const normalized = [ + ...new Set( + parsed + .map((entry) => normalizeIrcAllowEntry(entry)) + .map((entry) => entry.trim()) + .filter(Boolean), + ), + ]; + return setIrcAllowFrom(params.cfg, normalized); +} + +async function promptIrcNickServConfig(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId }); + const existing = resolved.config.nickserv; + const hasExisting = Boolean(existing?.password || existing?.passwordFile); + const wants = await params.prompter.confirm({ + message: hasExisting ? "Update NickServ settings?" : "Configure NickServ identify/register?", + initialValue: hasExisting, + }); + if (!wants) { + return params.cfg; + } + + const service = String( + await params.prompter.text({ + message: "NickServ service nick", + initialValue: existing?.service || "NickServ", + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim(); + + const useEnvPassword = + params.accountId === DEFAULT_ACCOUNT_ID && + Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) && + !(existing?.password || existing?.passwordFile) + ? await params.prompter.confirm({ + message: "IRC_NICKSERV_PASSWORD detected. Use env var?", + initialValue: true, + }) + : false; + + const password = useEnvPassword + ? undefined + : String( + await params.prompter.text({ + message: "NickServ password (blank to disable NickServ auth)", + validate: () => undefined, + }), + ).trim(); + + if (!password && !useEnvPassword) { + return setIrcNickServ(params.cfg, params.accountId, { + enabled: false, + service, + }); + } + + const register = await params.prompter.confirm({ + message: "Send NickServ REGISTER on connect?", + initialValue: existing?.register ?? false, + }); + const registerEmail = register + ? String( + await params.prompter.text({ + message: "NickServ register email", + initialValue: + existing?.registerEmail || + (params.accountId === DEFAULT_ACCOUNT_ID + ? process.env.IRC_NICKSERV_REGISTER_EMAIL + : undefined), + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }), + ).trim() + : undefined; + + return setIrcNickServ(params.cfg, params.accountId, { + enabled: true, + service, + ...(password ? { password } : {}), + register, + ...(registerEmail ? { registerEmail } : {}), + }); +} + +const ircDmPolicy: ChannelOnboardingDmPolicy = { + label: "IRC", + channel, + policyKey: "channels.irc.dmPolicy", + allowFromKey: "channels.irc.allowFrom", + getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy), + promptAllowFrom: async ({ cfg, prompter, accountId }) => + await promptIrcAllowFrom({ + cfg: cfg as CoreConfig, + prompter, + accountId: resolveOnboardingAccountId({ + accountId, + defaultAccountId: resolveDefaultIrcAccountId(cfg as CoreConfig), + }), + }), +}; + +export const ircSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + 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; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as IrcSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: setupInput.name, + }); + const portInput = + typeof setupInput.port === "number" ? String(setupInput.port) : String(setupInput.port ?? ""); + const patch: Partial = { + enabled: true, + host: setupInput.host?.trim(), + port: portInput ? parsePort(portInput, setupInput.tls === false ? 6667 : 6697) : undefined, + tls: setupInput.tls, + nick: setupInput.nick?.trim(), + username: setupInput.username?.trim(), + realname: setupInput.realname?.trim(), + password: setupInput.password?.trim(), + channels: setupInput.channels, + }; + return patchScopedAccountConfig({ + cfg: namedConfig, + channelKey: channel, + accountId, + patch, + }) as CoreConfig; + }, +}; + +export const ircSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs host + nick", + configuredHint: "configured", + unconfiguredHint: "needs host + nick", + configuredScore: 1, + unconfiguredScore: 0, + 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: [ + "IRC needs server host + bot nick.", + "Recommended: TLS on port 6697.", + "Optional: NickServ identify/register can be configured after the basic account fields.", + 'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.', + 'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).', + "Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.", + `Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`, + ], + shouldShow: ({ cfg, accountId }) => + !resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured, + }, + prepare: async ({ cfg, accountId, credentialValues, prompter }) => { + const resolved = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); + const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID; + const envHost = isDefaultAccount ? process.env.IRC_HOST?.trim() : ""; + const envNick = isDefaultAccount ? process.env.IRC_NICK?.trim() : ""; + const envReady = Boolean(envHost && envNick && !resolved.config.host && !resolved.config.nick); + + if (envReady) { + const useEnv = await prompter.confirm({ + message: "IRC_HOST and IRC_NICK detected. Use env vars?", + initialValue: true, + }); + if (useEnv) { + return { + cfg: updateIrcAccountConfig(cfg as CoreConfig, accountId, { enabled: true }), + credentialValues: { + ...credentialValues, + [USE_ENV_FLAG]: "1", + }, + }; + } + } + + const tls = await prompter.confirm({ + message: "Use TLS for IRC?", + initialValue: resolved.config.tls ?? true, + }); + return { + cfg: updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + tls, + }), + credentialValues: { + ...credentialValues, + [USE_ENV_FLAG]: "0", + [TLS_FLAG]: tls ? "1" : "0", + }, + }; + }, + credentials: [], + textInputs: [ + { + inputKey: "httpHost", + message: "IRC server host", + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.host || undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + host: value, + }), + }, + { + inputKey: "httpPort", + message: "IRC server port", + currentValue: ({ cfg, accountId }) => + String(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.port ?? ""), + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + initialValue: ({ cfg, accountId, credentialValues }) => { + const resolved = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); + const tls = credentialValues[TLS_FLAG] === "0" ? false : true; + const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667); + return String(defaultPort); + }, + validate: ({ value }) => { + const parsed = Number.parseInt(String(value ?? "").trim(), 10); + return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535 + ? undefined + : "Use a port between 1 and 65535"; + }, + normalizeValue: ({ value }) => String(parsePort(String(value), 6697)), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + port: parsePort(String(value), 6697), + }), + }, + { + inputKey: "token", + message: "IRC nick", + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.nick || undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + nick: value, + }), + }, + { + inputKey: "userId", + message: "IRC username", + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username || undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + initialValue: ({ cfg, accountId, credentialValues }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username || + credentialValues.token || + "openclaw", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + username: value, + }), + }, + { + inputKey: "deviceName", + message: "IRC real name", + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + initialValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || "OpenClaw", + validate: ({ value }) => (String(value ?? "").trim() ? undefined : "Required"), + normalizeValue: ({ value }) => String(value).trim(), + applySet: async ({ cfg, accountId, value }) => + updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + realname: value, + }), + }, + { + inputKey: "groupChannels", + message: "Auto-join IRC channels (optional, comma-separated)", + placeholder: "#openclaw, #ops", + required: false, + applyEmptyValue: true, + currentValue: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.channels?.join(", "), + shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1", + normalizeValue: ({ value }) => + parseListInput(String(value)) + .map((entry) => normalizeGroupEntry(entry)) + .filter((entry): entry is string => Boolean(entry && entry !== "*")) + .filter((entry) => isChannelTarget(entry)) + .join(", "), + applySet: async ({ cfg, accountId, value }) => { + const channels = parseListInput(String(value)) + .map((entry) => normalizeGroupEntry(entry)) + .filter((entry): entry is string => Boolean(entry && entry !== "*")) + .filter((entry) => isChannelTarget(entry)); + return updateIrcAccountConfig(cfg as CoreConfig, accountId, { + enabled: true, + channels: channels.length > 0 ? channels : undefined, + }); + }, + }, + ], + groupAccess: { + label: "IRC channels", + placeholder: "#openclaw, #ops, *", + currentPolicy: ({ cfg, accountId }) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groupPolicy ?? "allowlist", + currentEntries: ({ cfg, accountId }) => + Object.keys(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups ?? {}), + updatePrompt: ({ cfg, accountId }) => + Boolean(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups), + setPolicy: ({ cfg, accountId, policy }) => + setIrcGroupAccess(cfg as CoreConfig, accountId, policy, []), + resolveAllowlist: async ({ entries }) => + [...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean))] as string[], + applyAllowlist: ({ cfg, accountId, resolved }) => + setIrcGroupAccess(cfg as CoreConfig, accountId, "allowlist", resolved as string[]), + }, + allowFrom: { + helpTitle: "IRC allowlist", + helpLines: [ + "Allowlist IRC DMs by sender.", + "Examples:", + "- alice", + "- alice!ident@example.org", + "Multiple entries: comma-separated.", + ], + message: "IRC allowFrom (nick or nick!user@host)", + placeholder: "alice, bob!ident@example.org", + invalidWithoutCredentialNote: "Use an IRC nick or nick!user@host entry.", + parseId: (raw) => { + const normalized = normalizeIrcAllowEntry(raw); + return normalized || null; + }, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const normalized = normalizeIrcAllowEntry(entry); + return { + input: entry, + resolved: Boolean(normalized), + id: normalized || null, + }; + }), + apply: async ({ cfg, allowFrom }) => setIrcAllowFrom(cfg as CoreConfig, allowFrom), + }, + finalize: async ({ cfg, accountId, prompter }) => { + let next = cfg as CoreConfig; + + const resolvedAfterGroups = resolveIrcAccount({ cfg: next, accountId }); + if (resolvedAfterGroups.config.groupPolicy === "allowlist") { + const groupKeys = Object.keys(resolvedAfterGroups.config.groups ?? {}); + if (groupKeys.length > 0) { + const wantsMentions = await prompter.confirm({ + message: "Require @mention to reply in IRC channels?", + initialValue: true, + }); + if (!wantsMentions) { + const groups = resolvedAfterGroups.config.groups ?? {}; + const patched = Object.fromEntries( + Object.entries(groups).map(([key, value]) => [ + key, + { ...value, requireMention: false }, + ]), + ); + next = updateIrcAccountConfig(next, accountId, { groups: patched }); + } + } + } + + next = await promptIrcNickServConfig({ + cfg: next, + prompter, + accountId, + }); + return { cfg: next }; + }, + completionNote: { + title: "IRC next steps", + lines: [ + "Next: restart gateway and verify status.", + "Command: openclaw channels status --probe", + `Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`, + ], + }, + dmPolicy: ircDmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +};