UNPKG

@gguf/claw

Version:

Multi-channel AI gateway with extensible messaging integrations

480 lines (441 loc) 14.4 kB
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"; 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<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, }, }, }), };