mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
import {
|
|
DEFAULT_ACCOUNT_ID,
|
|
formatDocsLink,
|
|
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<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 {
|
|
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<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 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,
|
|
},
|
|
},
|
|
}),
|
|
};
|