2026-02-10 15:33:57 -08:00
import {
addWildcardAllowFrom ,
DEFAULT_ACCOUNT_ID ,
formatDocsLink ,
promptAccountId ,
promptChannelAccessConfig ,
type ChannelOnboardingAdapter ,
type ChannelOnboardingDmPolicy ,
type DmPolicy ,
type WizardPrompter ,
} from "openclaw/plugin-sdk" ;
import { listIrcAccountIds , resolveDefaultIrcAccountId , resolveIrcAccount } from "./accounts.js" ;
import {
isChannelTarget ,
normalizeIrcAllowEntry ,
normalizeIrcMessagingTarget ,
} from "./normalize.js" ;
2026-02-17 13:36:48 +09:00
import type { CoreConfig , IrcAccountConfig , IrcNickServConfig } from "./types.js" ;
2026-02-10 15:33:57 -08:00
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 < IrcAccountConfig > ,
) : CoreConfig {
const current = cfg . channels ? . irc ? ? { } ;
if ( accountId === DEFAULT_ACCOUNT_ID ) {
return {
. . . cfg ,
channels : {
. . . cfg . channels ,
irc : {
. . . current ,
. . . patch ,
} ,
} ,
} ;
}
return {
. . . cfg ,
channels : {
. . . cfg . channels ,
irc : {
. . . current ,
accounts : {
. . . current . accounts ,
[ accountId ] : {
. . . current . accounts ? . [ accountId ] ,
. . . patch ,
} ,
} ,
} ,
} ,
} ;
}
function setIrcDmPolicy ( cfg : CoreConfig , dmPolicy : DmPolicy ) : CoreConfig {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom ( cfg . channels ? . irc ? . allowFrom ) : undefined ;
return {
. . . cfg ,
channels : {
. . . cfg . channels ,
irc : {
. . . cfg . channels ? . irc ,
dmPolicy ,
. . . ( allowFrom ? { allowFrom } : { } ) ,
} ,
} ,
} ;
}
function setIrcAllowFrom ( cfg : CoreConfig , allowFrom : string [ ] ) : CoreConfig {
return {
. . . cfg ,
channels : {
. . . cfg . channels ,
irc : {
. . . cfg . channels ? . irc ,
allowFrom ,
} ,
} ,
} ;
}
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 < void > {
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 < CoreConfig > {
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 < CoreConfig > {
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 ircOverride = accountOverrides . irc ? . trim ( ) ;
const defaultAccountId = resolveDefaultIrcAccountId ( next ) ;
let accountId = ircOverride || defaultAccountId ;
if ( shouldPromptAccountIds && ! ircOverride ) {
accountId = await promptAccountId ( {
cfg : next ,
prompter ,
label : "IRC" ,
currentId : accountId ,
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 ,
} ,
} ,
} ) ,
} ;