@gguf/claw
Version:
Multi-channel AI gateway with extensible messaging integrations
480 lines (441 loc) • 14.4 kB
text/typescript
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,
},
},
}),
};