diff --git a/.github/labeler.yml b/.github/labeler.yml index 3147321ee37..78366fb2097 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -9,6 +9,11 @@ - "src/discord/**" - "extensions/discord/**" - "docs/channels/discord.md" +"channel: irc": + - changed-files: + - any-glob-to-any-file: + - "extensions/irc/**" + - "docs/channels/irc.md" "channel: feishu": - changed-files: - any-glob-to-any-file: diff --git a/CHANGELOG.md b/CHANGELOG.md index f309abf8b9e..a317764e288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk. - iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky. - Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204. +- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07. - Plugins: device pairing + phone control plugins (Telegram `/pair`, iOS/Android node controls). (#11755) Thanks @mbelinky. - Tools: add Grok (xAI) as a `web_search` provider. (#12419) Thanks @tmchow. - Gateway: add agent management RPC methods for the web UI (`agents.create`, `agents.update`, `agents.delete`). (#11045) Thanks @advaitpaliwal. diff --git a/docs/channels/index.md b/docs/channels/index.md index 23bf98915fc..181b8d080aa 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -16,6 +16,7 @@ Text is supported everywhere; media and reactions vary by channel. - [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing. - [Telegram](/channels/telegram) — Bot API via grammY; supports groups. - [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. +- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately). - [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook. diff --git a/docs/channels/irc.md b/docs/channels/irc.md new file mode 100644 index 00000000000..2bf6fb4eb4f --- /dev/null +++ b/docs/channels/irc.md @@ -0,0 +1,234 @@ +--- +title: IRC +description: Connect OpenClaw to IRC channels and direct messages. +--- + +Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages. +IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`. + +## Quick start + +1. Enable IRC config in `~/.openclaw/openclaw.json`. +2. Set at least: + +```json +{ + "channels": { + "irc": { + "enabled": true, + "host": "irc.libera.chat", + "port": 6697, + "tls": true, + "nick": "openclaw-bot", + "channels": ["#openclaw"] + } + } +} +``` + +3. Start/restart gateway: + +```bash +openclaw gateway run +``` + +## Security defaults + +- `channels.irc.dmPolicy` defaults to `"pairing"`. +- `channels.irc.groupPolicy` defaults to `"allowlist"`. +- With `groupPolicy="allowlist"`, set `channels.irc.groups` to define allowed channels. +- Use TLS (`channels.irc.tls=true`) unless you intentionally accept plaintext transport. + +## Access control + +There are two separate “gates” for IRC channels: + +1. **Channel access** (`groupPolicy` + `groups`): whether the bot accepts messages from a channel at all. +2. **Sender access** (`groupAllowFrom` / per-channel `groups["#channel"].allowFrom`): who is allowed to trigger the bot inside that channel. + +Config keys: + +- DM allowlist (DM sender access): `channels.irc.allowFrom` +- Group sender allowlist (channel sender access): `channels.irc.groupAllowFrom` +- Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]` +- `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**) + +Allowlist entries can use nick or `nick!user@host` forms. + +### Common gotcha: `allowFrom` is for DMs, not channels + +If you see logs like: + +- `irc: drop group sender alice!ident@host (policy=allowlist)` + +…it means the sender wasn’t allowed for **group/channel** messages. Fix it by either: + +- setting `channels.irc.groupAllowFrom` (global for all channels), or +- setting per-channel sender allowlists: `channels.irc.groups["#channel"].allowFrom` + +Example (allow anyone in `#tuirc-dev` to talk to the bot): + +```json5 +{ + channels: { + irc: { + groupPolicy: "allowlist", + groups: { + "#tuirc-dev": { allowFrom: ["*"] }, + }, + }, + }, +} +``` + +## Reply triggering (mentions) + +Even if a channel is allowed (via `groupPolicy` + `groups`) and the sender is allowed, OpenClaw defaults to **mention-gating** in group contexts. + +That means you may see logs like `drop channel … (missing-mention)` unless the message includes a mention pattern that matches the bot. + +To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel: + +```json5 +{ + channels: { + irc: { + groupPolicy: "allowlist", + groups: { + "#tuirc-dev": { + requireMention: false, + allowFrom: ["*"], + }, + }, + }, + }, +} +``` + +Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions: + +```json5 +{ + channels: { + irc: { + groupPolicy: "open", + groups: { + "*": { requireMention: false, allowFrom: ["*"] }, + }, + }, + }, +} +``` + +## Security note (recommended for public channels) + +If you allow `allowFrom: ["*"]` in a public channel, anyone can prompt the bot. +To reduce risk, restrict tools for that channel. + +### Same tools for everyone in the channel + +```json5 +{ + channels: { + irc: { + groups: { + "#tuirc-dev": { + allowFrom: ["*"], + tools: { + deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"], + }, + }, + }, + }, + }, +} +``` + +### Different tools per sender (owner gets more power) + +Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick: + +```json5 +{ + channels: { + irc: { + groups: { + "#tuirc-dev": { + allowFrom: ["*"], + toolsBySender: { + "*": { + deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"], + }, + eigen: { + deny: ["gateway", "nodes", "cron"], + }, + }, + }, + }, + }, + }, +} +``` + +Notes: + +- `toolsBySender` keys can be a nick (e.g. `"eigen"`) or a full hostmask (`"eigen!~eigen@174.127.248.171"`) for stronger identity matching. +- The first matching sender policy wins; `"*"` is the wildcard fallback. + +For more on group access vs mention-gating (and how they interact), see: [/channels/groups](/channels/groups). + +## NickServ + +To identify with NickServ after connect: + +```json +{ + "channels": { + "irc": { + "nickserv": { + "enabled": true, + "service": "NickServ", + "password": "your-nickserv-password" + } + } + } +} +``` + +Optional one-time registration on connect: + +```json +{ + "channels": { + "irc": { + "nickserv": { + "register": true, + "registerEmail": "bot@example.com" + } + } + } +} +``` + +Disable `register` after the nick is registered to avoid repeated REGISTER attempts. + +## Environment variables + +Default account supports: + +- `IRC_HOST` +- `IRC_PORT` +- `IRC_TLS` +- `IRC_NICK` +- `IRC_USERNAME` +- `IRC_REALNAME` +- `IRC_PASSWORD` +- `IRC_CHANNELS` (comma-separated) +- `IRC_NICKSERV_PASSWORD` +- `IRC_NICKSERV_REGISTER_EMAIL` + +## Troubleshooting + +- If the bot connects but never replies in channels, verify `channels.irc.groups` **and** whether mention-gating is dropping messages (`missing-mention`). If you want it to reply without pings, set `requireMention:false` for the channel. +- If login fails, verify nick availability and server password. +- If TLS fails on a custom network, verify host/port and certificate setup. diff --git a/docs/docs.json b/docs/docs.json index 93c55b29207..b05d3899ffd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -863,6 +863,7 @@ "channels/telegram", "channels/grammy", "channels/discord", + "channels/irc", "channels/slack", "channels/feishu", "channels/googlechat", diff --git a/extensions/irc/index.ts b/extensions/irc/index.ts new file mode 100644 index 00000000000..2a64cbe8650 --- /dev/null +++ b/extensions/irc/index.ts @@ -0,0 +1,17 @@ +import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk"; +import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; +import { ircPlugin } from "./src/channel.js"; +import { setIrcRuntime } from "./src/runtime.js"; + +const plugin = { + id: "irc", + name: "IRC", + description: "IRC channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: OpenClawPluginApi) { + setIrcRuntime(api.runtime); + api.registerChannel({ plugin: ircPlugin as ChannelPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/irc/openclaw.plugin.json b/extensions/irc/openclaw.plugin.json new file mode 100644 index 00000000000..df5404ce388 --- /dev/null +++ b/extensions/irc/openclaw.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "irc", + "channels": ["irc"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/irc/package.json b/extensions/irc/package.json new file mode 100644 index 00000000000..7aacea59e41 --- /dev/null +++ b/extensions/irc/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openclaw/irc", + "version": "2026.2.9", + "description": "OpenClaw IRC channel plugin", + "type": "module", + "devDependencies": { + "openclaw": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts new file mode 100644 index 00000000000..dfc6f24d5bd --- /dev/null +++ b/extensions/irc/src/accounts.ts @@ -0,0 +1,268 @@ +import { readFileSync } from "node:fs"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk"; +import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; + +const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]); + +export type ResolvedIrcAccount = { + accountId: string; + enabled: boolean; + name?: string; + configured: boolean; + host: string; + port: number; + tls: boolean; + nick: string; + username: string; + realname: string; + password: string; + passwordSource: "env" | "passwordFile" | "config" | "none"; + config: IrcAccountConfig; +}; + +function parseTruthy(value?: string): boolean { + if (!value) { + return false; + } + return TRUTHY_ENV.has(value.trim().toLowerCase()); +} + +function parseIntEnv(value?: string): number | undefined { + if (!value?.trim()) { + return undefined; + } + const parsed = Number.parseInt(value.trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) { + return undefined; + } + return parsed; +} + +function parseListEnv(value?: string): string[] | undefined { + if (!value?.trim()) { + return undefined; + } + const parsed = value + .split(/[\n,;]+/g) + .map((entry) => entry.trim()) + .filter(Boolean); + return parsed.length > 0 ? parsed : undefined; +} + +function listConfiguredAccountIds(cfg: CoreConfig): string[] { + const accounts = cfg.channels?.irc?.accounts; + if (!accounts || typeof accounts !== "object") { + return []; + } + const ids = new Set(); + for (const key of Object.keys(accounts)) { + if (key.trim()) { + ids.add(normalizeAccountId(key)); + } + } + return [...ids]; +} + +function resolveAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig | undefined { + const accounts = cfg.channels?.irc?.accounts; + if (!accounts || typeof accounts !== "object") { + return undefined; + } + const direct = accounts[accountId] as IrcAccountConfig | undefined; + if (direct) { + return direct; + } + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? (accounts[matchKey] as IrcAccountConfig | undefined) : undefined; +} + +function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.irc ?? {}) as IrcAccountConfig & { + accounts?: unknown; + }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + const merged: IrcAccountConfig = { ...base, ...account }; + if (base.nickserv || account.nickserv) { + merged.nickserv = { + ...base.nickserv, + ...account.nickserv, + }; + } + return merged; +} + +function resolvePassword(accountId: string, merged: IrcAccountConfig) { + if (accountId === DEFAULT_ACCOUNT_ID) { + const envPassword = process.env.IRC_PASSWORD?.trim(); + if (envPassword) { + return { password: envPassword, source: "env" as const }; + } + } + + if (merged.passwordFile?.trim()) { + try { + const filePassword = readFileSync(merged.passwordFile.trim(), "utf-8").trim(); + if (filePassword) { + return { password: filePassword, source: "passwordFile" as const }; + } + } catch { + // Ignore unreadable files here; status will still surface missing configuration. + } + } + + const configPassword = merged.password?.trim(); + if (configPassword) { + return { password: configPassword, source: "config" as const }; + } + + return { password: "", source: "none" as const }; +} + +function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig): IrcNickServConfig { + const base = nickserv ?? {}; + const envPassword = + accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_PASSWORD?.trim() : undefined; + const envRegisterEmail = + accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_REGISTER_EMAIL?.trim() : undefined; + + const passwordFile = base.passwordFile?.trim(); + let resolvedPassword = base.password?.trim() || envPassword || ""; + if (!resolvedPassword && passwordFile) { + try { + resolvedPassword = readFileSync(passwordFile, "utf-8").trim(); + } catch { + // Ignore unreadable files; monitor/probe status will surface failures. + } + } + + const merged: IrcNickServConfig = { + ...base, + service: base.service?.trim() || undefined, + passwordFile: passwordFile || undefined, + password: resolvedPassword || undefined, + registerEmail: base.registerEmail?.trim() || envRegisterEmail || undefined, + }; + return merged; +} + +export function listIrcAccountIds(cfg: CoreConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return ids.toSorted((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultIrcAccountId(cfg: CoreConfig): string { + const ids = listIrcAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function resolveIrcAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): ResolvedIrcAccount { + const hasExplicitAccountId = Boolean(params.accountId?.trim()); + const baseEnabled = params.cfg.channels?.irc?.enabled !== false; + + const resolve = (accountId: string) => { + const merged = mergeIrcAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + + const tls = + typeof merged.tls === "boolean" + ? merged.tls + : accountId === DEFAULT_ACCOUNT_ID && process.env.IRC_TLS + ? parseTruthy(process.env.IRC_TLS) + : true; + + const envPort = + accountId === DEFAULT_ACCOUNT_ID ? parseIntEnv(process.env.IRC_PORT) : undefined; + const port = merged.port ?? envPort ?? (tls ? 6697 : 6667); + const envChannels = + accountId === DEFAULT_ACCOUNT_ID ? parseListEnv(process.env.IRC_CHANNELS) : undefined; + + const host = ( + merged.host?.trim() || + (accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_HOST?.trim() : "") || + "" + ).trim(); + const nick = ( + merged.nick?.trim() || + (accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICK?.trim() : "") || + "" + ).trim(); + const username = ( + merged.username?.trim() || + (accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_USERNAME?.trim() : "") || + nick || + "openclaw" + ).trim(); + const realname = ( + merged.realname?.trim() || + (accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_REALNAME?.trim() : "") || + "OpenClaw" + ).trim(); + + const passwordResolution = resolvePassword(accountId, merged); + const nickserv = resolveNickServConfig(accountId, merged.nickserv); + + const config: IrcAccountConfig = { + ...merged, + channels: merged.channels ?? envChannels, + tls, + port, + host, + nick, + username, + realname, + nickserv, + }; + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + configured: Boolean(host && nick), + host, + port, + tls, + nick, + username, + realname, + password: passwordResolution.password, + passwordSource: passwordResolution.source, + config, + } satisfies ResolvedIrcAccount; + }; + + const normalized = normalizeAccountId(params.accountId); + const primary = resolve(normalized); + if (hasExplicitAccountId) { + return primary; + } + if (primary.configured) { + return primary; + } + + const fallbackId = resolveDefaultIrcAccountId(params.cfg); + if (fallbackId === primary.accountId) { + return primary; + } + const fallback = resolve(fallbackId); + if (!fallback.configured) { + return primary; + } + return fallback; +} + +export function listEnabledIrcAccounts(cfg: CoreConfig): ResolvedIrcAccount[] { + return listIrcAccountIds(cfg) + .map((accountId) => resolveIrcAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts new file mode 100644 index 00000000000..4ab0df5203c --- /dev/null +++ b/extensions/irc/src/channel.ts @@ -0,0 +1,367 @@ +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + formatPairingApproveHint, + getChatChannelMeta, + PAIRING_APPROVED_MESSAGE, + setAccountEnabledInConfigSection, + deleteAccountFromConfigSection, + type ChannelPlugin, +} from "openclaw/plugin-sdk"; +import type { CoreConfig, IrcProbe } from "./types.js"; +import { + listIrcAccountIds, + resolveDefaultIrcAccountId, + resolveIrcAccount, + type ResolvedIrcAccount, +} from "./accounts.js"; +import { IrcConfigSchema } from "./config-schema.js"; +import { monitorIrcProvider } from "./monitor.js"; +import { + normalizeIrcMessagingTarget, + looksLikeIrcTargetId, + 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"; + +const meta = getChatChannelMeta("irc"); + +function normalizePairingTarget(raw: string): string { + const normalized = normalizeIrcAllowEntry(raw); + if (!normalized) { + return ""; + } + return normalized.split(/[!@]/, 1)[0]?.trim() ?? ""; +} + +export const ircPlugin: ChannelPlugin = { + id: "irc", + meta: { + ...meta, + quickstartAllowFrom: true, + }, + onboarding: ircOnboardingAdapter, + pairing: { + idLabel: "ircUser", + normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry), + notifyApproval: async ({ id }) => { + const target = normalizePairingTarget(id); + if (!target) { + throw new Error(`invalid IRC pairing id: ${id}`); + } + await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE); + }, + }, + capabilities: { + chatTypes: ["direct", "group"], + media: true, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.irc"] }, + configSchema: buildChannelConfigSchema(IrcConfigSchema), + config: { + listAccountIds: (cfg) => listIrcAccountIds(cfg as CoreConfig), + resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg as CoreConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "irc", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as CoreConfig, + sectionKey: "irc", + accountId, + clearBaseFields: [ + "name", + "host", + "port", + "tls", + "nick", + "username", + "realname", + "password", + "passwordFile", + "channels", + ], + }), + isConfigured: (account) => account.configured, + describeAccount: (account) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + host: account.host, + port: account.port, + tls: account.tls, + nick: account.nick, + passwordSource: account.passwordSource, + }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map( + (entry) => String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom.map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.irc?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.irc.accounts.${resolvedAccountId}.` + : "channels.irc."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: `${basePath}allowFrom`, + approveHint: formatPairingApproveHint("irc"), + normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), + }; + }, + collectWarnings: ({ account, cfg }) => { + const warnings: string[] = []; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy === "open") { + warnings.push( + '- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.', + ); + } + if (!account.config.tls) { + warnings.push( + "- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.", + ); + } + if (account.config.nickserv?.register) { + warnings.push( + '- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.', + ); + if (!account.config.nickserv.password?.trim()) { + warnings.push( + "- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.", + ); + } + } + return warnings; + }, + }, + groups: { + resolveRequireMention: ({ cfg, accountId, groupId }) => { + const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); + if (!groupId) { + return true; + } + const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId }); + return resolveIrcRequireMention({ + groupConfig: match.groupConfig, + wildcardConfig: match.wildcardConfig, + }); + }, + resolveToolPolicy: ({ cfg, accountId, groupId }) => { + const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); + if (!groupId) { + return undefined; + } + const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId }); + return match.groupConfig?.tools ?? match.wildcardConfig?.tools; + }, + }, + messaging: { + normalizeTarget: normalizeIrcMessagingTarget, + targetResolver: { + looksLikeId: looksLikeIrcTargetId, + hint: "<#channel|nick>", + }, + }, + resolver: { + resolveTargets: async ({ inputs, kind }) => { + return inputs.map((input) => { + const normalized = normalizeIrcMessagingTarget(input); + if (!normalized) { + return { + input, + resolved: false, + note: "invalid IRC target", + }; + } + if (kind === "group") { + const groupId = isChannelTarget(normalized) ? normalized : `#${normalized}`; + return { + input, + resolved: true, + id: groupId, + name: groupId, + }; + } + if (isChannelTarget(normalized)) { + return { + input, + resolved: false, + note: "expected user target", + }; + } + return { + input, + resolved: true, + id: normalized, + name: normalized, + }; + }); + }, + }, + directory: { + self: async () => null, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); + const q = query?.trim().toLowerCase() ?? ""; + const ids = new Set(); + + for (const entry of account.config.allowFrom ?? []) { + const normalized = normalizePairingTarget(String(entry)); + if (normalized && normalized !== "*") { + ids.add(normalized); + } + } + for (const entry of account.config.groupAllowFrom ?? []) { + const normalized = normalizePairingTarget(String(entry)); + if (normalized && normalized !== "*") { + ids.add(normalized); + } + } + for (const group of Object.values(account.config.groups ?? {})) { + for (const entry of group.allowFrom ?? []) { + const normalized = normalizePairingTarget(String(entry)); + if (normalized && normalized !== "*") { + ids.add(normalized); + } + } + } + + return Array.from(ids) + .filter((id) => (q ? id.includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "user", id })); + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); + const q = query?.trim().toLowerCase() ?? ""; + const groupIds = new Set(); + + for (const channel of account.config.channels ?? []) { + const normalized = normalizeIrcMessagingTarget(channel); + if (normalized && isChannelTarget(normalized)) { + groupIds.add(normalized); + } + } + for (const group of Object.keys(account.config.groups ?? {})) { + if (group === "*") { + continue; + } + const normalized = normalizeIrcMessagingTarget(group); + if (normalized && isChannelTarget(normalized)) { + groupIds.add(normalized); + } + } + + return Array.from(groupIds) + .filter((id) => (q ? id.toLowerCase().includes(q) : true)) + .slice(0, limit && limit > 0 ? limit : undefined) + .map((id) => ({ kind: "group", id, name: id })); + }, + }, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 350, + sendText: async ({ to, text, accountId, replyToId }) => { + const result = await sendMessageIrc(to, text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }); + return { channel: "irc", ...result }; + }, + sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => { + const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; + const result = await sendMessageIrc(to, combined, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }); + return { channel: "irc", ...result }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildChannelSummary: ({ account, snapshot }) => ({ + configured: snapshot.configured ?? false, + host: account.host, + port: snapshot.port, + tls: account.tls, + nick: account.nick, + running: snapshot.running ?? false, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async ({ cfg, account, timeoutMs }) => + probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }), + buildAccountSnapshot: ({ account, runtime, probe }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + host: account.host, + port: account.port, + tls: account.tls, + nick: account.nick, + passwordSource: account.passwordSource, + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + probe, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + if (!account.configured) { + throw new Error( + `IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`, + ); + } + ctx.log?.info( + `[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`, + ); + const { stop } = await monitorIrcProvider({ + accountId: account.accountId, + config: ctx.cfg as CoreConfig, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + }); + return { stop }; + }, + }, +}; diff --git a/extensions/irc/src/client.test.ts b/extensions/irc/src/client.test.ts new file mode 100644 index 00000000000..06e63093dc3 --- /dev/null +++ b/extensions/irc/src/client.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { buildIrcNickServCommands } from "./client.js"; + +describe("irc client nickserv", () => { + it("builds IDENTIFY command when password is set", () => { + expect( + buildIrcNickServCommands({ + password: "secret", + }), + ).toEqual(["PRIVMSG NickServ :IDENTIFY secret"]); + }); + + it("builds REGISTER command when enabled with email", () => { + expect( + buildIrcNickServCommands({ + password: "secret", + register: true, + registerEmail: "bot@example.com", + }), + ).toEqual([ + "PRIVMSG NickServ :IDENTIFY secret", + "PRIVMSG NickServ :REGISTER secret bot@example.com", + ]); + }); + + it("rejects register without registerEmail", () => { + expect(() => + buildIrcNickServCommands({ + password: "secret", + register: true, + }), + ).toThrow(/registerEmail/); + }); + + it("sanitizes outbound NickServ payloads", () => { + expect( + buildIrcNickServCommands({ + service: "NickServ\n", + password: "secret\r\nJOIN #bad", + }), + ).toEqual(["PRIVMSG NickServ :IDENTIFY secret JOIN #bad"]); + }); +}); diff --git a/extensions/irc/src/client.ts b/extensions/irc/src/client.ts new file mode 100644 index 00000000000..8eac015aaa7 --- /dev/null +++ b/extensions/irc/src/client.ts @@ -0,0 +1,439 @@ +import net from "node:net"; +import tls from "node:tls"; +import { + parseIrcLine, + parseIrcPrefix, + sanitizeIrcOutboundText, + sanitizeIrcTarget, +} from "./protocol.js"; + +const IRC_ERROR_CODES = new Set(["432", "464", "465"]); +const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]); + +export type IrcPrivmsgEvent = { + senderNick: string; + senderUser?: string; + senderHost?: string; + target: string; + text: string; + rawLine: string; +}; + +export type IrcClientOptions = { + host: string; + port: number; + tls: boolean; + nick: string; + username: string; + realname: string; + password?: string; + nickserv?: IrcNickServOptions; + channels?: string[]; + connectTimeoutMs?: number; + messageChunkMaxChars?: number; + abortSignal?: AbortSignal; + onPrivmsg?: (event: IrcPrivmsgEvent) => void | Promise; + onNotice?: (text: string, target?: string) => void; + onError?: (error: Error) => void; + onLine?: (line: string) => void; +}; + +export type IrcNickServOptions = { + enabled?: boolean; + service?: string; + password?: string; + register?: boolean; + registerEmail?: string; +}; + +export type IrcClient = { + nick: string; + isReady: () => boolean; + sendRaw: (line: string) => void; + join: (channel: string) => void; + sendPrivmsg: (target: string, text: string) => void; + quit: (reason?: string) => void; + close: () => void; +}; + +function toError(err: unknown): Error { + if (err instanceof Error) { + return err; + } + return new Error(typeof err === "string" ? err : JSON.stringify(err)); +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + promise + .then((result) => { + clearTimeout(timer); + resolve(result); + }) + .catch((error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function buildFallbackNick(nick: string): string { + const normalized = nick.replace(/\s+/g, ""); + const safe = normalized.replace(/[^A-Za-z0-9_\-\[\]\\`^{}|]/g, ""); + const base = safe || "openclaw"; + const suffix = "_"; + const maxNickLen = 30; + if (base.length >= maxNickLen) { + return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`; + } + return `${base}${suffix}`; +} + +export function buildIrcNickServCommands(options?: IrcNickServOptions): string[] { + if (!options || options.enabled === false) { + return []; + } + const password = sanitizeIrcOutboundText(options.password ?? ""); + if (!password) { + return []; + } + const service = sanitizeIrcTarget(options.service?.trim() || "NickServ"); + const commands = [`PRIVMSG ${service} :IDENTIFY ${password}`]; + if (options.register) { + const registerEmail = sanitizeIrcOutboundText(options.registerEmail ?? ""); + if (!registerEmail) { + throw new Error("IRC NickServ register requires registerEmail"); + } + commands.push(`PRIVMSG ${service} :REGISTER ${password} ${registerEmail}`); + } + return commands; +} + +export async function connectIrcClient(options: IrcClientOptions): Promise { + const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000; + const messageChunkMaxChars = + options.messageChunkMaxChars != null ? options.messageChunkMaxChars : 350; + + if (!options.host.trim()) { + throw new Error("IRC host is required"); + } + if (!options.nick.trim()) { + throw new Error("IRC nick is required"); + } + + const desiredNick = options.nick.trim(); + let currentNick = desiredNick; + let ready = false; + let closed = false; + let nickServRecoverAttempted = false; + let fallbackNickAttempted = false; + + const socket = options.tls + ? tls.connect({ + host: options.host, + port: options.port, + servername: options.host, + }) + : net.connect({ host: options.host, port: options.port }); + + socket.setEncoding("utf8"); + + let resolveReady: (() => void) | null = null; + let rejectReady: ((error: Error) => void) | null = null; + const readyPromise = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + const fail = (err: unknown) => { + const error = toError(err); + if (options.onError) { + options.onError(error); + } + if (!ready && rejectReady) { + rejectReady(error); + rejectReady = null; + resolveReady = null; + } + }; + + const sendRaw = (line: string) => { + const cleaned = line.replace(/[\r\n]+/g, "").trim(); + if (!cleaned) { + throw new Error("IRC command cannot be empty"); + } + socket.write(`${cleaned}\r\n`); + }; + + const tryRecoverNickCollision = (): boolean => { + const nickServEnabled = options.nickserv?.enabled !== false; + const nickservPassword = sanitizeIrcOutboundText(options.nickserv?.password ?? ""); + if (nickServEnabled && !nickServRecoverAttempted && nickservPassword) { + nickServRecoverAttempted = true; + try { + const service = sanitizeIrcTarget(options.nickserv?.service?.trim() || "NickServ"); + sendRaw(`PRIVMSG ${service} :GHOST ${desiredNick} ${nickservPassword}`); + sendRaw(`NICK ${desiredNick}`); + return true; + } catch (err) { + fail(err); + } + } + + if (!fallbackNickAttempted) { + fallbackNickAttempted = true; + const fallbackNick = buildFallbackNick(desiredNick); + if (fallbackNick.toLowerCase() !== currentNick.toLowerCase()) { + try { + sendRaw(`NICK ${fallbackNick}`); + currentNick = fallbackNick; + return true; + } catch (err) { + fail(err); + } + } + } + return false; + }; + + const join = (channel: string) => { + const target = sanitizeIrcTarget(channel); + if (!target.startsWith("#") && !target.startsWith("&")) { + throw new Error(`IRC JOIN target must be a channel: ${channel}`); + } + sendRaw(`JOIN ${target}`); + }; + + const sendPrivmsg = (target: string, text: string) => { + const normalizedTarget = sanitizeIrcTarget(target); + const cleaned = sanitizeIrcOutboundText(text); + if (!cleaned) { + return; + } + let remaining = cleaned; + while (remaining.length > 0) { + let chunk = remaining; + if (chunk.length > messageChunkMaxChars) { + let splitAt = chunk.lastIndexOf(" ", messageChunkMaxChars); + if (splitAt < Math.floor(messageChunkMaxChars / 2)) { + splitAt = messageChunkMaxChars; + } + chunk = chunk.slice(0, splitAt).trim(); + } + if (!chunk) { + break; + } + sendRaw(`PRIVMSG ${normalizedTarget} :${chunk}`); + remaining = remaining.slice(chunk.length).trimStart(); + } + }; + + const quit = (reason?: string) => { + if (closed) { + return; + } + closed = true; + const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye"); + try { + if (safeReason) { + sendRaw(`QUIT :${safeReason}`); + } else { + sendRaw("QUIT"); + } + } catch { + // Ignore quit failures while shutting down. + } + socket.end(); + }; + + const close = () => { + if (closed) { + return; + } + closed = true; + socket.destroy(); + }; + + let buffer = ""; + socket.on("data", (chunk: string) => { + buffer += chunk; + let idx = buffer.indexOf("\n"); + while (idx !== -1) { + const rawLine = buffer.slice(0, idx).replace(/\r$/, ""); + buffer = buffer.slice(idx + 1); + idx = buffer.indexOf("\n"); + + if (!rawLine) { + continue; + } + if (options.onLine) { + options.onLine(rawLine); + } + + const line = parseIrcLine(rawLine); + if (!line) { + continue; + } + + if (line.command === "PING") { + const payload = + line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : ""; + sendRaw(`PONG :${payload}`); + continue; + } + + if (line.command === "NICK") { + const prefix = parseIrcPrefix(line.prefix); + if (prefix.nick && prefix.nick.toLowerCase() === currentNick.toLowerCase()) { + const next = + line.trailing != null + ? line.trailing + : line.params[0] != null + ? line.params[0] + : currentNick; + currentNick = String(next).trim(); + } + continue; + } + + if (!ready && IRC_NICK_COLLISION_CODES.has(line.command)) { + if (tryRecoverNickCollision()) { + continue; + } + const detail = + line.trailing != null ? line.trailing : line.params.join(" ") || "nickname in use"; + fail(new Error(`IRC login failed (${line.command}): ${detail}`)); + close(); + return; + } + + if (!ready && IRC_ERROR_CODES.has(line.command)) { + const detail = + line.trailing != null ? line.trailing : line.params.join(" ") || "login rejected"; + fail(new Error(`IRC login failed (${line.command}): ${detail}`)); + close(); + return; + } + + if (line.command === "001") { + ready = true; + const nickParam = line.params[0]; + if (nickParam && nickParam.trim()) { + currentNick = nickParam.trim(); + } + try { + const nickServCommands = buildIrcNickServCommands(options.nickserv); + for (const command of nickServCommands) { + sendRaw(command); + } + } catch (err) { + fail(err); + } + for (const channel of options.channels || []) { + const trimmed = channel.trim(); + if (!trimmed) { + continue; + } + try { + join(trimmed); + } catch (err) { + fail(err); + } + } + if (resolveReady) { + resolveReady(); + } + resolveReady = null; + rejectReady = null; + continue; + } + + if (line.command === "NOTICE") { + if (options.onNotice) { + options.onNotice(line.trailing != null ? line.trailing : "", line.params[0]); + } + continue; + } + + if (line.command === "PRIVMSG") { + const targetParam = line.params[0]; + const target = targetParam ? targetParam.trim() : ""; + const text = line.trailing != null ? line.trailing : ""; + const prefix = parseIrcPrefix(line.prefix); + const senderNick = prefix.nick ? prefix.nick.trim() : ""; + if (!target || !senderNick || !text.trim()) { + continue; + } + if (options.onPrivmsg) { + void Promise.resolve( + options.onPrivmsg({ + senderNick, + senderUser: prefix.user ? prefix.user.trim() : undefined, + senderHost: prefix.host ? prefix.host.trim() : undefined, + target, + text, + rawLine, + }), + ).catch((error) => { + fail(error); + }); + } + } + } + }); + + socket.once("connect", () => { + try { + if (options.password && options.password.trim()) { + sendRaw(`PASS ${options.password.trim()}`); + } + sendRaw(`NICK ${options.nick.trim()}`); + sendRaw(`USER ${options.username.trim()} 0 * :${sanitizeIrcOutboundText(options.realname)}`); + } catch (err) { + fail(err); + close(); + } + }); + + socket.once("error", (err) => { + fail(err); + }); + + socket.once("close", () => { + if (!closed) { + closed = true; + if (!ready) { + fail(new Error("IRC connection closed before ready")); + } + } + }); + + if (options.abortSignal) { + const abort = () => { + quit("shutdown"); + }; + if (options.abortSignal.aborted) { + abort(); + } else { + options.abortSignal.addEventListener("abort", abort, { once: true }); + } + } + + await withTimeout(readyPromise, timeoutMs, "IRC connect"); + + return { + get nick() { + return currentNick; + }, + isReady: () => ready && !closed, + sendRaw, + join, + sendPrivmsg, + quit, + close, + }; +} diff --git a/extensions/irc/src/config-schema.test.ts b/extensions/irc/src/config-schema.test.ts new file mode 100644 index 00000000000..007ada9d43e --- /dev/null +++ b/extensions/irc/src/config-schema.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { IrcConfigSchema } from "./config-schema.js"; + +describe("irc config schema", () => { + it("accepts numeric allowFrom and groupAllowFrom entries", () => { + const parsed = IrcConfigSchema.parse({ + dmPolicy: "allowlist", + allowFrom: [12345, "alice"], + groupAllowFrom: [67890, "alice!ident@example.org"], + }); + + expect(parsed.allowFrom).toEqual([12345, "alice"]); + expect(parsed.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]); + }); + + it("accepts numeric per-channel allowFrom entries", () => { + const parsed = IrcConfigSchema.parse({ + groups: { + "#ops": { + allowFrom: [42, "alice"], + }, + }, + }); + + expect(parsed.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]); + }); +}); diff --git a/extensions/irc/src/config-schema.ts b/extensions/irc/src/config-schema.ts new file mode 100644 index 00000000000..14ce51b39a4 --- /dev/null +++ b/extensions/irc/src/config-schema.ts @@ -0,0 +1,97 @@ +import { + BlockStreamingCoalesceSchema, + DmConfigSchema, + DmPolicySchema, + GroupPolicySchema, + MarkdownConfigSchema, + ToolPolicySchema, + requireOpenAllowFrom, +} from "openclaw/plugin-sdk"; +import { z } from "zod"; + +const IrcGroupSchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + toolsBySender: z.record(z.string(), ToolPolicySchema).optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +const IrcNickServSchema = z + .object({ + enabled: z.boolean().optional(), + service: z.string().optional(), + password: z.string().optional(), + passwordFile: z.string().optional(), + register: z.boolean().optional(), + registerEmail: z.string().optional(), + }) + .strict() + .superRefine((value, ctx) => { + if (value.register && !value.registerEmail?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["registerEmail"], + message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail", + }); + } + }); + +export const IrcAccountSchemaBase = z + .object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + host: z.string().optional(), + port: z.number().int().min(1).max(65535).optional(), + tls: z.boolean().optional(), + nick: z.string().optional(), + username: z.string().optional(), + realname: z.string().optional(), + password: z.string().optional(), + passwordFile: z.string().optional(), + nickserv: IrcNickServSchema.optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groups: z.record(z.string(), IrcGroupSchema.optional()).optional(), + channels: z.array(z.string()).optional(), + mentionPatterns: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + responsePrefix: z.string().optional(), + mediaMaxMb: z.number().positive().optional(), + }) + .strict(); + +export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"', + }); +}); + +export const IrcConfigSchema = IrcAccountSchemaBase.extend({ + accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"', + }); +}); diff --git a/extensions/irc/src/control-chars.ts b/extensions/irc/src/control-chars.ts new file mode 100644 index 00000000000..8b349ba1cd0 --- /dev/null +++ b/extensions/irc/src/control-chars.ts @@ -0,0 +1,22 @@ +export function isIrcControlChar(charCode: number): boolean { + return charCode <= 0x1f || charCode === 0x7f; +} + +export function hasIrcControlChars(value: string): boolean { + for (const char of value) { + if (isIrcControlChar(char.charCodeAt(0))) { + return true; + } + } + return false; +} + +export function stripIrcControlChars(value: string): string { + let out = ""; + for (const char of value) { + if (!isIrcControlChar(char.charCodeAt(0))) { + out += char; + } + } + return out; +} diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts new file mode 100644 index 00000000000..2c9c3ee9f62 --- /dev/null +++ b/extensions/irc/src/inbound.ts @@ -0,0 +1,334 @@ +import { + createReplyPrefixOptions, + logInboundDrop, + resolveControlCommandGate, + type OpenClawConfig, + type RuntimeEnv, +} from "openclaw/plugin-sdk"; +import type { ResolvedIrcAccount } from "./accounts.js"; +import type { CoreConfig, IrcInboundMessage } from "./types.js"; +import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js"; +import { + resolveIrcMentionGate, + resolveIrcGroupAccessGate, + resolveIrcGroupMatch, + resolveIrcGroupSenderAllowed, + resolveIrcRequireMention, +} from "./policy.js"; +import { getIrcRuntime } from "./runtime.js"; +import { sendMessageIrc } from "./send.js"; + +const CHANNEL_ID = "irc" as const; + +const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +async function deliverIrcReply(params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + target: string; + accountId: string; + sendReply?: (target: string, text: string, replyToId?: string) => Promise; + statusSink?: (patch: { lastOutboundAt?: number }) => void; +}) { + const text = params.payload.text ?? ""; + const mediaList = params.payload.mediaUrls?.length + ? params.payload.mediaUrls + : params.payload.mediaUrl + ? [params.payload.mediaUrl] + : []; + + if (!text.trim() && mediaList.length === 0) { + return; + } + + const mediaBlock = mediaList.length + ? mediaList.map((url) => `Attachment: ${url}`).join("\n") + : ""; + const combined = text.trim() + ? mediaBlock + ? `${text.trim()}\n\n${mediaBlock}` + : text.trim() + : mediaBlock; + + if (params.sendReply) { + await params.sendReply(params.target, combined, params.payload.replyToId); + } else { + await sendMessageIrc(params.target, combined, { + accountId: params.accountId, + replyTo: params.payload.replyToId, + }); + } + params.statusSink?.({ lastOutboundAt: Date.now() }); +} + +export async function handleIrcInbound(params: { + message: IrcInboundMessage; + account: ResolvedIrcAccount; + config: CoreConfig; + runtime: RuntimeEnv; + connectedNick?: string; + sendReply?: (target: string, text: string, replyToId?: string) => Promise; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): Promise { + const { message, account, config, runtime, connectedNick, statusSink } = params; + const core = getIrcRuntime(); + + const rawBody = message.text?.trim() ?? ""; + if (!rawBody) { + return; + } + + statusSink?.({ lastInboundAt: message.timestamp }); + + const senderDisplay = message.senderHost + ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` + : message.senderNick; + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + + const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom); + const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom); + const storeAllowFrom = await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []); + const storeAllowList = normalizeIrcAllowlist(storeAllowFrom); + + const groupMatch = resolveIrcGroupMatch({ + groups: account.config.groups, + target: message.target, + }); + + if (message.isGroup) { + const groupAccess = resolveIrcGroupAccessGate({ groupPolicy, groupMatch }); + if (!groupAccess.allowed) { + runtime.log?.(`irc: drop channel ${message.target} (${groupAccess.reason})`); + return; + } + } + + const directGroupAllowFrom = normalizeIrcAllowlist(groupMatch.groupConfig?.allowFrom); + const wildcardGroupAllowFrom = normalizeIrcAllowlist(groupMatch.wildcardConfig?.allowFrom); + const groupAllowFrom = + directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom; + + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); + const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean); + + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg: config as OpenClawConfig, + surface: CHANNEL_ID, + }); + const useAccessGroups = config.commands?.useAccessGroups !== false; + const senderAllowedForCommands = resolveIrcAllowlistMatch({ + allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, + message, + }).allowed; + const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); + const commandGate = resolveControlCommandGate({ + useAccessGroups, + authorizers: [ + { + configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, + allowed: senderAllowedForCommands, + }, + ], + allowTextCommands, + hasControlCommand, + }); + const commandAuthorized = commandGate.commandAuthorized; + + if (message.isGroup) { + const senderAllowed = resolveIrcGroupSenderAllowed({ + groupPolicy, + message, + outerAllowFrom: effectiveGroupAllowFrom, + innerAllowFrom: groupAllowFrom, + }); + if (!senderAllowed) { + runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`); + return; + } + } else { + if (dmPolicy === "disabled") { + runtime.log?.(`irc: drop DM sender=${senderDisplay} (dmPolicy=disabled)`); + return; + } + if (dmPolicy !== "open") { + const dmAllowed = resolveIrcAllowlistMatch({ + allowFrom: effectiveAllowFrom, + message, + }).allowed; + if (!dmAllowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: CHANNEL_ID, + id: senderDisplay.toLowerCase(), + meta: { name: message.senderNick || undefined }, + }); + if (created) { + try { + const reply = core.channel.pairing.buildPairingReply({ + channel: CHANNEL_ID, + idLine: `Your IRC id: ${senderDisplay}`, + code, + }); + await deliverIrcReply({ + payload: { text: reply }, + target: message.senderNick, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + } catch (err) { + runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`); + } + } + } + runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`); + return; + } + } + } + + if (message.isGroup && commandGate.shouldBlock) { + logInboundDrop({ + log: (line) => runtime.log?.(line), + channel: CHANNEL_ID, + reason: "control command (unauthorized)", + target: senderDisplay, + }); + return; + } + + const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig); + const mentionNick = connectedNick?.trim() || account.nick; + const explicitMentionRegex = mentionNick + ? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i") + : null; + const wasMentioned = + core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) || + (explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false); + + const requireMention = message.isGroup + ? resolveIrcRequireMention({ + groupConfig: groupMatch.groupConfig, + wildcardConfig: groupMatch.wildcardConfig, + }) + : false; + + const mentionGate = resolveIrcMentionGate({ + isGroup: message.isGroup, + requireMention, + wasMentioned, + hasControlCommand, + allowTextCommands, + commandAuthorized, + }); + if (mentionGate.shouldSkip) { + runtime.log?.(`irc: drop channel ${message.target} (${mentionGate.reason})`); + return; + } + + const peerId = message.isGroup ? message.target : message.senderNick; + const route = core.channel.routing.resolveAgentRoute({ + cfg: config as OpenClawConfig, + channel: CHANNEL_ID, + accountId: account.accountId, + peer: { + kind: message.isGroup ? "group" : "direct", + id: peerId, + }, + }); + + const fromLabel = message.isGroup ? message.target : senderDisplay; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "IRC", + from: fromLabel, + timestamp: message.timestamp, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const groupSystemPrompt = groupMatch.groupConfig?.systemPrompt?.trim() || undefined; + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: message.isGroup ? `irc:channel:${message.target}` : `irc:${senderDisplay}`, + To: `irc:${peerId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: message.isGroup ? "group" : "direct", + ConversationLabel: fromLabel, + SenderName: message.senderNick || undefined, + SenderId: senderDisplay, + GroupSubject: message.isGroup ? message.target : undefined, + GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined, + Provider: CHANNEL_ID, + Surface: CHANNEL_ID, + WasMentioned: message.isGroup ? wasMentioned : undefined, + MessageSid: message.messageId, + Timestamp: message.timestamp, + OriginatingChannel: CHANNEL_ID, + OriginatingTo: `irc:${peerId}`, + CommandAuthorized: commandAuthorized, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + runtime.error?.(`irc: failed updating session meta: ${String(err)}`); + }, + }); + + const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({ + cfg: config as OpenClawConfig, + agentId: route.agentId, + channel: CHANNEL_ID, + accountId: account.accountId, + }); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config as OpenClawConfig, + dispatcherOptions: { + ...prefixOptions, + deliver: async (payload) => { + await deliverIrcReply({ + payload: payload as { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; + replyToId?: string; + }, + target: peerId, + accountId: account.accountId, + sendReply: params.sendReply, + statusSink, + }); + }, + onError: (err, info) => { + runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`); + }, + }, + replyOptions: { + skillFilter: groupMatch.groupConfig?.skills, + onModelSelected, + disableBlockStreaming: + typeof account.config.blockStreaming === "boolean" + ? !account.config.blockStreaming + : undefined, + }, + }); +} diff --git a/extensions/irc/src/monitor.test.ts b/extensions/irc/src/monitor.test.ts new file mode 100644 index 00000000000..b8af37265e7 --- /dev/null +++ b/extensions/irc/src/monitor.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { resolveIrcInboundTarget } from "./monitor.js"; + +describe("irc monitor inbound target", () => { + it("keeps channel target for group messages", () => { + expect( + resolveIrcInboundTarget({ + target: "#openclaw", + senderNick: "alice", + }), + ).toEqual({ + isGroup: true, + target: "#openclaw", + rawTarget: "#openclaw", + }); + }); + + it("maps DM target to sender nick and preserves raw target", () => { + expect( + resolveIrcInboundTarget({ + target: "openclaw-bot", + senderNick: "alice", + }), + ).toEqual({ + isGroup: false, + target: "alice", + rawTarget: "openclaw-bot", + }); + }); + + it("falls back to raw target when sender nick is empty", () => { + expect( + resolveIrcInboundTarget({ + target: "openclaw-bot", + senderNick: " ", + }), + ).toEqual({ + isGroup: false, + target: "openclaw-bot", + rawTarget: "openclaw-bot", + }); + }); +}); diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts new file mode 100644 index 00000000000..bcfd88138eb --- /dev/null +++ b/extensions/irc/src/monitor.ts @@ -0,0 +1,158 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk"; +import type { CoreConfig, IrcInboundMessage } from "./types.js"; +import { resolveIrcAccount } from "./accounts.js"; +import { connectIrcClient, type IrcClient } from "./client.js"; +import { handleIrcInbound } from "./inbound.js"; +import { isChannelTarget } from "./normalize.js"; +import { makeIrcMessageId } from "./protocol.js"; +import { getIrcRuntime } from "./runtime.js"; + +export type IrcMonitorOptions = { + accountId?: string; + config?: CoreConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + onMessage?: (message: IrcInboundMessage, client: IrcClient) => void | Promise; +}; + +export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): { + isGroup: boolean; + target: string; + rawTarget: string; +} { + const rawTarget = params.target; + const isGroup = isChannelTarget(rawTarget); + if (isGroup) { + return { isGroup: true, target: rawTarget, rawTarget }; + } + const senderNick = params.senderNick.trim(); + return { isGroup: false, target: senderNick || rawTarget, rawTarget }; +} + +export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> { + const core = getIrcRuntime(); + const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig); + const account = resolveIrcAccount({ + cfg, + accountId: opts.accountId, + }); + + const runtime: RuntimeEnv = opts.runtime ?? { + log: (message: string) => core.logging.getChildLogger().info(message), + error: (message: string) => core.logging.getChildLogger().error(message), + exit: () => { + throw new Error("Runtime exit not available"); + }, + }; + + if (!account.configured) { + throw new Error( + `IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`, + ); + } + + const logger = core.logging.getChildLogger({ + channel: "irc", + accountId: account.accountId, + }); + + let client: IrcClient | null = null; + + client = await connectIrcClient({ + host: account.host, + port: account.port, + tls: account.tls, + nick: account.nick, + username: account.username, + realname: account.realname, + password: account.password, + nickserv: { + enabled: account.config.nickserv?.enabled, + service: account.config.nickserv?.service, + password: account.config.nickserv?.password, + register: account.config.nickserv?.register, + registerEmail: account.config.nickserv?.registerEmail, + }, + channels: account.config.channels, + abortSignal: opts.abortSignal, + onLine: (line) => { + if (core.logging.shouldLogVerbose()) { + logger.debug?.(`[${account.accountId}] << ${line}`); + } + }, + onNotice: (text, target) => { + if (core.logging.shouldLogVerbose()) { + logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`); + } + }, + onError: (error) => { + logger.error(`[${account.accountId}] IRC error: ${error.message}`); + }, + onPrivmsg: async (event) => { + if (!client) { + return; + } + if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) { + return; + } + + const inboundTarget = resolveIrcInboundTarget({ + target: event.target, + senderNick: event.senderNick, + }); + const message: IrcInboundMessage = { + messageId: makeIrcMessageId(), + target: inboundTarget.target, + rawTarget: inboundTarget.rawTarget, + senderNick: event.senderNick, + senderUser: event.senderUser, + senderHost: event.senderHost, + text: event.text, + timestamp: Date.now(), + isGroup: inboundTarget.isGroup, + }; + + core.channel.activity.record({ + channel: "irc", + accountId: account.accountId, + direction: "inbound", + at: message.timestamp, + }); + + if (opts.onMessage) { + await opts.onMessage(message, client); + return; + } + + await handleIrcInbound({ + message, + account, + config: cfg, + runtime, + connectedNick: client.nick, + sendReply: async (target, text) => { + client?.sendPrivmsg(target, text); + opts.statusSink?.({ lastOutboundAt: Date.now() }); + core.channel.activity.record({ + channel: "irc", + accountId: account.accountId, + direction: "outbound", + }); + }, + statusSink: opts.statusSink, + }); + }, + }); + + logger.info( + `[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`, + ); + + return { + stop: () => { + client?.quit("shutdown"); + client = null; + }, + }; +} diff --git a/extensions/irc/src/normalize.test.ts b/extensions/irc/src/normalize.test.ts new file mode 100644 index 00000000000..a498ffaacd0 --- /dev/null +++ b/extensions/irc/src/normalize.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + buildIrcAllowlistCandidates, + normalizeIrcAllowEntry, + normalizeIrcMessagingTarget, + resolveIrcAllowlistMatch, +} from "./normalize.js"; + +describe("irc normalize", () => { + it("normalizes targets", () => { + expect(normalizeIrcMessagingTarget("irc:channel:openclaw")).toBe("#openclaw"); + expect(normalizeIrcMessagingTarget("user:alice")).toBe("alice"); + expect(normalizeIrcMessagingTarget("\n")).toBeUndefined(); + }); + + it("normalizes allowlist entries", () => { + expect(normalizeIrcAllowEntry("IRC:User:Alice!u@h")).toBe("alice!u@h"); + }); + + it("matches senders by nick/user/host candidates", () => { + const message = { + messageId: "m1", + target: "#chan", + senderNick: "Alice", + senderUser: "ident", + senderHost: "example.org", + text: "hi", + timestamp: Date.now(), + isGroup: true, + }; + + expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org"); + expect( + resolveIrcAllowlistMatch({ + allowFrom: ["alice!ident@example.org"], + message, + }).allowed, + ).toBe(true); + expect( + resolveIrcAllowlistMatch({ + allowFrom: ["bob"], + message, + }).allowed, + ).toBe(false); + }); +}); diff --git a/extensions/irc/src/normalize.ts b/extensions/irc/src/normalize.ts new file mode 100644 index 00000000000..0860efa5e07 --- /dev/null +++ b/extensions/irc/src/normalize.ts @@ -0,0 +1,117 @@ +import type { IrcInboundMessage } from "./types.js"; +import { hasIrcControlChars } from "./control-chars.js"; + +const IRC_TARGET_PATTERN = /^[^\s:]+$/u; + +export function isChannelTarget(target: string): boolean { + return target.startsWith("#") || target.startsWith("&"); +} + +export function normalizeIrcMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + let target = trimmed; + const lowered = target.toLowerCase(); + if (lowered.startsWith("irc:")) { + target = target.slice("irc:".length).trim(); + } + if (target.toLowerCase().startsWith("channel:")) { + target = target.slice("channel:".length).trim(); + if (!target.startsWith("#") && !target.startsWith("&")) { + target = `#${target}`; + } + } + if (target.toLowerCase().startsWith("user:")) { + target = target.slice("user:".length).trim(); + } + if (!target || !looksLikeIrcTargetId(target)) { + return undefined; + } + return target; +} + +export function looksLikeIrcTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) { + return false; + } + if (hasIrcControlChars(trimmed)) { + return false; + } + return IRC_TARGET_PATTERN.test(trimmed); +} + +export function normalizeIrcAllowEntry(raw: string): string { + let value = raw.trim().toLowerCase(); + if (!value) { + return ""; + } + if (value.startsWith("irc:")) { + value = value.slice("irc:".length); + } + if (value.startsWith("user:")) { + value = value.slice("user:".length); + } + return value.trim(); +} + +export function normalizeIrcAllowlist(entries?: Array): string[] { + return (entries ?? []).map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean); +} + +export function formatIrcSenderId(message: IrcInboundMessage): string { + const base = message.senderNick.trim(); + const user = message.senderUser?.trim(); + const host = message.senderHost?.trim(); + if (user && host) { + return `${base}!${user}@${host}`; + } + if (user) { + return `${base}!${user}`; + } + if (host) { + return `${base}@${host}`; + } + return base; +} + +export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] { + const nick = message.senderNick.trim().toLowerCase(); + const user = message.senderUser?.trim().toLowerCase(); + const host = message.senderHost?.trim().toLowerCase(); + const candidates = new Set(); + if (nick) { + candidates.add(nick); + } + if (nick && user) { + candidates.add(`${nick}!${user}`); + } + if (nick && host) { + candidates.add(`${nick}@${host}`); + } + if (nick && user && host) { + candidates.add(`${nick}!${user}@${host}`); + } + return [...candidates]; +} + +export function resolveIrcAllowlistMatch(params: { + allowFrom: string[]; + message: IrcInboundMessage; +}): { allowed: boolean; source?: string } { + const allowFrom = new Set( + params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean), + ); + if (allowFrom.has("*")) { + return { allowed: true, source: "wildcard" }; + } + const candidates = buildIrcAllowlistCandidates(params.message); + for (const candidate of candidates) { + if (allowFrom.has(candidate)) { + return { allowed: true, source: candidate }; + } + } + return { allowed: false }; +} diff --git a/extensions/irc/src/onboarding.test.ts b/extensions/irc/src/onboarding.test.ts new file mode 100644 index 00000000000..400e34fc739 --- /dev/null +++ b/extensions/irc/src/onboarding.test.ts @@ -0,0 +1,118 @@ +import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk"; +import { describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "./types.js"; +import { ircOnboardingAdapter } from "./onboarding.js"; + +describe("irc onboarding", () => { + it("configures host and nick via onboarding prompts", async () => { + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async () => "allowlist"), + multiselect: vi.fn(async () => []), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "IRC server host") { + return "irc.libera.chat"; + } + if (message === "IRC server port") { + return "6697"; + } + if (message === "IRC nick") { + return "openclaw-bot"; + } + if (message === "IRC username") { + return "openclaw"; + } + if (message === "IRC real name") { + return "OpenClaw Bot"; + } + if (message.startsWith("Auto-join IRC channels")) { + return "#openclaw, #ops"; + } + if (message.startsWith("IRC channels allowlist")) { + return "#openclaw, #ops"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async ({ message }: { message: string }) => { + if (message === "Use TLS for IRC?") { + return true; + } + if (message === "Configure IRC channels access?") { + return true; + } + return false; + }), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + const result = await ircOnboardingAdapter.configure({ + cfg: {} as CoreConfig, + runtime, + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.irc?.enabled).toBe(true); + expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat"); + expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot"); + expect(result.cfg.channels?.irc?.tls).toBe(true); + expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]); + expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist"); + expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]); + }); + + it("writes DM allowFrom to top-level config for non-default account prompts", async () => { + const prompter: WizardPrompter = { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async () => "allowlist"), + multiselect: vi.fn(async () => []), + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "IRC allowFrom (nick or nick!user@host)") { + return "Alice, Bob!ident@example.org"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + }; + + const promptAllowFrom = ircOnboardingAdapter.dmPolicy?.promptAllowFrom; + expect(promptAllowFrom).toBeTypeOf("function"); + + const cfg: CoreConfig = { + channels: { + irc: { + accounts: { + work: { + host: "irc.libera.chat", + nick: "openclaw-work", + }, + }, + }, + }, + }; + + const updated = (await promptAllowFrom?.({ + cfg, + prompter, + accountId: "work", + })) as CoreConfig; + + expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]); + expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined(); + }); +}); diff --git a/extensions/irc/src/onboarding.ts b/extensions/irc/src/onboarding.ts new file mode 100644 index 00000000000..6f0508f6768 --- /dev/null +++ b/extensions/irc/src/onboarding.ts @@ -0,0 +1,479 @@ +import { + addWildcardAllowFrom, + DEFAULT_ACCOUNT_ID, + formatDocsLink, + promptAccountId, + promptChannelAccessConfig, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type DmPolicy, + type WizardPrompter, +} from "openclaw/plugin-sdk"; +import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js"; +import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js"; +import { + isChannelTarget, + normalizeIrcAllowEntry, + normalizeIrcMessagingTarget, +} from "./normalize.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 { + 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 { + 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 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, + }, + }, + }), +}; diff --git a/extensions/irc/src/policy.test.ts b/extensions/irc/src/policy.test.ts new file mode 100644 index 00000000000..cd617c86195 --- /dev/null +++ b/extensions/irc/src/policy.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { resolveChannelGroupPolicy } from "../../../src/config/group-policy.js"; +import { + resolveIrcGroupAccessGate, + resolveIrcGroupMatch, + resolveIrcGroupSenderAllowed, + resolveIrcMentionGate, + resolveIrcRequireMention, +} from "./policy.js"; + +describe("irc policy", () => { + it("matches direct and wildcard group entries", () => { + const direct = resolveIrcGroupMatch({ + groups: { + "#ops": { requireMention: false }, + }, + target: "#ops", + }); + expect(direct.allowed).toBe(true); + expect(resolveIrcRequireMention({ groupConfig: direct.groupConfig })).toBe(false); + + const wildcard = resolveIrcGroupMatch({ + groups: { + "*": { requireMention: true }, + }, + target: "#random", + }); + expect(wildcard.allowed).toBe(true); + expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true); + }); + + it("enforces allowlist by default in groups", () => { + const message = { + messageId: "m1", + target: "#ops", + senderNick: "alice", + senderUser: "ident", + senderHost: "example.org", + text: "hi", + timestamp: Date.now(), + isGroup: true, + }; + + expect( + resolveIrcGroupSenderAllowed({ + groupPolicy: "allowlist", + message, + outerAllowFrom: [], + innerAllowFrom: [], + }), + ).toBe(false); + + expect( + resolveIrcGroupSenderAllowed({ + groupPolicy: "allowlist", + message, + outerAllowFrom: ["alice"], + innerAllowFrom: [], + }), + ).toBe(true); + }); + + it('allows unconfigured channels when groupPolicy is "open"', () => { + const groupMatch = resolveIrcGroupMatch({ + groups: undefined, + target: "#random", + }); + const gate = resolveIrcGroupAccessGate({ + groupPolicy: "open", + groupMatch, + }); + expect(gate.allowed).toBe(true); + expect(gate.reason).toBe("open"); + }); + + it("honors explicit group disable even in open mode", () => { + const groupMatch = resolveIrcGroupMatch({ + groups: { + "#ops": { enabled: false }, + }, + target: "#ops", + }); + const gate = resolveIrcGroupAccessGate({ + groupPolicy: "open", + groupMatch, + }); + expect(gate.allowed).toBe(false); + expect(gate.reason).toBe("disabled"); + }); + + it("allows authorized control commands without mention", () => { + const gate = resolveIrcMentionGate({ + isGroup: true, + requireMention: true, + wasMentioned: false, + hasControlCommand: true, + allowTextCommands: true, + commandAuthorized: true, + }); + expect(gate.shouldSkip).toBe(false); + }); + + it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => { + const groups = { + "#Ops": { requireMention: false }, + "#Hidden": { enabled: false }, + "*": { requireMention: true }, + }; + + const inboundDirect = resolveIrcGroupMatch({ groups, target: "#ops" }); + const sharedDirect = resolveChannelGroupPolicy({ + cfg: { channels: { irc: { groups } } }, + channel: "irc", + groupId: "#ops", + groupIdCaseInsensitive: true, + }); + expect(sharedDirect.allowed).toBe(inboundDirect.allowed); + expect(sharedDirect.groupConfig?.requireMention).toBe( + inboundDirect.groupConfig?.requireMention, + ); + + const inboundDisabled = resolveIrcGroupMatch({ groups, target: "#hidden" }); + const sharedDisabled = resolveChannelGroupPolicy({ + cfg: { channels: { irc: { groups } } }, + channel: "irc", + groupId: "#hidden", + groupIdCaseInsensitive: true, + }); + expect(sharedDisabled.allowed).toBe(inboundDisabled.allowed); + expect(sharedDisabled.groupConfig?.enabled).toBe(inboundDisabled.groupConfig?.enabled); + }); +}); diff --git a/extensions/irc/src/policy.ts b/extensions/irc/src/policy.ts new file mode 100644 index 00000000000..7faa24f4d50 --- /dev/null +++ b/extensions/irc/src/policy.ts @@ -0,0 +1,157 @@ +import type { IrcAccountConfig, IrcChannelConfig } from "./types.js"; +import type { IrcInboundMessage } from "./types.js"; +import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js"; + +export type IrcGroupMatch = { + allowed: boolean; + groupConfig?: IrcChannelConfig; + wildcardConfig?: IrcChannelConfig; + hasConfiguredGroups: boolean; +}; + +export type IrcGroupAccessGate = { + allowed: boolean; + reason: string; +}; + +export function resolveIrcGroupMatch(params: { + groups?: Record; + target: string; +}): IrcGroupMatch { + const groups = params.groups ?? {}; + const hasConfiguredGroups = Object.keys(groups).length > 0; + + // IRC channel targets are case-insensitive, but config keys are plain strings. + // To avoid surprising drops (e.g. "#TUIRC-DEV" vs "#tuirc-dev"), match + // group config keys case-insensitively. + const direct = groups[params.target]; + if (direct) { + return { + // "allowed" means the target matched an allowlisted key. + // Explicit disables are handled later by resolveIrcGroupAccessGate. + allowed: true, + groupConfig: direct, + wildcardConfig: groups["*"], + hasConfiguredGroups, + }; + } + + const targetLower = params.target.toLowerCase(); + const directKey = Object.keys(groups).find((key) => key.toLowerCase() === targetLower); + if (directKey) { + const matched = groups[directKey]; + if (matched) { + return { + // "allowed" means the target matched an allowlisted key. + // Explicit disables are handled later by resolveIrcGroupAccessGate. + allowed: true, + groupConfig: matched, + wildcardConfig: groups["*"], + hasConfiguredGroups, + }; + } + } + + const wildcard = groups["*"]; + if (wildcard) { + return { + // "allowed" means the target matched an allowlisted key. + // Explicit disables are handled later by resolveIrcGroupAccessGate. + allowed: true, + wildcardConfig: wildcard, + hasConfiguredGroups, + }; + } + return { + allowed: false, + hasConfiguredGroups, + }; +} + +export function resolveIrcGroupAccessGate(params: { + groupPolicy: IrcAccountConfig["groupPolicy"]; + groupMatch: IrcGroupMatch; +}): IrcGroupAccessGate { + const policy = params.groupPolicy ?? "allowlist"; + if (policy === "disabled") { + return { allowed: false, reason: "groupPolicy=disabled" }; + } + + // In open mode, unconfigured channels are allowed (mention-gated) but explicit + // per-channel/wildcard disables still apply. + if (policy === "allowlist") { + if (!params.groupMatch.hasConfiguredGroups) { + return { + allowed: false, + reason: "groupPolicy=allowlist and no groups configured", + }; + } + if (!params.groupMatch.allowed) { + return { allowed: false, reason: "not allowlisted" }; + } + } + + if ( + params.groupMatch.groupConfig?.enabled === false || + params.groupMatch.wildcardConfig?.enabled === false + ) { + return { allowed: false, reason: "disabled" }; + } + + return { allowed: true, reason: policy === "open" ? "open" : "allowlisted" }; +} + +export function resolveIrcRequireMention(params: { + groupConfig?: IrcChannelConfig; + wildcardConfig?: IrcChannelConfig; +}): boolean { + if (params.groupConfig?.requireMention !== undefined) { + return params.groupConfig.requireMention; + } + if (params.wildcardConfig?.requireMention !== undefined) { + return params.wildcardConfig.requireMention; + } + return true; +} + +export function resolveIrcMentionGate(params: { + isGroup: boolean; + requireMention: boolean; + wasMentioned: boolean; + hasControlCommand: boolean; + allowTextCommands: boolean; + commandAuthorized: boolean; +}): { shouldSkip: boolean; reason: string } { + if (!params.isGroup) { + return { shouldSkip: false, reason: "direct" }; + } + if (!params.requireMention) { + return { shouldSkip: false, reason: "mention-not-required" }; + } + if (params.wasMentioned) { + return { shouldSkip: false, reason: "mentioned" }; + } + if (params.hasControlCommand && params.allowTextCommands && params.commandAuthorized) { + return { shouldSkip: false, reason: "authorized-command" }; + } + return { shouldSkip: true, reason: "missing-mention" }; +} + +export function resolveIrcGroupSenderAllowed(params: { + groupPolicy: IrcAccountConfig["groupPolicy"]; + message: IrcInboundMessage; + outerAllowFrom: string[]; + innerAllowFrom: string[]; +}): boolean { + const policy = params.groupPolicy ?? "allowlist"; + const inner = normalizeIrcAllowlist(params.innerAllowFrom); + const outer = normalizeIrcAllowlist(params.outerAllowFrom); + + if (inner.length > 0) { + return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed; + } + if (outer.length > 0) { + return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed; + } + return policy === "open"; +} diff --git a/extensions/irc/src/probe.ts b/extensions/irc/src/probe.ts new file mode 100644 index 00000000000..95f7ea6a527 --- /dev/null +++ b/extensions/irc/src/probe.ts @@ -0,0 +1,64 @@ +import type { CoreConfig, IrcProbe } from "./types.js"; +import { resolveIrcAccount } from "./accounts.js"; +import { connectIrcClient } from "./client.js"; + +function formatError(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return typeof err === "string" ? err : JSON.stringify(err); +} + +export async function probeIrc( + cfg: CoreConfig, + opts?: { accountId?: string; timeoutMs?: number }, +): Promise { + const account = resolveIrcAccount({ cfg, accountId: opts?.accountId }); + const base: IrcProbe = { + ok: false, + host: account.host, + port: account.port, + tls: account.tls, + nick: account.nick, + }; + + if (!account.configured) { + return { + ...base, + error: "missing host or nick", + }; + } + + const started = Date.now(); + try { + const client = await connectIrcClient({ + host: account.host, + port: account.port, + tls: account.tls, + nick: account.nick, + username: account.username, + realname: account.realname, + password: account.password, + nickserv: { + enabled: account.config.nickserv?.enabled, + service: account.config.nickserv?.service, + password: account.config.nickserv?.password, + register: account.config.nickserv?.register, + registerEmail: account.config.nickserv?.registerEmail, + }, + connectTimeoutMs: opts?.timeoutMs ?? 8000, + }); + const elapsed = Date.now() - started; + client.quit("probe"); + return { + ...base, + ok: true, + latencyMs: elapsed, + }; + } catch (err) { + return { + ...base, + error: formatError(err), + }; + } +} diff --git a/extensions/irc/src/protocol.test.ts b/extensions/irc/src/protocol.test.ts new file mode 100644 index 00000000000..8be7c4ff06c --- /dev/null +++ b/extensions/irc/src/protocol.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + parseIrcLine, + parseIrcPrefix, + sanitizeIrcOutboundText, + sanitizeIrcTarget, + splitIrcText, +} from "./protocol.js"; + +describe("irc protocol", () => { + it("parses PRIVMSG lines with prefix and trailing", () => { + const parsed = parseIrcLine(":alice!u@host PRIVMSG #room :hello world"); + expect(parsed).toEqual({ + raw: ":alice!u@host PRIVMSG #room :hello world", + prefix: "alice!u@host", + command: "PRIVMSG", + params: ["#room"], + trailing: "hello world", + }); + + expect(parseIrcPrefix(parsed?.prefix)).toEqual({ + nick: "alice", + user: "u", + host: "host", + }); + }); + + it("sanitizes outbound text to prevent command injection", () => { + expect(sanitizeIrcOutboundText("hello\\r\\nJOIN #oops")).toBe("hello JOIN #oops"); + expect(sanitizeIrcOutboundText("\\u0001test\\u0000")).toBe("test"); + }); + + it("validates targets and rejects control characters", () => { + expect(sanitizeIrcTarget("#openclaw")).toBe("#openclaw"); + expect(() => sanitizeIrcTarget("#bad\\nPING")).toThrow(/Invalid IRC target/); + expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/); + }); + + it("splits long text on boundaries", () => { + const chunks = splitIrcText("a ".repeat(300), 120); + expect(chunks.length).toBeGreaterThan(2); + expect(chunks.every((chunk) => chunk.length <= 120)).toBe(true); + }); +}); diff --git a/extensions/irc/src/protocol.ts b/extensions/irc/src/protocol.ts new file mode 100644 index 00000000000..c8b08f6e697 --- /dev/null +++ b/extensions/irc/src/protocol.ts @@ -0,0 +1,169 @@ +import { randomUUID } from "node:crypto"; +import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js"; + +const IRC_TARGET_PATTERN = /^[^\s:]+$/u; + +export type ParsedIrcLine = { + raw: string; + prefix?: string; + command: string; + params: string[]; + trailing?: string; +}; + +export type ParsedIrcPrefix = { + nick?: string; + user?: string; + host?: string; + server?: string; +}; + +export function parseIrcLine(line: string): ParsedIrcLine | null { + const raw = line.replace(/[\r\n]+/g, "").trim(); + if (!raw) { + return null; + } + + let cursor = raw; + let prefix: string | undefined; + if (cursor.startsWith(":")) { + const idx = cursor.indexOf(" "); + if (idx <= 1) { + return null; + } + prefix = cursor.slice(1, idx); + cursor = cursor.slice(idx + 1).trimStart(); + } + + if (!cursor) { + return null; + } + + const firstSpace = cursor.indexOf(" "); + const command = (firstSpace === -1 ? cursor : cursor.slice(0, firstSpace)).trim(); + if (!command) { + return null; + } + + cursor = firstSpace === -1 ? "" : cursor.slice(firstSpace + 1); + const params: string[] = []; + let trailing: string | undefined; + + while (cursor.length > 0) { + cursor = cursor.trimStart(); + if (!cursor) { + break; + } + if (cursor.startsWith(":")) { + trailing = cursor.slice(1); + break; + } + const spaceIdx = cursor.indexOf(" "); + if (spaceIdx === -1) { + params.push(cursor); + break; + } + params.push(cursor.slice(0, spaceIdx)); + cursor = cursor.slice(spaceIdx + 1); + } + + return { + raw, + prefix, + command: command.toUpperCase(), + params, + trailing, + }; +} + +export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix { + if (!prefix) { + return {}; + } + const nickPart = prefix.match(/^([^!@]+)!([^@]+)@(.+)$/); + if (nickPart) { + return { + nick: nickPart[1], + user: nickPart[2], + host: nickPart[3], + }; + } + const nickHostPart = prefix.match(/^([^@]+)@(.+)$/); + if (nickHostPart) { + return { + nick: nickHostPart[1], + host: nickHostPart[2], + }; + } + if (prefix.includes("!")) { + const [nick, user] = prefix.split("!", 2); + return { nick, user }; + } + if (prefix.includes(".")) { + return { server: prefix }; + } + return { nick: prefix }; +} + +function decodeLiteralEscapes(input: string): string { + // Defensive: this is not a full JS string unescaper. + // It's just enough to catch common "\r\n" / "\u0001" style payloads. + return input + .replace(/\\r/g, "\r") + .replace(/\\n/g, "\n") + .replace(/\\t/g, "\t") + .replace(/\\0/g, "\0") + .replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))) + .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16))); +} + +export function sanitizeIrcOutboundText(text: string): string { + const decoded = decodeLiteralEscapes(text); + return stripIrcControlChars(decoded.replace(/\r?\n/g, " ")).trim(); +} + +export function sanitizeIrcTarget(raw: string): string { + const decoded = decodeLiteralEscapes(raw); + if (!decoded) { + throw new Error("IRC target is required"); + } + // Reject any surrounding whitespace instead of trimming it away. + if (decoded !== decoded.trim()) { + throw new Error(`Invalid IRC target: ${raw}`); + } + if (hasIrcControlChars(decoded)) { + throw new Error(`Invalid IRC target: ${raw}`); + } + if (!IRC_TARGET_PATTERN.test(decoded)) { + throw new Error(`Invalid IRC target: ${raw}`); + } + return decoded; +} + +export function splitIrcText(text: string, maxChars = 350): string[] { + const cleaned = sanitizeIrcOutboundText(text); + if (!cleaned) { + return []; + } + if (cleaned.length <= maxChars) { + return [cleaned]; + } + const chunks: string[] = []; + let remaining = cleaned; + while (remaining.length > maxChars) { + let splitAt = remaining.lastIndexOf(" ", maxChars); + if (splitAt < Math.floor(maxChars * 0.5)) { + splitAt = maxChars; + } + chunks.push(remaining.slice(0, splitAt).trim()); + remaining = remaining.slice(splitAt).trimStart(); + } + if (remaining) { + chunks.push(remaining); + } + return chunks.filter(Boolean); +} + +export function makeIrcMessageId() { + return randomUUID(); +} diff --git a/extensions/irc/src/runtime.ts b/extensions/irc/src/runtime.ts new file mode 100644 index 00000000000..547525cea4f --- /dev/null +++ b/extensions/irc/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "openclaw/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setIrcRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getIrcRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("IRC runtime not initialized"); + } + return runtime; +} diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts new file mode 100644 index 00000000000..ebc48564634 --- /dev/null +++ b/extensions/irc/src/send.ts @@ -0,0 +1,99 @@ +import type { IrcClient } from "./client.js"; +import type { CoreConfig } from "./types.js"; +import { resolveIrcAccount } from "./accounts.js"; +import { connectIrcClient } from "./client.js"; +import { normalizeIrcMessagingTarget } from "./normalize.js"; +import { makeIrcMessageId } from "./protocol.js"; +import { getIrcRuntime } from "./runtime.js"; + +type SendIrcOptions = { + accountId?: string; + replyTo?: string; + target?: string; + client?: IrcClient; +}; + +export type SendIrcResult = { + messageId: string; + target: string; +}; + +function resolveTarget(to: string, opts?: SendIrcOptions): string { + const fromArg = normalizeIrcMessagingTarget(to); + if (fromArg) { + return fromArg; + } + const fromOpt = normalizeIrcMessagingTarget(opts?.target ?? ""); + if (fromOpt) { + return fromOpt; + } + throw new Error(`Invalid IRC target: ${to}`); +} + +export async function sendMessageIrc( + to: string, + text: string, + opts: SendIrcOptions = {}, +): Promise { + const runtime = getIrcRuntime(); + const cfg = runtime.config.loadConfig() as CoreConfig; + const account = resolveIrcAccount({ + cfg, + accountId: opts.accountId, + }); + + if (!account.configured) { + throw new Error( + `IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`, + ); + } + + const target = resolveTarget(to, opts); + const tableMode = runtime.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "irc", + accountId: account.accountId, + }); + const prepared = runtime.channel.text.convertMarkdownTables(text.trim(), tableMode); + const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared; + + if (!payload.trim()) { + throw new Error("Message must be non-empty for IRC sends"); + } + + const client = opts.client; + if (client?.isReady()) { + client.sendPrivmsg(target, payload); + } else { + const transient = await connectIrcClient({ + host: account.host, + port: account.port, + tls: account.tls, + nick: account.nick, + username: account.username, + realname: account.realname, + password: account.password, + nickserv: { + enabled: account.config.nickserv?.enabled, + service: account.config.nickserv?.service, + password: account.config.nickserv?.password, + register: account.config.nickserv?.register, + registerEmail: account.config.nickserv?.registerEmail, + }, + connectTimeoutMs: 12000, + }); + transient.sendPrivmsg(target, payload); + transient.quit("sent"); + } + + runtime.channel.activity.record({ + channel: "irc", + accountId: account.accountId, + direction: "outbound", + }); + + return { + messageId: makeIrcMessageId(), + target, + }; +} diff --git a/extensions/irc/src/types.ts b/extensions/irc/src/types.ts new file mode 100644 index 00000000000..5446649aad2 --- /dev/null +++ b/extensions/irc/src/types.ts @@ -0,0 +1,94 @@ +import type { + BlockStreamingCoalesceConfig, + DmConfig, + DmPolicy, + GroupPolicy, + GroupToolPolicyBySenderConfig, + GroupToolPolicyConfig, + MarkdownConfig, + OpenClawConfig, +} from "openclaw/plugin-sdk"; + +export type IrcChannelConfig = { + requireMention?: boolean; + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; + skills?: string[]; + enabled?: boolean; + allowFrom?: Array; + systemPrompt?: string; +}; + +export type IrcNickServConfig = { + enabled?: boolean; + service?: string; + password?: string; + passwordFile?: string; + register?: boolean; + registerEmail?: string; +}; + +export type IrcAccountConfig = { + name?: string; + enabled?: boolean; + host?: string; + port?: number; + tls?: boolean; + nick?: string; + username?: string; + realname?: string; + password?: string; + passwordFile?: string; + nickserv?: IrcNickServConfig; + dmPolicy?: DmPolicy; + allowFrom?: Array; + groupPolicy?: GroupPolicy; + groupAllowFrom?: Array; + groups?: Record; + channels?: string[]; + mentionPatterns?: string[]; + markdown?: MarkdownConfig; + historyLimit?: number; + dmHistoryLimit?: number; + dms?: Record; + textChunkLimit?: number; + chunkMode?: "length" | "newline"; + blockStreaming?: boolean; + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + responsePrefix?: string; + mediaMaxMb?: number; +}; + +export type IrcConfig = IrcAccountConfig & { + accounts?: Record; +}; + +export type CoreConfig = OpenClawConfig & { + channels?: OpenClawConfig["channels"] & { + irc?: IrcConfig; + }; +}; + +export type IrcInboundMessage = { + messageId: string; + /** Conversation peer id: channel name for groups, sender nick for DMs. */ + target: string; + /** Raw IRC PRIVMSG target (bot nick for DMs, channel for groups). */ + rawTarget?: string; + senderNick: string; + senderUser?: string; + senderHost?: string; + text: string; + timestamp: number; + isGroup: boolean; +}; + +export type IrcProbe = { + ok: boolean; + host: string; + port: number; + tls: boolean; + nick: string; + latencyMs?: number; + error?: string; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e4cd030403..290e8859ee0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,6 +342,12 @@ importers: specifier: workspace:* version: link:../.. + extensions/irc: + devDependencies: + openclaw: + specifier: workspace:* + version: link:../.. + extensions/line: devDependencies: openclaw: diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 33ac0a68a9c..af6f9d3bc7e 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -10,6 +10,10 @@ import type { ChannelPlugin, ChannelThreadingAdapter, } from "./plugins/types.js"; +import { + resolveChannelGroupRequireMention, + resolveChannelGroupToolsPolicy, +} from "../config/group-policy.js"; import { resolveDiscordAccount } from "../discord/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js"; import { requireActivePluginRegistry } from "../plugins/runtime.js"; @@ -75,7 +79,6 @@ const formatLower = (allowFrom: Array) => .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.toLowerCase()); - // Channel docks: lightweight channel metadata/behavior for shared code paths. // // Rules: @@ -213,6 +216,73 @@ const DOCKS: Record = { }), }, }, + irc: { + id: "irc", + capabilities: { + chatTypes: ["direct", "group"], + media: true, + blockStreaming: true, + }, + outbound: { textChunkLimit: 350 }, + streaming: { + blockStreamingCoalesceDefaults: { minChars: 300, idleMs: 1000 }, + }, + config: { + resolveAllowFrom: ({ cfg, accountId }) => { + const channel = cfg.channels?.irc; + const normalized = normalizeAccountId(accountId); + const account = + channel?.accounts?.[normalized] ?? + channel?.accounts?.[ + Object.keys(channel?.accounts ?? {}).find( + (key) => key.toLowerCase() === normalized.toLowerCase(), + ) ?? "" + ]; + return (account?.allowFrom ?? channel?.allowFrom ?? []).map((entry) => String(entry)); + }, + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => + entry + .replace(/^irc:/i, "") + .replace(/^user:/i, "") + .toLowerCase(), + ), + }, + groups: { + resolveRequireMention: ({ cfg, accountId, groupId }) => { + if (!groupId) { + return true; + } + return resolveChannelGroupRequireMention({ + cfg, + channel: "irc", + groupId, + accountId, + groupIdCaseInsensitive: true, + }); + }, + resolveToolPolicy: ({ cfg, accountId, groupId, senderId, senderName, senderUsername }) => { + if (!groupId) { + return undefined; + } + // IRC supports per-channel tool policies. Prefer the shared resolver so + // toolsBySender is honored consistently across surfaces. + return resolveChannelGroupToolsPolicy({ + cfg, + channel: "irc", + groupId, + accountId, + groupIdCaseInsensitive: true, + senderId, + senderName, + senderUsername, + }); + }, + }, + }, googlechat: { id: "googlechat", capabilities: { diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts index 5101519b98c..cee891be70c 100644 --- a/src/channels/registry.test.ts +++ b/src/channels/registry.test.ts @@ -10,6 +10,7 @@ describe("channel registry", () => { expect(normalizeChatChannelId("imsg")).toBe("imessage"); expect(normalizeChatChannelId("gchat")).toBe("googlechat"); expect(normalizeChatChannelId("google-chat")).toBe("googlechat"); + expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc"); expect(normalizeChatChannelId("web")).toBeNull(); }); diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 701516a0c80..205372334d4 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -8,6 +8,7 @@ export const CHAT_CHANNEL_ORDER = [ "telegram", "whatsapp", "discord", + "irc", "googlechat", "slack", "signal", @@ -58,6 +59,16 @@ const CHAT_CHANNEL_META: Record = { blurb: "very well supported right now.", systemImage: "bubble.left.and.bubble.right", }, + irc: { + id: "irc", + label: "IRC", + selectionLabel: "IRC (Server + Nick)", + detailLabel: "IRC", + docsPath: "/channels/irc", + docsLabel: "irc", + blurb: "classic IRC networks with DM/channel routing and pairing controls.", + systemImage: "network", + }, googlechat: { id: "googlechat", label: "Google Chat", @@ -102,6 +113,7 @@ const CHAT_CHANNEL_META: Record = { export const CHAT_CHANNEL_ALIASES: Record = { imsg: "imessage", + "internet-relay-chat": "irc", "google-chat": "googlechat", gchat: "googlechat", }; diff --git a/src/config/config.irc.test.ts b/src/config/config.irc.test.ts new file mode 100644 index 00000000000..680d10ba5e3 --- /dev/null +++ b/src/config/config.irc.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("config irc", () => { + it("accepts basic irc config", () => { + const res = validateConfigObject({ + channels: { + irc: { + host: "irc.libera.chat", + nick: "openclaw-bot", + channels: ["#openclaw"], + }, + }, + }); + + expect(res.ok).toBe(true); + expect(res.config.channels?.irc?.host).toBe("irc.libera.chat"); + expect(res.config.channels?.irc?.nick).toBe("openclaw-bot"); + }); + + it('rejects irc.dmPolicy="open" without allowFrom "*"', () => { + const res = validateConfigObject({ + channels: { + irc: { + dmPolicy: "open", + allowFrom: ["alice"], + }, + }, + }); + + expect(res.ok).toBe(false); + expect(res.issues[0]?.path).toBe("channels.irc.allowFrom"); + }); + + it('accepts irc.dmPolicy="open" with allowFrom "*"', () => { + const res = validateConfigObject({ + channels: { + irc: { + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + expect(res.ok).toBe(true); + expect(res.config.channels?.irc?.dmPolicy).toBe("open"); + }); + + it("accepts mixed allowFrom value types for IRC", () => { + const res = validateConfigObject({ + channels: { + irc: { + allowFrom: [12345, "alice"], + groupAllowFrom: [67890, "alice!ident@example.org"], + groups: { + "#ops": { + allowFrom: [42, "alice"], + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + expect(res.config.channels?.irc?.allowFrom).toEqual([12345, "alice"]); + expect(res.config.channels?.irc?.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]); + expect(res.config.channels?.irc?.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]); + }); + + it("rejects nickserv register without registerEmail", () => { + const res = validateConfigObject({ + channels: { + irc: { + nickserv: { + register: true, + password: "secret", + }, + }, + }, + }); + + expect(res.ok).toBe(false); + expect(res.issues[0]?.path).toBe("channels.irc.nickserv.registerEmail"); + }); + + it("accepts nickserv register with password and registerEmail", () => { + const res = validateConfigObject({ + channels: { + irc: { + nickserv: { + register: true, + password: "secret", + registerEmail: "bot@example.com", + }, + }, + }, + }); + + expect(res.ok).toBe(true); + expect(res.config.channels?.irc?.nickserv?.register).toBe(true); + }); + + it("accepts nickserv register with registerEmail only (password may come from env)", () => { + const res = validateConfigObject({ + channels: { + irc: { + nickserv: { + register: true, + registerEmail: "bot@example.com", + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); +}); diff --git a/src/config/group-policy.ts b/src/config/group-policy.ts index 2adc60f9bc0..8aecd78a8f2 100644 --- a/src/config/group-policy.ts +++ b/src/config/group-policy.ts @@ -20,6 +20,29 @@ export type ChannelGroupPolicy = { type ChannelGroups = Record; +function resolveChannelGroupConfig( + groups: ChannelGroups | undefined, + groupId: string, + caseInsensitive = false, +): ChannelGroupConfig | undefined { + if (!groups) { + return undefined; + } + const direct = groups[groupId]; + if (direct) { + return direct; + } + if (!caseInsensitive) { + return undefined; + } + const target = groupId.toLowerCase(); + const matchedKey = Object.keys(groups).find((key) => key !== "*" && key.toLowerCase() === target); + if (!matchedKey) { + return undefined; + } + return groups[matchedKey]; +} + export type GroupToolPolicySender = { senderId?: string | null; senderName?: string | null; @@ -125,18 +148,18 @@ export function resolveChannelGroupPolicy(params: { channel: GroupPolicyChannel; groupId?: string | null; accountId?: string | null; + groupIdCaseInsensitive?: boolean; }): ChannelGroupPolicy { const { cfg, channel } = params; const groups = resolveChannelGroups(cfg, channel, params.accountId); const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0); const normalizedId = params.groupId?.trim(); - const groupConfig = normalizedId && groups ? groups[normalizedId] : undefined; + const groupConfig = normalizedId + ? resolveChannelGroupConfig(groups, normalizedId, params.groupIdCaseInsensitive) + : undefined; const defaultConfig = groups?.["*"]; const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*")); - const allowed = - !allowlistEnabled || - allowAll || - (normalizedId ? Boolean(groups && Object.hasOwn(groups, normalizedId)) : false); + const allowed = !allowlistEnabled || allowAll || Boolean(groupConfig); return { allowlistEnabled, allowed, @@ -150,6 +173,7 @@ export function resolveChannelGroupRequireMention(params: { channel: GroupPolicyChannel; groupId?: string | null; accountId?: string | null; + groupIdCaseInsensitive?: boolean; requireMentionOverride?: boolean; overrideOrder?: "before-config" | "after-config"; }): boolean { @@ -180,6 +204,7 @@ export function resolveChannelGroupToolsPolicy( channel: GroupPolicyChannel; groupId?: string | null; accountId?: string | null; + groupIdCaseInsensitive?: boolean; } & GroupToolPolicySender, ): GroupToolPolicyConfig | undefined { const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params); diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index f84900d446e..72f4d2dd4d8 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -29,6 +29,19 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); + it("configures irc as disabled when configured via env", () => { + const result = applyPluginAutoEnable({ + config: {}, + env: { + IRC_HOST: "irc.libera.chat", + IRC_NICK: "openclaw-bot", + }, + }); + + expect(result.config.plugins?.entries?.irc?.enabled).toBe(false); + expect(result.changes.join("\n")).toContain("IRC configured, not enabled yet."); + }); + it("configures provider auth plugins as disabled when profiles exist", () => { const result = applyPluginAutoEnable({ config: { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 99f034aa368..eb56c3402d6 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -105,6 +105,23 @@ function isDiscordConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boole return recordHasKeys(entry); } +function isIrcConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { + if (hasNonEmptyString(env.IRC_HOST) && hasNonEmptyString(env.IRC_NICK)) { + return true; + } + const entry = resolveChannelConfig(cfg, "irc"); + if (!entry) { + return false; + } + if (hasNonEmptyString(entry.host) || hasNonEmptyString(entry.nick)) { + return true; + } + if (accountsHaveKeys(entry.accounts, ["host", "nick"])) { + return true; + } + return recordHasKeys(entry); +} + function isSlackConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { if ( hasNonEmptyString(env.SLACK_BOT_TOKEN) || @@ -189,6 +206,8 @@ export function isChannelConfigured( return isTelegramConfigured(cfg, env); case "discord": return isDiscordConfigured(cfg, env); + case "irc": + return isIrcConfigured(cfg, env); case "slack": return isSlackConfigured(cfg, env); case "signal": diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts new file mode 100644 index 00000000000..fdcc20f34e5 --- /dev/null +++ b/src/config/schema.hints.ts @@ -0,0 +1,786 @@ +import { IRC_FIELD_HELP, IRC_FIELD_LABELS } from "./schema.irc.js"; + +export type ConfigUiHint = { + label?: string; + help?: string; + group?: string; + order?: number; + advanced?: boolean; + sensitive?: boolean; + placeholder?: string; + itemTemplate?: unknown; +}; + +export type ConfigUiHints = Record; + +const GROUP_LABELS: Record = { + wizard: "Wizard", + update: "Update", + diagnostics: "Diagnostics", + logging: "Logging", + gateway: "Gateway", + nodeHost: "Node Host", + agents: "Agents", + tools: "Tools", + bindings: "Bindings", + audio: "Audio", + models: "Models", + messages: "Messages", + commands: "Commands", + session: "Session", + cron: "Cron", + hooks: "Hooks", + ui: "UI", + browser: "Browser", + talk: "Talk", + channels: "Messaging Channels", + skills: "Skills", + plugins: "Plugins", + discovery: "Discovery", + presence: "Presence", + voicewake: "Voice Wake", +}; + +const GROUP_ORDER: Record = { + wizard: 20, + update: 25, + diagnostics: 27, + gateway: 30, + nodeHost: 35, + agents: 40, + tools: 50, + bindings: 55, + audio: 60, + models: 70, + messages: 80, + commands: 85, + session: 90, + cron: 100, + hooks: 110, + ui: 120, + browser: 130, + talk: 140, + channels: 150, + skills: 200, + plugins: 205, + discovery: 210, + presence: 220, + voicewake: 230, + logging: 900, +}; + +const FIELD_LABELS: Record = { + "meta.lastTouchedVersion": "Config Last Touched Version", + "meta.lastTouchedAt": "Config Last Touched At", + "update.channel": "Update Channel", + "update.checkOnStart": "Update Check on Start", + "diagnostics.enabled": "Diagnostics Enabled", + "diagnostics.flags": "Diagnostics Flags", + "diagnostics.otel.enabled": "OpenTelemetry Enabled", + "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", + "diagnostics.otel.protocol": "OpenTelemetry Protocol", + "diagnostics.otel.headers": "OpenTelemetry Headers", + "diagnostics.otel.serviceName": "OpenTelemetry Service Name", + "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", + "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", + "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", + "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", + "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", + "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", + "diagnostics.cacheTrace.filePath": "Cache Trace File Path", + "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", + "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", + "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", + "agents.list.*.identity.avatar": "Identity Avatar", + "agents.list.*.skills": "Agent Skill Filter", + "gateway.remote.url": "Remote Gateway URL", + "gateway.remote.sshTarget": "Remote Gateway SSH Target", + "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", + "gateway.remote.token": "Remote Gateway Token", + "gateway.remote.password": "Remote Gateway Password", + "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", + "gateway.auth.token": "Gateway Token", + "gateway.auth.password": "Gateway Password", + "tools.media.image.enabled": "Enable Image Understanding", + "tools.media.image.maxBytes": "Image Understanding Max Bytes", + "tools.media.image.maxChars": "Image Understanding Max Chars", + "tools.media.image.prompt": "Image Understanding Prompt", + "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", + "tools.media.image.attachments": "Image Understanding Attachment Policy", + "tools.media.image.models": "Image Understanding Models", + "tools.media.image.scope": "Image Understanding Scope", + "tools.media.models": "Media Understanding Shared Models", + "tools.media.concurrency": "Media Understanding Concurrency", + "tools.media.audio.enabled": "Enable Audio Understanding", + "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", + "tools.media.audio.maxChars": "Audio Understanding Max Chars", + "tools.media.audio.prompt": "Audio Understanding Prompt", + "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", + "tools.media.audio.language": "Audio Understanding Language", + "tools.media.audio.attachments": "Audio Understanding Attachment Policy", + "tools.media.audio.models": "Audio Understanding Models", + "tools.media.audio.scope": "Audio Understanding Scope", + "tools.media.video.enabled": "Enable Video Understanding", + "tools.media.video.maxBytes": "Video Understanding Max Bytes", + "tools.media.video.maxChars": "Video Understanding Max Chars", + "tools.media.video.prompt": "Video Understanding Prompt", + "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", + "tools.media.video.attachments": "Video Understanding Attachment Policy", + "tools.media.video.models": "Video Understanding Models", + "tools.media.video.scope": "Video Understanding Scope", + "tools.links.enabled": "Enable Link Understanding", + "tools.links.maxLinks": "Link Understanding Max Links", + "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", + "tools.links.models": "Link Understanding Models", + "tools.links.scope": "Link Understanding Scope", + "tools.profile": "Tool Profile", + "tools.alsoAllow": "Tool Allowlist Additions", + "agents.list[].tools.profile": "Agent Tool Profile", + "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", + "tools.byProvider": "Tool Policy by Provider", + "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", + "tools.exec.applyPatch.enabled": "Enable apply_patch", + "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", + "tools.exec.notifyOnExit": "Exec Notify On Exit", + "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", + "tools.exec.host": "Exec Host", + "tools.exec.security": "Exec Security", + "tools.exec.ask": "Exec Ask", + "tools.exec.node": "Exec Node Binding", + "tools.exec.pathPrepend": "Exec PATH Prepend", + "tools.exec.safeBins": "Exec Safe Bins", + "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", + "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", + "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", + "tools.message.crossContext.marker.enabled": "Cross-Context Marker", + "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", + "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", + "tools.message.broadcast.enabled": "Enable Message Broadcast", + "tools.web.search.enabled": "Enable Web Search Tool", + "tools.web.search.provider": "Web Search Provider", + "tools.web.search.apiKey": "Brave Search API Key", + "tools.web.search.maxResults": "Web Search Max Results", + "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", + "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", + "tools.web.fetch.enabled": "Enable Web Fetch Tool", + "tools.web.fetch.maxChars": "Web Fetch Max Chars", + "tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)", + "tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)", + "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", + "tools.web.fetch.userAgent": "Web Fetch User-Agent", + "gateway.controlUi.basePath": "Control UI Base Path", + "gateway.controlUi.root": "Control UI Assets Root", + "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", + "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", + "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", + "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", + "gateway.reload.mode": "Config Reload Mode", + "gateway.reload.debounceMs": "Config Reload Debounce (ms)", + "gateway.nodes.browser.mode": "Gateway Node Browser Mode", + "gateway.nodes.browser.node": "Gateway Node Browser Pin", + "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", + "gateway.nodes.denyCommands": "Gateway Node Denylist", + "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", + "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", + "skills.load.watch": "Watch Skills", + "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", + "agents.defaults.workspace": "Workspace", + "agents.defaults.repoRoot": "Repo Root", + "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", + "agents.defaults.envelopeTimezone": "Envelope Timezone", + "agents.defaults.envelopeTimestamp": "Envelope Timestamp", + "agents.defaults.envelopeElapsed": "Envelope Elapsed", + "agents.defaults.memorySearch": "Memory Search", + "agents.defaults.memorySearch.enabled": "Enable Memory Search", + "agents.defaults.memorySearch.sources": "Memory Search Sources", + "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Memory Search Session Index (Experimental)", + "agents.defaults.memorySearch.provider": "Memory Search Provider", + "agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL", + "agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key", + "agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers", + "agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency", + "agents.defaults.memorySearch.model": "Memory Search Model", + "agents.defaults.memorySearch.fallback": "Memory Search Fallback", + "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", + "agents.defaults.memorySearch.store.path": "Memory Search Index Path", + "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", + "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", + "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", + "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", + "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", + "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", + "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", + "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", + "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", + "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", + "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", + "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Memory Search Hybrid Candidate Multiplier", + "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", + "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", + memory: "Memory", + "memory.backend": "Memory Backend", + "memory.citations": "Memory Citations Mode", + "memory.qmd.command": "QMD Binary", + "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", + "memory.qmd.paths": "QMD Extra Paths", + "memory.qmd.paths.path": "QMD Path", + "memory.qmd.paths.pattern": "QMD Path Pattern", + "memory.qmd.paths.name": "QMD Path Name", + "memory.qmd.sessions.enabled": "QMD Session Indexing", + "memory.qmd.sessions.exportDir": "QMD Session Export Directory", + "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", + "memory.qmd.update.interval": "QMD Update Interval", + "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", + "memory.qmd.update.onBoot": "QMD Update on Startup", + "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", + "memory.qmd.update.embedInterval": "QMD Embed Interval", + "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", + "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", + "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", + "memory.qmd.limits.maxResults": "QMD Max Results", + "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", + "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", + "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", + "memory.qmd.scope": "QMD Surface Scope", + "auth.profiles": "Auth Profiles", + "auth.order": "Auth Profile Order", + "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", + "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", + "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", + "auth.cooldowns.failureWindowHours": "Failover Window (hours)", + "agents.defaults.models": "Models", + "agents.defaults.model.primary": "Primary Model", + "agents.defaults.model.fallbacks": "Model Fallbacks", + "agents.defaults.imageModel.primary": "Image Model", + "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", + "agents.defaults.humanDelay.mode": "Human Delay Mode", + "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", + "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", + "agents.defaults.cliBackends": "CLI Backends", + "commands.native": "Native Commands", + "commands.nativeSkills": "Native Skill Commands", + "commands.text": "Text Commands", + "commands.bash": "Allow Bash Chat Command", + "commands.bashForegroundMs": "Bash Foreground Window (ms)", + "commands.config": "Allow /config", + "commands.debug": "Allow /debug", + "commands.restart": "Allow Restart", + "commands.useAccessGroups": "Use Access Groups", + "commands.ownerAllowFrom": "Command Owners", + "ui.seamColor": "Accent Color", + "ui.assistant.name": "Assistant Name", + "ui.assistant.avatar": "Assistant Avatar", + "browser.evaluateEnabled": "Browser Evaluate Enabled", + "browser.snapshotDefaults": "Browser Snapshot Defaults", + "browser.snapshotDefaults.mode": "Browser Snapshot Mode", + "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", + "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", + "session.dmScope": "DM Session Scope", + "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", + "messages.ackReaction": "Ack Reaction Emoji", + "messages.ackReactionScope": "Ack Reaction Scope", + "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", + "talk.apiKey": "Talk API Key", + "channels.whatsapp": "WhatsApp", + "channels.telegram": "Telegram", + "channels.telegram.customCommands": "Telegram Custom Commands", + "channels.discord": "Discord", + "channels.slack": "Slack", + "channels.mattermost": "Mattermost", + "channels.signal": "Signal", + "channels.imessage": "iMessage", + "channels.bluebubbles": "BlueBubbles", + "channels.msteams": "MS Teams", + ...IRC_FIELD_LABELS, + "channels.telegram.botToken": "Telegram Bot Token", + "channels.telegram.dmPolicy": "Telegram DM Policy", + "channels.telegram.streamMode": "Telegram Draft Stream Mode", + "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", + "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", + "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", + "channels.telegram.retry.attempts": "Telegram Retry Attempts", + "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", + "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", + "channels.telegram.retry.jitter": "Telegram Retry Jitter", + "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", + "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", + "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", + "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", + "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", + "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", + "channels.signal.dmPolicy": "Signal DM Policy", + "channels.imessage.dmPolicy": "iMessage DM Policy", + "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", + "channels.discord.dm.policy": "Discord DM Policy", + "channels.discord.retry.attempts": "Discord Retry Attempts", + "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", + "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", + "channels.discord.retry.jitter": "Discord Retry Jitter", + "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.intents.presence": "Discord Presence Intent", + "channels.discord.intents.guildMembers": "Discord Guild Members Intent", + "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", + "channels.discord.pluralkit.token": "Discord PluralKit Token", + "channels.slack.dm.policy": "Slack DM Policy", + "channels.slack.allowBots": "Slack Allow Bot Messages", + "channels.discord.token": "Discord Bot Token", + "channels.slack.botToken": "Slack Bot Token", + "channels.slack.appToken": "Slack App Token", + "channels.slack.userToken": "Slack User Token", + "channels.slack.userTokenReadOnly": "Slack User Token Read Only", + "channels.slack.thread.historyScope": "Slack Thread History Scope", + "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", + "channels.mattermost.botToken": "Mattermost Bot Token", + "channels.mattermost.baseUrl": "Mattermost Base URL", + "channels.mattermost.chatmode": "Mattermost Chat Mode", + "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", + "channels.mattermost.requireMention": "Mattermost Require Mention", + "channels.signal.account": "Signal Account", + "channels.imessage.cliPath": "iMessage CLI Path", + "agents.list[].skills": "Agent Skill Filter", + "agents.list[].identity.avatar": "Agent Avatar", + "discovery.mdns.mode": "mDNS Discovery Mode", + "plugins.enabled": "Enable Plugins", + "plugins.allow": "Plugin Allowlist", + "plugins.deny": "Plugin Denylist", + "plugins.load.paths": "Plugin Load Paths", + "plugins.slots": "Plugin Slots", + "plugins.slots.memory": "Memory Plugin", + "plugins.entries": "Plugin Entries", + "plugins.entries.*.enabled": "Plugin Enabled", + "plugins.entries.*.config": "Plugin Config", + "plugins.installs": "Plugin Install Records", + "plugins.installs.*.source": "Plugin Install Source", + "plugins.installs.*.spec": "Plugin Install Spec", + "plugins.installs.*.sourcePath": "Plugin Install Source Path", + "plugins.installs.*.installPath": "Plugin Install Path", + "plugins.installs.*.version": "Plugin Install Version", + "plugins.installs.*.installedAt": "Plugin Install Time", +}; + +const FIELD_HELP: Record = { + "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", + "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", + "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").', + "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", + "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", + "gateway.remote.tlsFingerprint": + "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", + "gateway.remote.sshTarget": + "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", + "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "agents.list.*.skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].skills": + "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", + "agents.list[].identity.avatar": + "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", + "discovery.mdns.mode": + 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', + "gateway.auth.token": + "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", + "gateway.auth.password": "Required for Tailscale funnel.", + "gateway.controlUi.basePath": + "Optional URL prefix where the Control UI is served (e.g. /openclaw).", + "gateway.controlUi.root": + "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", + "gateway.controlUi.allowedOrigins": + "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).", + "gateway.controlUi.allowInsecureAuth": + "Allow Control UI auth over insecure HTTP (token-only; not recommended).", + "gateway.controlUi.dangerouslyDisableDeviceAuth": + "DANGEROUS. Disable Control UI device identity checks (token/password only).", + "gateway.http.endpoints.chatCompletions.enabled": + "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", + "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', + "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", + "gateway.nodes.browser.mode": + 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', + "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", + "gateway.nodes.allowCommands": + "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", + "gateway.nodes.denyCommands": + "Commands to block even if present in node claims or default allowlist.", + "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", + "nodeHost.browserProxy.allowProfiles": + "Optional allowlist of browser profile names exposed via the node proxy.", + "diagnostics.flags": + 'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".', + "diagnostics.cacheTrace.enabled": + "Log cache trace snapshots for embedded agent runs (default: false).", + "diagnostics.cacheTrace.filePath": + "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", + "diagnostics.cacheTrace.includeMessages": + "Include full message payloads in trace output (default: true).", + "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", + "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", + "tools.exec.applyPatch.enabled": + "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", + "tools.exec.applyPatch.allowModels": + 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', + "tools.exec.notifyOnExit": + "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", + "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", + "tools.exec.safeBins": + "Allow stdin-only safe binaries to run without explicit allowlist entries.", + "tools.message.allowCrossContextSend": + "Legacy override: allow cross-context sends across all providers.", + "tools.message.crossContext.allowWithinProvider": + "Allow sends to other channels within the same provider (default: true).", + "tools.message.crossContext.allowAcrossProviders": + "Allow sends across different providers (default: false).", + "tools.message.crossContext.marker.enabled": + "Add a visible origin marker when sending cross-context (default: true).", + "tools.message.crossContext.marker.prefix": + 'Text prefix for cross-context markers (supports "{channel}").', + "tools.message.crossContext.marker.suffix": + 'Text suffix for cross-context markers (supports "{channel}").', + "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", + "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", + "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', + "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", + "tools.web.search.maxResults": "Default number of results to return (1-10).", + "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", + "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", + "tools.web.search.perplexity.apiKey": + "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", + "tools.web.search.perplexity.baseUrl": + "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", + "tools.web.search.perplexity.model": + 'Perplexity model override (default: "perplexity/sonar-pro").', + "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", + "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", + "tools.web.fetch.maxCharsCap": + "Hard cap for web_fetch maxChars (applies to config and tool calls).", + "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", + "tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.", + "tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).", + "tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.", + "tools.web.fetch.readability": + "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", + "tools.web.fetch.firecrawl.enabled": "Enable Firecrawl fallback for web_fetch (if configured).", + "tools.web.fetch.firecrawl.apiKey": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", + "tools.web.fetch.firecrawl.baseUrl": + "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", + "tools.web.fetch.firecrawl.onlyMainContent": + "When true, Firecrawl returns only the main content (default: true).", + "tools.web.fetch.firecrawl.maxAgeMs": + "Firecrawl maxAge (ms) for cached results when supported by the API.", + "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", + "channels.slack.allowBots": + "Allow bot-authored messages to trigger Slack replies (default: false).", + "channels.slack.thread.historyScope": + 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', + "channels.slack.thread.inheritParent": + "If true, Slack thread sessions inherit the parent channel transcript (default: false).", + "channels.mattermost.botToken": + "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", + "channels.mattermost.baseUrl": + "Base URL for your Mattermost server (e.g., https://chat.example.com).", + "channels.mattermost.chatmode": + 'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").', + "channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).', + "channels.mattermost.requireMention": + "Require @mention in channels before responding (default: true).", + "auth.profiles": "Named auth profiles (provider + mode + optional email).", + "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", + "auth.cooldowns.billingBackoffHours": + "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", + "auth.cooldowns.billingBackoffHoursByProvider": + "Optional per-provider overrides for billing backoff (hours).", + "auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).", + "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", + "agents.defaults.bootstrapMaxChars": + "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "agents.defaults.repoRoot": + "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", + "agents.defaults.envelopeTimezone": + 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', + "agents.defaults.envelopeTimestamp": + 'Include absolute timestamps in message envelopes ("on" or "off").', + "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', + "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", + "agents.defaults.memorySearch": + "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", + "agents.defaults.memorySearch.sources": + 'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).', + "agents.defaults.memorySearch.extraPaths": + "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", + "agents.defaults.memorySearch.experimental.sessionMemory": + "Enable experimental session transcript indexing for memory search (default: false).", + "agents.defaults.memorySearch.provider": + 'Embedding provider ("openai", "gemini", "voyage", or "local").', + "agents.defaults.memorySearch.remote.baseUrl": + "Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).", + "agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.", + "agents.defaults.memorySearch.remote.headers": + "Extra headers for remote embeddings (merged; remote overrides OpenAI headers).", + "agents.defaults.memorySearch.remote.batch.enabled": + "Enable batch API for memory embeddings (OpenAI/Gemini; default: true).", + "agents.defaults.memorySearch.remote.batch.wait": + "Wait for batch completion when indexing (default: true).", + "agents.defaults.memorySearch.remote.batch.concurrency": + "Max concurrent embedding batch jobs for memory indexing (default: 2).", + "agents.defaults.memorySearch.remote.batch.pollIntervalMs": + "Polling interval in ms for batch status (default: 2000).", + "agents.defaults.memorySearch.remote.batch.timeoutMinutes": + "Timeout in minutes for batch indexing (default: 60).", + "agents.defaults.memorySearch.local.modelPath": + "Local GGUF model path or hf: URI (node-llama-cpp).", + "agents.defaults.memorySearch.fallback": + 'Fallback provider when embeddings fail ("openai", "gemini", "local", or "none").', + "agents.defaults.memorySearch.store.path": + "SQLite index path (default: ~/.openclaw/memory/{agentId}.sqlite).", + "agents.defaults.memorySearch.store.vector.enabled": + "Enable sqlite-vec extension for vector search (default: true).", + "agents.defaults.memorySearch.store.vector.extensionPath": + "Optional override path to sqlite-vec extension library (.dylib/.so/.dll).", + "agents.defaults.memorySearch.query.hybrid.enabled": + "Enable hybrid BM25 + vector search for memory (default: true).", + "agents.defaults.memorySearch.query.hybrid.vectorWeight": + "Weight for vector similarity when merging results (0-1).", + "agents.defaults.memorySearch.query.hybrid.textWeight": + "Weight for BM25 text relevance when merging results (0-1).", + "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": + "Multiplier for candidate pool size (default: 4).", + "agents.defaults.memorySearch.cache.enabled": + "Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).", + memory: "Memory backend configuration (global).", + "memory.backend": 'Memory backend ("builtin" for OpenClaw embeddings, "qmd" for QMD sidecar).', + "memory.citations": 'Default citation behavior ("auto", "on", or "off").', + "memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).", + "memory.qmd.includeDefaultMemory": + "Whether to automatically index MEMORY.md + memory/**/*.md (default: true).", + "memory.qmd.paths": + "Additional directories/files to index with QMD (path + optional glob pattern).", + "memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.", + "memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).", + "memory.qmd.paths.name": + "Optional stable name for the QMD collection (default derived from path).", + "memory.qmd.sessions.enabled": + "Enable QMD session transcript indexing (experimental, default: false).", + "memory.qmd.sessions.exportDir": + "Override directory for sanitized session exports before indexing.", + "memory.qmd.sessions.retentionDays": + "Retention window for exported sessions before pruning (default: unlimited).", + "memory.qmd.update.interval": + "How often the QMD sidecar refreshes indexes (duration string, default: 5m).", + "memory.qmd.update.debounceMs": + "Minimum delay between successive QMD refresh runs (default: 15000).", + "memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).", + "memory.qmd.update.waitForBootSync": + "Block startup until the boot QMD refresh finishes (default: false).", + "memory.qmd.update.embedInterval": + "How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.", + "memory.qmd.update.commandTimeoutMs": + "Timeout for QMD maintenance commands like collection list/add (default: 30000).", + "memory.qmd.update.updateTimeoutMs": "Timeout for `qmd update` runs (default: 120000).", + "memory.qmd.update.embedTimeoutMs": "Timeout for `qmd embed` runs (default: 120000).", + "memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).", + "memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).", + "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", + "memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).", + "memory.qmd.scope": + "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).", + "agents.defaults.memorySearch.cache.maxEntries": + "Optional cap on cached embeddings (best-effort).", + "agents.defaults.memorySearch.sync.onSearch": + "Lazy sync: schedule a reindex on search after changes.", + "agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).", + "agents.defaults.memorySearch.sync.sessions.deltaBytes": + "Minimum appended bytes before session transcripts trigger reindex (default: 100000).", + "agents.defaults.memorySearch.sync.sessions.deltaMessages": + "Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).", + "plugins.enabled": "Enable plugin/extension loading (default: true).", + "plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.", + "plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.", + "plugins.load.paths": "Additional plugin files or directories to load.", + "plugins.slots": "Select which plugins own exclusive slots (memory, etc.).", + "plugins.slots.memory": + 'Select the active memory plugin by id, or "none" to disable memory plugins.', + "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", + "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", + "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", + "plugins.installs": + "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", + "plugins.installs.*.source": 'Install source ("npm", "archive", or "path").', + "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", + "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", + "plugins.installs.*.installPath": + "Resolved install directory (usually ~/.openclaw/extensions/).", + "plugins.installs.*.version": "Version recorded at install time (if available).", + "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", + "agents.list.*.identity.avatar": + "Agent avatar (workspace-relative path, http(s) URL, or data URI).", + "agents.defaults.model.primary": "Primary model (provider/model).", + "agents.defaults.model.fallbacks": + "Ordered fallback models (provider/model). Used when the primary model fails.", + "agents.defaults.imageModel.primary": + "Optional image model (provider/model) used when the primary model lacks image input.", + "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", + "agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).", + "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', + "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", + "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", + "commands.native": + "Register native commands with channels that support it (Discord/Slack/Telegram).", + "commands.nativeSkills": + "Register native skill commands (user-invocable skills) with channels that support it.", + "commands.text": "Allow text command parsing (slash commands only).", + "commands.bash": + "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", + "commands.bashForegroundMs": + "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", + "commands.config": "Allow /config chat command to read/write config on disk (default: false).", + "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", + "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", + "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", + "commands.ownerAllowFrom": + "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", + "session.dmScope": + 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', + "session.identityLinks": + "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", + "channels.telegram.configWrites": + "Allow Telegram to write config in response to channel events/commands (default: true).", + "channels.slack.configWrites": + "Allow Slack to write config in response to channel events/commands (default: true).", + "channels.mattermost.configWrites": + "Allow Mattermost to write config in response to channel events/commands (default: true).", + "channels.discord.configWrites": + "Allow Discord to write config in response to channel events/commands (default: true).", + "channels.whatsapp.configWrites": + "Allow WhatsApp to write config in response to channel events/commands (default: true).", + "channels.signal.configWrites": + "Allow Signal to write config in response to channel events/commands (default: true).", + "channels.imessage.configWrites": + "Allow iMessage to write config in response to channel events/commands (default: true).", + "channels.msteams.configWrites": + "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", + ...IRC_FIELD_HELP, + "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', + "channels.discord.commands.nativeSkills": + 'Override native skill commands for Discord (bool or "auto").', + "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', + "channels.telegram.commands.nativeSkills": + 'Override native skill commands for Telegram (bool or "auto").', + "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', + "channels.slack.commands.nativeSkills": + 'Override native skill commands for Slack (bool or "auto").', + "session.agentToAgent.maxPingPongTurns": + "Max reply-back turns between requester and target (0–5).", + "channels.telegram.customCommands": + "Additional Telegram bot menu commands (merged with native; conflicts ignored).", + "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", + "messages.ackReactionScope": + 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', + "messages.inbound.debounceMs": + "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", + "channels.telegram.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', + "channels.telegram.streamMode": + "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", + "channels.telegram.draftChunk.minChars": + 'Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode="block" (default: 200).', + "channels.telegram.draftChunk.maxChars": + 'Target max size for a Telegram draft update chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).', + "channels.telegram.draftChunk.breakPreference": + "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", + "channels.telegram.retry.attempts": + "Max retry attempts for outbound Telegram API calls (default: 3).", + "channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.", + "channels.telegram.retry.maxDelayMs": + "Maximum retry delay cap in ms for Telegram outbound calls.", + "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", + "channels.telegram.network.autoSelectFamily": + "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", + "channels.telegram.timeoutSeconds": + "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", + "channels.whatsapp.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', + "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", + "channels.whatsapp.debounceMs": + "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", + "channels.signal.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', + "channels.imessage.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', + "channels.bluebubbles.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', + "channels.discord.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', + "channels.discord.retry.attempts": + "Max retry attempts for outbound Discord API calls (default: 3).", + "channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.", + "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", + "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", + "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.intents.presence": + "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", + "channels.discord.intents.guildMembers": + "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", + "channels.discord.pluralkit.enabled": + "Resolve PluralKit proxied messages and treat system members as distinct senders.", + "channels.discord.pluralkit.token": + "Optional PluralKit token for resolving private systems or members.", + "channels.slack.dm.policy": + 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', +}; + +const FIELD_PLACEHOLDERS: Record = { + "gateway.remote.url": "ws://host:18789", + "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", + "gateway.remote.sshTarget": "user@host", + "gateway.controlUi.basePath": "/openclaw", + "gateway.controlUi.root": "dist/control-ui", + "gateway.controlUi.allowedOrigins": "https://control.example.com", + "channels.mattermost.baseUrl": "https://chat.example.com", + "agents.list[].identity.avatar": "avatars/openclaw.png", +}; + +const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; + +function isSensitiveConfigPath(path: string): boolean { + return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); +} + +export function buildBaseHints(): ConfigUiHints { + const hints: ConfigUiHints = {}; + for (const [group, label] of Object.entries(GROUP_LABELS)) { + hints[group] = { + label, + group: label, + order: GROUP_ORDER[group], + }; + } + for (const [path, label] of Object.entries(FIELD_LABELS)) { + const current = hints[path]; + hints[path] = current ? { ...current, label } : { label }; + } + for (const [path, help] of Object.entries(FIELD_HELP)) { + const current = hints[path]; + hints[path] = current ? { ...current, help } : { help }; + } + for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) { + const current = hints[path]; + hints[path] = current ? { ...current, placeholder } : { placeholder }; + } + return hints; +} + +export function applySensitiveHints(hints: ConfigUiHints): ConfigUiHints { + const next = { ...hints }; + for (const key of Object.keys(next)) { + if (isSensitiveConfigPath(key)) { + next[key] = { ...next[key], sensitive: true }; + } + } + return next; +} diff --git a/src/config/schema.irc.ts b/src/config/schema.irc.ts new file mode 100644 index 00000000000..2847276a09b --- /dev/null +++ b/src/config/schema.irc.ts @@ -0,0 +1,26 @@ +export const IRC_FIELD_LABELS: Record = { + "channels.irc": "IRC", + "channels.irc.dmPolicy": "IRC DM Policy", + "channels.irc.nickserv.enabled": "IRC NickServ Enabled", + "channels.irc.nickserv.service": "IRC NickServ Service", + "channels.irc.nickserv.password": "IRC NickServ Password", + "channels.irc.nickserv.passwordFile": "IRC NickServ Password File", + "channels.irc.nickserv.register": "IRC NickServ Register", + "channels.irc.nickserv.registerEmail": "IRC NickServ Register Email", +}; + +export const IRC_FIELD_HELP: Record = { + "channels.irc.configWrites": + "Allow IRC to write config in response to channel events/commands (default: true).", + "channels.irc.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.irc.allowFrom=["*"].', + "channels.irc.nickserv.enabled": + "Enable NickServ identify/register after connect (defaults to enabled when password is configured).", + "channels.irc.nickserv.service": "NickServ service nick (default: NickServ).", + "channels.irc.nickserv.password": "NickServ password used for IDENTIFY/REGISTER (sensitive).", + "channels.irc.nickserv.passwordFile": "Optional file path containing NickServ password.", + "channels.irc.nickserv.register": + "If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.", + "channels.irc.nickserv.registerEmail": + "Email used with NickServ REGISTER (required when register=true).", +}; diff --git a/src/config/schema.ts b/src/config/schema.ts index 0fd9909faf7..4160403b8d7 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,19 +1,10 @@ +import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; import { CHANNEL_IDS } from "../channels/registry.js"; import { VERSION } from "../version.js"; +import { applySensitiveHints, buildBaseHints } from "./schema.hints.js"; import { OpenClawSchema } from "./zod-schema.js"; -export type ConfigUiHint = { - label?: string; - help?: string; - group?: string; - order?: number; - advanced?: boolean; - sensitive?: boolean; - placeholder?: string; - itemTemplate?: unknown; -}; - -export type ConfigUiHints = Record; +export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js"; export type ConfigSchema = ReturnType; @@ -45,745 +36,6 @@ export type ChannelUiMetadata = { configUiHints?: Record; }; -const GROUP_LABELS: Record = { - wizard: "Wizard", - update: "Update", - diagnostics: "Diagnostics", - logging: "Logging", - gateway: "Gateway", - nodeHost: "Node Host", - agents: "Agents", - tools: "Tools", - bindings: "Bindings", - audio: "Audio", - models: "Models", - messages: "Messages", - commands: "Commands", - session: "Session", - cron: "Cron", - hooks: "Hooks", - ui: "UI", - browser: "Browser", - talk: "Talk", - channels: "Messaging Channels", - skills: "Skills", - plugins: "Plugins", - discovery: "Discovery", - presence: "Presence", - voicewake: "Voice Wake", -}; - -const GROUP_ORDER: Record = { - wizard: 20, - update: 25, - diagnostics: 27, - gateway: 30, - nodeHost: 35, - agents: 40, - tools: 50, - bindings: 55, - audio: 60, - models: 70, - messages: 80, - commands: 85, - session: 90, - cron: 100, - hooks: 110, - ui: 120, - browser: 130, - talk: 140, - channels: 150, - skills: 200, - plugins: 205, - discovery: 210, - presence: 220, - voicewake: 230, - logging: 900, -}; - -const FIELD_LABELS: Record = { - "meta.lastTouchedVersion": "Config Last Touched Version", - "meta.lastTouchedAt": "Config Last Touched At", - "update.channel": "Update Channel", - "update.checkOnStart": "Update Check on Start", - "diagnostics.enabled": "Diagnostics Enabled", - "diagnostics.flags": "Diagnostics Flags", - "diagnostics.otel.enabled": "OpenTelemetry Enabled", - "diagnostics.otel.endpoint": "OpenTelemetry Endpoint", - "diagnostics.otel.protocol": "OpenTelemetry Protocol", - "diagnostics.otel.headers": "OpenTelemetry Headers", - "diagnostics.otel.serviceName": "OpenTelemetry Service Name", - "diagnostics.otel.traces": "OpenTelemetry Traces Enabled", - "diagnostics.otel.metrics": "OpenTelemetry Metrics Enabled", - "diagnostics.otel.logs": "OpenTelemetry Logs Enabled", - "diagnostics.otel.sampleRate": "OpenTelemetry Trace Sample Rate", - "diagnostics.otel.flushIntervalMs": "OpenTelemetry Flush Interval (ms)", - "diagnostics.cacheTrace.enabled": "Cache Trace Enabled", - "diagnostics.cacheTrace.filePath": "Cache Trace File Path", - "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", - "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", - "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", - "agents.list.*.identity.avatar": "Identity Avatar", - "agents.list.*.skills": "Agent Skill Filter", - "gateway.remote.url": "Remote Gateway URL", - "gateway.remote.sshTarget": "Remote Gateway SSH Target", - "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", - "gateway.remote.token": "Remote Gateway Token", - "gateway.remote.password": "Remote Gateway Password", - "gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint", - "gateway.auth.token": "Gateway Token", - "gateway.auth.password": "Gateway Password", - "tools.media.image.enabled": "Enable Image Understanding", - "tools.media.image.maxBytes": "Image Understanding Max Bytes", - "tools.media.image.maxChars": "Image Understanding Max Chars", - "tools.media.image.prompt": "Image Understanding Prompt", - "tools.media.image.timeoutSeconds": "Image Understanding Timeout (sec)", - "tools.media.image.attachments": "Image Understanding Attachment Policy", - "tools.media.image.models": "Image Understanding Models", - "tools.media.image.scope": "Image Understanding Scope", - "tools.media.models": "Media Understanding Shared Models", - "tools.media.concurrency": "Media Understanding Concurrency", - "tools.media.audio.enabled": "Enable Audio Understanding", - "tools.media.audio.maxBytes": "Audio Understanding Max Bytes", - "tools.media.audio.maxChars": "Audio Understanding Max Chars", - "tools.media.audio.prompt": "Audio Understanding Prompt", - "tools.media.audio.timeoutSeconds": "Audio Understanding Timeout (sec)", - "tools.media.audio.language": "Audio Understanding Language", - "tools.media.audio.attachments": "Audio Understanding Attachment Policy", - "tools.media.audio.models": "Audio Understanding Models", - "tools.media.audio.scope": "Audio Understanding Scope", - "tools.media.video.enabled": "Enable Video Understanding", - "tools.media.video.maxBytes": "Video Understanding Max Bytes", - "tools.media.video.maxChars": "Video Understanding Max Chars", - "tools.media.video.prompt": "Video Understanding Prompt", - "tools.media.video.timeoutSeconds": "Video Understanding Timeout (sec)", - "tools.media.video.attachments": "Video Understanding Attachment Policy", - "tools.media.video.models": "Video Understanding Models", - "tools.media.video.scope": "Video Understanding Scope", - "tools.links.enabled": "Enable Link Understanding", - "tools.links.maxLinks": "Link Understanding Max Links", - "tools.links.timeoutSeconds": "Link Understanding Timeout (sec)", - "tools.links.models": "Link Understanding Models", - "tools.links.scope": "Link Understanding Scope", - "tools.profile": "Tool Profile", - "tools.alsoAllow": "Tool Allowlist Additions", - "agents.list[].tools.profile": "Agent Tool Profile", - "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions", - "tools.byProvider": "Tool Policy by Provider", - "agents.list[].tools.byProvider": "Agent Tool Policy by Provider", - "tools.exec.applyPatch.enabled": "Enable apply_patch", - "tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist", - "tools.exec.notifyOnExit": "Exec Notify On Exit", - "tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)", - "tools.exec.host": "Exec Host", - "tools.exec.security": "Exec Security", - "tools.exec.ask": "Exec Ask", - "tools.exec.node": "Exec Node Binding", - "tools.exec.pathPrepend": "Exec PATH Prepend", - "tools.exec.safeBins": "Exec Safe Bins", - "tools.message.allowCrossContextSend": "Allow Cross-Context Messaging", - "tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)", - "tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)", - "tools.message.crossContext.marker.enabled": "Cross-Context Marker", - "tools.message.crossContext.marker.prefix": "Cross-Context Marker Prefix", - "tools.message.crossContext.marker.suffix": "Cross-Context Marker Suffix", - "tools.message.broadcast.enabled": "Enable Message Broadcast", - "tools.web.search.enabled": "Enable Web Search Tool", - "tools.web.search.provider": "Web Search Provider", - "tools.web.search.apiKey": "Brave Search API Key", - "tools.web.search.maxResults": "Web Search Max Results", - "tools.web.search.timeoutSeconds": "Web Search Timeout (sec)", - "tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)", - "tools.web.fetch.enabled": "Enable Web Fetch Tool", - "tools.web.fetch.maxChars": "Web Fetch Max Chars", - "tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)", - "tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)", - "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", - "tools.web.fetch.userAgent": "Web Fetch User-Agent", - "gateway.controlUi.basePath": "Control UI Base Path", - "gateway.controlUi.root": "Control UI Assets Root", - "gateway.controlUi.allowedOrigins": "Control UI Allowed Origins", - "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", - "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", - "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", - "gateway.reload.mode": "Config Reload Mode", - "gateway.reload.debounceMs": "Config Reload Debounce (ms)", - "gateway.nodes.browser.mode": "Gateway Node Browser Mode", - "gateway.nodes.browser.node": "Gateway Node Browser Pin", - "gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)", - "gateway.nodes.denyCommands": "Gateway Node Denylist", - "nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled", - "nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles", - "skills.load.watch": "Watch Skills", - "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", - "agents.defaults.workspace": "Workspace", - "agents.defaults.repoRoot": "Repo Root", - "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", - "agents.defaults.envelopeTimezone": "Envelope Timezone", - "agents.defaults.envelopeTimestamp": "Envelope Timestamp", - "agents.defaults.envelopeElapsed": "Envelope Elapsed", - "agents.defaults.memorySearch": "Memory Search", - "agents.defaults.memorySearch.enabled": "Enable Memory Search", - "agents.defaults.memorySearch.sources": "Memory Search Sources", - "agents.defaults.memorySearch.extraPaths": "Extra Memory Paths", - "agents.defaults.memorySearch.experimental.sessionMemory": - "Memory Search Session Index (Experimental)", - "agents.defaults.memorySearch.provider": "Memory Search Provider", - "agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL", - "agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key", - "agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers", - "agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency", - "agents.defaults.memorySearch.model": "Memory Search Model", - "agents.defaults.memorySearch.fallback": "Memory Search Fallback", - "agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path", - "agents.defaults.memorySearch.store.path": "Memory Search Index Path", - "agents.defaults.memorySearch.store.vector.enabled": "Memory Search Vector Index", - "agents.defaults.memorySearch.store.vector.extensionPath": "Memory Search Vector Extension Path", - "agents.defaults.memorySearch.chunking.tokens": "Memory Chunk Tokens", - "agents.defaults.memorySearch.chunking.overlap": "Memory Chunk Overlap Tokens", - "agents.defaults.memorySearch.sync.onSessionStart": "Index on Session Start", - "agents.defaults.memorySearch.sync.onSearch": "Index on Search (Lazy)", - "agents.defaults.memorySearch.sync.watch": "Watch Memory Files", - "agents.defaults.memorySearch.sync.watchDebounceMs": "Memory Watch Debounce (ms)", - "agents.defaults.memorySearch.sync.sessions.deltaBytes": "Session Delta Bytes", - "agents.defaults.memorySearch.sync.sessions.deltaMessages": "Session Delta Messages", - "agents.defaults.memorySearch.query.maxResults": "Memory Search Max Results", - "agents.defaults.memorySearch.query.minScore": "Memory Search Min Score", - "agents.defaults.memorySearch.query.hybrid.enabled": "Memory Search Hybrid", - "agents.defaults.memorySearch.query.hybrid.vectorWeight": "Memory Search Vector Weight", - "agents.defaults.memorySearch.query.hybrid.textWeight": "Memory Search Text Weight", - "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": - "Memory Search Hybrid Candidate Multiplier", - "agents.defaults.memorySearch.cache.enabled": "Memory Search Embedding Cache", - "agents.defaults.memorySearch.cache.maxEntries": "Memory Search Embedding Cache Max Entries", - memory: "Memory", - "memory.backend": "Memory Backend", - "memory.citations": "Memory Citations Mode", - "memory.qmd.command": "QMD Binary", - "memory.qmd.includeDefaultMemory": "QMD Include Default Memory", - "memory.qmd.paths": "QMD Extra Paths", - "memory.qmd.paths.path": "QMD Path", - "memory.qmd.paths.pattern": "QMD Path Pattern", - "memory.qmd.paths.name": "QMD Path Name", - "memory.qmd.sessions.enabled": "QMD Session Indexing", - "memory.qmd.sessions.exportDir": "QMD Session Export Directory", - "memory.qmd.sessions.retentionDays": "QMD Session Retention (days)", - "memory.qmd.update.interval": "QMD Update Interval", - "memory.qmd.update.debounceMs": "QMD Update Debounce (ms)", - "memory.qmd.update.onBoot": "QMD Update on Startup", - "memory.qmd.update.waitForBootSync": "QMD Wait for Boot Sync", - "memory.qmd.update.embedInterval": "QMD Embed Interval", - "memory.qmd.update.commandTimeoutMs": "QMD Command Timeout (ms)", - "memory.qmd.update.updateTimeoutMs": "QMD Update Timeout (ms)", - "memory.qmd.update.embedTimeoutMs": "QMD Embed Timeout (ms)", - "memory.qmd.limits.maxResults": "QMD Max Results", - "memory.qmd.limits.maxSnippetChars": "QMD Max Snippet Chars", - "memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars", - "memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)", - "memory.qmd.scope": "QMD Surface Scope", - "auth.profiles": "Auth Profiles", - "auth.order": "Auth Profile Order", - "auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)", - "auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides", - "auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)", - "auth.cooldowns.failureWindowHours": "Failover Window (hours)", - "agents.defaults.models": "Models", - "agents.defaults.model.primary": "Primary Model", - "agents.defaults.model.fallbacks": "Model Fallbacks", - "agents.defaults.imageModel.primary": "Image Model", - "agents.defaults.imageModel.fallbacks": "Image Model Fallbacks", - "agents.defaults.humanDelay.mode": "Human Delay Mode", - "agents.defaults.humanDelay.minMs": "Human Delay Min (ms)", - "agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)", - "agents.defaults.cliBackends": "CLI Backends", - "commands.native": "Native Commands", - "commands.nativeSkills": "Native Skill Commands", - "commands.text": "Text Commands", - "commands.bash": "Allow Bash Chat Command", - "commands.bashForegroundMs": "Bash Foreground Window (ms)", - "commands.config": "Allow /config", - "commands.debug": "Allow /debug", - "commands.restart": "Allow Restart", - "commands.useAccessGroups": "Use Access Groups", - "commands.ownerAllowFrom": "Command Owners", - "commands.allowFrom": "Command Access Allowlist", - "ui.seamColor": "Accent Color", - "ui.assistant.name": "Assistant Name", - "ui.assistant.avatar": "Assistant Avatar", - "browser.evaluateEnabled": "Browser Evaluate Enabled", - "browser.snapshotDefaults": "Browser Snapshot Defaults", - "browser.snapshotDefaults.mode": "Browser Snapshot Mode", - "browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)", - "browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)", - "session.dmScope": "DM Session Scope", - "session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns", - "messages.ackReaction": "Ack Reaction Emoji", - "messages.ackReactionScope": "Ack Reaction Scope", - "messages.inbound.debounceMs": "Inbound Message Debounce (ms)", - "talk.apiKey": "Talk API Key", - "channels.whatsapp": "WhatsApp", - "channels.telegram": "Telegram", - "channels.telegram.customCommands": "Telegram Custom Commands", - "channels.discord": "Discord", - "channels.slack": "Slack", - "channels.mattermost": "Mattermost", - "channels.signal": "Signal", - "channels.imessage": "iMessage", - "channels.bluebubbles": "BlueBubbles", - "channels.msteams": "MS Teams", - "channels.telegram.botToken": "Telegram Bot Token", - "channels.telegram.dmPolicy": "Telegram DM Policy", - "channels.telegram.streamMode": "Telegram Draft Stream Mode", - "channels.telegram.draftChunk.minChars": "Telegram Draft Chunk Min Chars", - "channels.telegram.draftChunk.maxChars": "Telegram Draft Chunk Max Chars", - "channels.telegram.draftChunk.breakPreference": "Telegram Draft Chunk Break Preference", - "channels.telegram.retry.attempts": "Telegram Retry Attempts", - "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", - "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", - "channels.telegram.retry.jitter": "Telegram Retry Jitter", - "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", - "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", - "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", - "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", - "channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode", - "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", - "channels.signal.dmPolicy": "Signal DM Policy", - "channels.imessage.dmPolicy": "iMessage DM Policy", - "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", - "channels.discord.dm.policy": "Discord DM Policy", - "channels.discord.retry.attempts": "Discord Retry Attempts", - "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", - "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", - "channels.discord.retry.jitter": "Discord Retry Jitter", - "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", - "channels.discord.intents.presence": "Discord Presence Intent", - "channels.discord.intents.guildMembers": "Discord Guild Members Intent", - "channels.discord.pluralkit.enabled": "Discord PluralKit Enabled", - "channels.discord.pluralkit.token": "Discord PluralKit Token", - "channels.slack.dm.policy": "Slack DM Policy", - "channels.slack.allowBots": "Slack Allow Bot Messages", - "channels.discord.token": "Discord Bot Token", - "channels.slack.botToken": "Slack Bot Token", - "channels.slack.appToken": "Slack App Token", - "channels.slack.userToken": "Slack User Token", - "channels.slack.userTokenReadOnly": "Slack User Token Read Only", - "channels.slack.thread.historyScope": "Slack Thread History Scope", - "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", - "channels.mattermost.botToken": "Mattermost Bot Token", - "channels.mattermost.baseUrl": "Mattermost Base URL", - "channels.mattermost.chatmode": "Mattermost Chat Mode", - "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes", - "channels.mattermost.requireMention": "Mattermost Require Mention", - "channels.signal.account": "Signal Account", - "channels.imessage.cliPath": "iMessage CLI Path", - "agents.list[].skills": "Agent Skill Filter", - "agents.list[].identity.avatar": "Agent Avatar", - "discovery.mdns.mode": "mDNS Discovery Mode", - "plugins.enabled": "Enable Plugins", - "plugins.allow": "Plugin Allowlist", - "plugins.deny": "Plugin Denylist", - "plugins.load.paths": "Plugin Load Paths", - "plugins.slots": "Plugin Slots", - "plugins.slots.memory": "Memory Plugin", - "plugins.entries": "Plugin Entries", - "plugins.entries.*.enabled": "Plugin Enabled", - "plugins.entries.*.config": "Plugin Config", - "plugins.installs": "Plugin Install Records", - "plugins.installs.*.source": "Plugin Install Source", - "plugins.installs.*.spec": "Plugin Install Spec", - "plugins.installs.*.sourcePath": "Plugin Install Source Path", - "plugins.installs.*.installPath": "Plugin Install Path", - "plugins.installs.*.version": "Plugin Install Version", - "plugins.installs.*.installedAt": "Plugin Install Time", -}; - -const FIELD_HELP: Record = { - "meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.", - "meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).", - "update.channel": 'Update channel for git + npm installs ("stable", "beta", or "dev").', - "update.checkOnStart": "Check for npm updates when the gateway starts (default: true).", - "gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).", - "gateway.remote.tlsFingerprint": - "Expected sha256 TLS fingerprint for the remote gateway (pin to avoid MITM).", - "gateway.remote.sshTarget": - "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", - "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", - "agents.list.*.skills": - "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", - "agents.list[].skills": - "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", - "agents.list[].identity.avatar": - "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", - "discovery.mdns.mode": - 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', - "gateway.auth.token": - "Required by default for gateway access (unless using Tailscale Serve identity); required for non-loopback binds.", - "gateway.auth.password": "Required for Tailscale funnel.", - "gateway.controlUi.basePath": - "Optional URL prefix where the Control UI is served (e.g. /openclaw).", - "gateway.controlUi.root": - "Optional filesystem root for Control UI assets (defaults to dist/control-ui).", - "gateway.controlUi.allowedOrigins": - "Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com).", - "gateway.controlUi.allowInsecureAuth": - "Allow Control UI auth over insecure HTTP (token-only; not recommended).", - "gateway.controlUi.dangerouslyDisableDeviceAuth": - "DANGEROUS. Disable Control UI device identity checks (token/password only).", - "gateway.http.endpoints.chatCompletions.enabled": - "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", - "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', - "gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.", - "gateway.nodes.browser.mode": - 'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).', - "gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).", - "gateway.nodes.allowCommands": - "Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).", - "gateway.nodes.denyCommands": - "Commands to block even if present in node claims or default allowlist.", - "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", - "nodeHost.browserProxy.allowProfiles": - "Optional allowlist of browser profile names exposed via the node proxy.", - "diagnostics.flags": - 'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".', - "diagnostics.cacheTrace.enabled": - "Log cache trace snapshots for embedded agent runs (default: false).", - "diagnostics.cacheTrace.filePath": - "JSONL output path for cache trace logs (default: $OPENCLAW_STATE_DIR/logs/cache-trace.jsonl).", - "diagnostics.cacheTrace.includeMessages": - "Include full message payloads in trace output (default: true).", - "diagnostics.cacheTrace.includePrompt": "Include prompt text in trace output (default: true).", - "diagnostics.cacheTrace.includeSystem": "Include system prompt in trace output (default: true).", - "tools.exec.applyPatch.enabled": - "Experimental. Enables apply_patch for OpenAI models when allowed by tool policy.", - "tools.exec.applyPatch.allowModels": - 'Optional allowlist of model ids (e.g. "gpt-5.2" or "openai/gpt-5.2").', - "tools.exec.notifyOnExit": - "When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.", - "tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).", - "tools.exec.safeBins": - "Allow stdin-only safe binaries to run without explicit allowlist entries.", - "tools.message.allowCrossContextSend": - "Legacy override: allow cross-context sends across all providers.", - "tools.message.crossContext.allowWithinProvider": - "Allow sends to other channels within the same provider (default: true).", - "tools.message.crossContext.allowAcrossProviders": - "Allow sends across different providers (default: false).", - "tools.message.crossContext.marker.enabled": - "Add a visible origin marker when sending cross-context (default: true).", - "tools.message.crossContext.marker.prefix": - 'Text prefix for cross-context markers (supports "{channel}").', - "tools.message.crossContext.marker.suffix": - 'Text suffix for cross-context markers (supports "{channel}").', - "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", - "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", - "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', - "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", - "tools.web.search.maxResults": "Default number of results to return (1-10).", - "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", - "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", - "tools.web.search.perplexity.apiKey": - "Perplexity or OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var).", - "tools.web.search.perplexity.baseUrl": - "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", - "tools.web.search.perplexity.model": - 'Perplexity model override (default: "perplexity/sonar-pro").', - "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", - "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", - "tools.web.fetch.maxCharsCap": - "Hard cap for web_fetch maxChars (applies to config and tool calls).", - "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", - "tools.web.fetch.cacheTtlMinutes": "Cache TTL in minutes for web_fetch results.", - "tools.web.fetch.maxRedirects": "Maximum redirects allowed for web_fetch (default: 3).", - "tools.web.fetch.userAgent": "Override User-Agent header for web_fetch requests.", - "tools.web.fetch.readability": - "Use Readability to extract main content from HTML (fallbacks to basic HTML cleanup).", - "tools.web.fetch.firecrawl.enabled": "Enable Firecrawl fallback for web_fetch (if configured).", - "tools.web.fetch.firecrawl.apiKey": "Firecrawl API key (fallback: FIRECRAWL_API_KEY env var).", - "tools.web.fetch.firecrawl.baseUrl": - "Firecrawl base URL (e.g. https://api.firecrawl.dev or custom endpoint).", - "tools.web.fetch.firecrawl.onlyMainContent": - "When true, Firecrawl returns only the main content (default: true).", - "tools.web.fetch.firecrawl.maxAgeMs": - "Firecrawl maxAge (ms) for cached results when supported by the API.", - "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", - "channels.slack.allowBots": - "Allow bot-authored messages to trigger Slack replies (default: false).", - "channels.slack.thread.historyScope": - 'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).', - "channels.slack.thread.inheritParent": - "If true, Slack thread sessions inherit the parent channel transcript (default: false).", - "channels.mattermost.botToken": - "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.", - "channels.mattermost.baseUrl": - "Base URL for your Mattermost server (e.g., https://chat.example.com).", - "channels.mattermost.chatmode": - 'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").', - "channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).', - "channels.mattermost.requireMention": - "Require @mention in channels before responding (default: true).", - "auth.profiles": "Named auth profiles (provider + mode + optional email).", - "auth.order": "Ordered auth profile IDs per provider (used for automatic failover).", - "auth.cooldowns.billingBackoffHours": - "Base backoff (hours) when a profile fails due to billing/insufficient credits (default: 5).", - "auth.cooldowns.billingBackoffHoursByProvider": - "Optional per-provider overrides for billing backoff (hours).", - "auth.cooldowns.billingMaxHours": "Cap (hours) for billing backoff (default: 24).", - "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", - "agents.defaults.bootstrapMaxChars": - "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", - "agents.defaults.repoRoot": - "Optional repository root shown in the system prompt runtime line (overrides auto-detect).", - "agents.defaults.envelopeTimezone": - 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', - "agents.defaults.envelopeTimestamp": - 'Include absolute timestamps in message envelopes ("on" or "off").', - "agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").', - "agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).", - "agents.defaults.memorySearch": - "Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).", - "agents.defaults.memorySearch.sources": - 'Sources to index for memory search (default: ["memory"]; add "sessions" to include session transcripts).', - "agents.defaults.memorySearch.extraPaths": - "Extra paths to include in memory search (directories or .md files; relative paths resolved from workspace).", - "agents.defaults.memorySearch.experimental.sessionMemory": - "Enable experimental session transcript indexing for memory search (default: false).", - "agents.defaults.memorySearch.provider": - 'Embedding provider ("openai", "gemini", "voyage", or "local").', - "agents.defaults.memorySearch.remote.baseUrl": - "Custom base URL for remote embeddings (OpenAI-compatible proxies or Gemini overrides).", - "agents.defaults.memorySearch.remote.apiKey": "Custom API key for the remote embedding provider.", - "agents.defaults.memorySearch.remote.headers": - "Extra headers for remote embeddings (merged; remote overrides OpenAI headers).", - "agents.defaults.memorySearch.remote.batch.enabled": - "Enable batch API for memory embeddings (OpenAI/Gemini/Voyage; default: false).", - "agents.defaults.memorySearch.remote.batch.wait": - "Wait for batch completion when indexing (default: true).", - "agents.defaults.memorySearch.remote.batch.concurrency": - "Max concurrent embedding batch jobs for memory indexing (default: 2).", - "agents.defaults.memorySearch.remote.batch.pollIntervalMs": - "Polling interval in ms for batch status (default: 2000).", - "agents.defaults.memorySearch.remote.batch.timeoutMinutes": - "Timeout in minutes for batch indexing (default: 60).", - "agents.defaults.memorySearch.local.modelPath": - "Local GGUF model path or hf: URI (node-llama-cpp).", - "agents.defaults.memorySearch.fallback": - 'Fallback provider when embeddings fail ("openai", "gemini", "local", or "none").', - "agents.defaults.memorySearch.store.path": - "SQLite index path (default: ~/.openclaw/memory/{agentId}.sqlite).", - "agents.defaults.memorySearch.store.vector.enabled": - "Enable sqlite-vec extension for vector search (default: true).", - "agents.defaults.memorySearch.store.vector.extensionPath": - "Optional override path to sqlite-vec extension library (.dylib/.so/.dll).", - "agents.defaults.memorySearch.query.hybrid.enabled": - "Enable hybrid BM25 + vector search for memory (default: true).", - "agents.defaults.memorySearch.query.hybrid.vectorWeight": - "Weight for vector similarity when merging results (0-1).", - "agents.defaults.memorySearch.query.hybrid.textWeight": - "Weight for BM25 text relevance when merging results (0-1).", - "agents.defaults.memorySearch.query.hybrid.candidateMultiplier": - "Multiplier for candidate pool size (default: 4).", - "agents.defaults.memorySearch.cache.enabled": - "Cache chunk embeddings in SQLite to speed up reindexing and frequent updates (default: true).", - memory: "Memory backend configuration (global).", - "memory.backend": 'Memory backend ("builtin" for OpenClaw embeddings, "qmd" for QMD sidecar).', - "memory.citations": 'Default citation behavior ("auto", "on", or "off").', - "memory.qmd.command": "Path to the qmd binary (default: resolves from PATH).", - "memory.qmd.includeDefaultMemory": - "Whether to automatically index MEMORY.md + memory/**/*.md (default: true).", - "memory.qmd.paths": - "Additional directories/files to index with QMD (path + optional glob pattern).", - "memory.qmd.paths.path": "Absolute or ~-relative path to index via QMD.", - "memory.qmd.paths.pattern": "Glob pattern relative to the path root (default: **/*.md).", - "memory.qmd.paths.name": - "Optional stable name for the QMD collection (default derived from path).", - "memory.qmd.sessions.enabled": - "Enable QMD session transcript indexing (experimental, default: false).", - "memory.qmd.sessions.exportDir": - "Override directory for sanitized session exports before indexing.", - "memory.qmd.sessions.retentionDays": - "Retention window for exported sessions before pruning (default: unlimited).", - "memory.qmd.update.interval": - "How often the QMD sidecar refreshes indexes (duration string, default: 5m).", - "memory.qmd.update.debounceMs": - "Minimum delay between successive QMD refresh runs (default: 15000).", - "memory.qmd.update.onBoot": "Run QMD update once on gateway startup (default: true).", - "memory.qmd.update.waitForBootSync": - "Block startup until the boot QMD refresh finishes (default: false).", - "memory.qmd.update.embedInterval": - "How often QMD embeddings are refreshed (duration string, default: 60m). Set to 0 to disable periodic embed.", - "memory.qmd.update.commandTimeoutMs": - "Timeout for QMD maintenance commands like collection list/add (default: 30000).", - "memory.qmd.update.updateTimeoutMs": "Timeout for `qmd update` runs (default: 120000).", - "memory.qmd.update.embedTimeoutMs": "Timeout for `qmd embed` runs (default: 120000).", - "memory.qmd.limits.maxResults": "Max QMD results returned to the agent loop (default: 6).", - "memory.qmd.limits.maxSnippetChars": "Max characters per snippet pulled from QMD (default: 700).", - "memory.qmd.limits.maxInjectedChars": "Max total characters injected from QMD hits per turn.", - "memory.qmd.limits.timeoutMs": "Per-query timeout for QMD searches (default: 4000).", - "memory.qmd.scope": - "Session/channel scope for QMD recall (same syntax as session.sendPolicy; default: direct-only).", - "agents.defaults.memorySearch.cache.maxEntries": - "Optional cap on cached embeddings (best-effort).", - "agents.defaults.memorySearch.sync.onSearch": - "Lazy sync: schedule a reindex on search after changes.", - "agents.defaults.memorySearch.sync.watch": "Watch memory files for changes (chokidar).", - "agents.defaults.memorySearch.sync.sessions.deltaBytes": - "Minimum appended bytes before session transcripts trigger reindex (default: 100000).", - "agents.defaults.memorySearch.sync.sessions.deltaMessages": - "Minimum appended JSONL lines before session transcripts trigger reindex (default: 50).", - "plugins.enabled": "Enable plugin/extension loading (default: true).", - "plugins.allow": "Optional allowlist of plugin ids; when set, only listed plugins load.", - "plugins.deny": "Optional denylist of plugin ids; deny wins over allowlist.", - "plugins.load.paths": "Additional plugin files or directories to load.", - "plugins.slots": "Select which plugins own exclusive slots (memory, etc.).", - "plugins.slots.memory": - 'Select the active memory plugin by id, or "none" to disable memory plugins.', - "plugins.entries": "Per-plugin settings keyed by plugin id (enable/disable + config payloads).", - "plugins.entries.*.enabled": "Overrides plugin enable/disable for this entry (restart required).", - "plugins.entries.*.config": "Plugin-defined config payload (schema is provided by the plugin).", - "plugins.installs": - "CLI-managed install metadata (used by `openclaw plugins update` to locate install sources).", - "plugins.installs.*.source": 'Install source ("npm", "archive", or "path").', - "plugins.installs.*.spec": "Original npm spec used for install (if source is npm).", - "plugins.installs.*.sourcePath": "Original archive/path used for install (if any).", - "plugins.installs.*.installPath": - "Resolved install directory (usually ~/.openclaw/extensions/).", - "plugins.installs.*.version": "Version recorded at install time (if available).", - "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", - "agents.list.*.identity.avatar": - "Agent avatar (workspace-relative path, http(s) URL, or data URI).", - "agents.defaults.model.primary": "Primary model (provider/model).", - "agents.defaults.model.fallbacks": - "Ordered fallback models (provider/model). Used when the primary model fails.", - "agents.defaults.imageModel.primary": - "Optional image model (provider/model) used when the primary model lacks image input.", - "agents.defaults.imageModel.fallbacks": "Ordered fallback image models (provider/model).", - "agents.defaults.cliBackends": "Optional CLI backends for text-only fallback (claude-cli, etc.).", - "agents.defaults.humanDelay.mode": 'Delay style for block replies ("off", "natural", "custom").', - "agents.defaults.humanDelay.minMs": "Minimum delay in ms for custom humanDelay (default: 800).", - "agents.defaults.humanDelay.maxMs": "Maximum delay in ms for custom humanDelay (default: 2500).", - "commands.native": - "Register native commands with channels that support it (Discord/Slack/Telegram).", - "commands.nativeSkills": - "Register native skill commands (user-invocable skills) with channels that support it.", - "commands.text": "Allow text command parsing (slash commands only).", - "commands.bash": - "Allow bash chat command (`!`; `/bash` alias) to run host shell commands (default: false; requires tools.elevated).", - "commands.bashForegroundMs": - "How long bash waits before backgrounding (default: 2000; 0 backgrounds immediately).", - "commands.config": "Allow /config chat command to read/write config on disk (default: false).", - "commands.debug": "Allow /debug chat command for runtime-only overrides (default: false).", - "commands.restart": "Allow /restart and gateway restart tool actions (default: false).", - "commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.", - "commands.ownerAllowFrom": - "Explicit owner allowlist for owner-only tools/commands. Use channel-native IDs (optionally prefixed like \"whatsapp:+15551234567\"). '*' is ignored.", - "commands.allowFrom": - 'Per-provider allowlist restricting who can use slash commands. If set, overrides the channel\'s allowFrom for command authorization. Use \'*\' key for global default; provider-specific keys (e.g. \'discord\') override the global. Example: { "*": ["user1"], "discord": ["user:123"] }.', - "session.dmScope": - 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).', - "session.identityLinks": - "Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).", - "channels.telegram.configWrites": - "Allow Telegram to write config in response to channel events/commands (default: true).", - "channels.slack.configWrites": - "Allow Slack to write config in response to channel events/commands (default: true).", - "channels.mattermost.configWrites": - "Allow Mattermost to write config in response to channel events/commands (default: true).", - "channels.discord.configWrites": - "Allow Discord to write config in response to channel events/commands (default: true).", - "channels.whatsapp.configWrites": - "Allow WhatsApp to write config in response to channel events/commands (default: true).", - "channels.signal.configWrites": - "Allow Signal to write config in response to channel events/commands (default: true).", - "channels.imessage.configWrites": - "Allow iMessage to write config in response to channel events/commands (default: true).", - "channels.msteams.configWrites": - "Allow Microsoft Teams to write config in response to channel events/commands (default: true).", - "channels.discord.commands.native": 'Override native commands for Discord (bool or "auto").', - "channels.discord.commands.nativeSkills": - 'Override native skill commands for Discord (bool or "auto").', - "channels.telegram.commands.native": 'Override native commands for Telegram (bool or "auto").', - "channels.telegram.commands.nativeSkills": - 'Override native skill commands for Telegram (bool or "auto").', - "channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").', - "channels.slack.commands.nativeSkills": - 'Override native skill commands for Slack (bool or "auto").', - "session.agentToAgent.maxPingPongTurns": - "Max reply-back turns between requester and target (0–5).", - "channels.telegram.customCommands": - "Additional Telegram bot menu commands (merged with native; conflicts ignored).", - "messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).", - "messages.ackReactionScope": - 'When to send ack reactions ("group-mentions", "group-all", "direct", "all").', - "messages.inbound.debounceMs": - "Debounce window (ms) for batching rapid inbound messages from the same sender (0 to disable).", - "channels.telegram.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.telegram.allowFrom=["*"].', - "channels.telegram.streamMode": - "Draft streaming mode for Telegram replies (off | partial | block). Separate from block streaming; requires private topics + sendMessageDraft.", - "channels.telegram.draftChunk.minChars": - 'Minimum chars before emitting a Telegram draft update when channels.telegram.streamMode="block" (default: 200).', - "channels.telegram.draftChunk.maxChars": - 'Target max size for a Telegram draft update chunk when channels.telegram.streamMode="block" (default: 800; clamped to channels.telegram.textChunkLimit).', - "channels.telegram.draftChunk.breakPreference": - "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence). Default: paragraph.", - "channels.telegram.retry.attempts": - "Max retry attempts for outbound Telegram API calls (default: 3).", - "channels.telegram.retry.minDelayMs": "Minimum retry delay in ms for Telegram outbound calls.", - "channels.telegram.retry.maxDelayMs": - "Maximum retry delay cap in ms for Telegram outbound calls.", - "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", - "channels.telegram.network.autoSelectFamily": - "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", - "channels.telegram.timeoutSeconds": - "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", - "channels.whatsapp.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.whatsapp.allowFrom=["*"].', - "channels.whatsapp.selfChatMode": "Same-phone setup (bot uses your personal WhatsApp number).", - "channels.whatsapp.debounceMs": - "Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable).", - "channels.signal.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', - "channels.imessage.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', - "channels.bluebubbles.dmPolicy": - 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', - "channels.discord.dm.policy": - 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', - "channels.discord.retry.attempts": - "Max retry attempts for outbound Discord API calls (default: 3).", - "channels.discord.retry.minDelayMs": "Minimum retry delay in ms for Discord outbound calls.", - "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", - "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", - "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", - "channels.discord.intents.presence": - "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", - "channels.discord.intents.guildMembers": - "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", - "channels.discord.pluralkit.enabled": - "Resolve PluralKit proxied messages and treat system members as distinct senders.", - "channels.discord.pluralkit.token": - "Optional PluralKit token for resolving private systems or members.", - "channels.slack.dm.policy": - 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', -}; - -const FIELD_PLACEHOLDERS: Record = { - "gateway.remote.url": "ws://host:18789", - "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", - "gateway.remote.sshTarget": "user@host", - "gateway.controlUi.basePath": "/openclaw", - "gateway.controlUi.root": "dist/control-ui", - "gateway.controlUi.allowedOrigins": "https://control.example.com", - "channels.mattermost.baseUrl": "https://chat.example.com", - "agents.list[].identity.avatar": "avatars/openclaw.png", -}; - -const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; - -function isSensitivePath(path: string): boolean { - return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path)); -} - type JsonSchemaObject = JsonSchemaNode & { type?: string | string[]; properties?: Record; @@ -836,40 +88,6 @@ function mergeObjectSchema(base: JsonSchemaObject, extension: JsonSchemaObject): return merged; } -function buildBaseHints(): ConfigUiHints { - const hints: ConfigUiHints = {}; - for (const [group, label] of Object.entries(GROUP_LABELS)) { - hints[group] = { - label, - group: label, - order: GROUP_ORDER[group], - }; - } - for (const [path, label] of Object.entries(FIELD_LABELS)) { - const current = hints[path]; - hints[path] = current ? { ...current, label } : { label }; - } - for (const [path, help] of Object.entries(FIELD_HELP)) { - const current = hints[path]; - hints[path] = current ? { ...current, help } : { help }; - } - for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) { - const current = hints[path]; - hints[path] = current ? { ...current, placeholder } : { placeholder }; - } - return hints; -} - -function applySensitiveHints(hints: ConfigUiHints): ConfigUiHints { - const next = { ...hints }; - for (const key of Object.keys(next)) { - if (isSensitivePath(key)) { - next[key] = { ...next[key], sensitive: true }; - } - } - return next; -} - function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): ConfigUiHints { const next: ConfigUiHints = { ...hints }; for (const plugin of plugins) { diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index ce750297785..c0fece90ea5 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -2,6 +2,7 @@ import type { GroupPolicy } from "./types.base.js"; import type { DiscordConfig } from "./types.discord.js"; import type { GoogleChatConfig } from "./types.googlechat.js"; import type { IMessageConfig } from "./types.imessage.js"; +import type { IrcConfig } from "./types.irc.js"; import type { MSTeamsConfig } from "./types.msteams.js"; import type { SignalConfig } from "./types.signal.js"; import type { SlackConfig } from "./types.slack.js"; @@ -41,6 +42,7 @@ export type ChannelsConfig = { whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; + irc?: IrcConfig; googlechat?: GoogleChatConfig; slack?: SlackConfig; signal?: SignalConfig; diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 7ca74605a28..52dd57ce36e 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -25,6 +25,7 @@ export type HookMappingConfig = { | "whatsapp" | "telegram" | "discord" + | "irc" | "googlechat" | "slack" | "signal" diff --git a/src/config/types.irc.ts b/src/config/types.irc.ts new file mode 100644 index 00000000000..833823d7c92 --- /dev/null +++ b/src/config/types.irc.ts @@ -0,0 +1,106 @@ +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; +import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { DmConfig } from "./types.messages.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; + +export type IrcAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** Optional provider capability tags used for agent/runtime guidance. */ + capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; + /** Allow channel-initiated config writes (default: true). */ + configWrites?: boolean; + /** If false, do not start this IRC account. Default: true. */ + enabled?: boolean; + /** IRC server hostname (example: irc.libera.chat). */ + host?: string; + /** IRC server port (default: 6697 with TLS, otherwise 6667). */ + port?: number; + /** Use TLS for IRC connection (default: true). */ + tls?: boolean; + /** IRC nickname to identify this bot. */ + nick?: string; + /** IRC USER field username (defaults to nick). */ + username?: string; + /** IRC USER field realname (default: OpenClaw). */ + realname?: string; + /** Optional IRC server password (sensitive). */ + password?: string; + /** Optional file path containing IRC server password. */ + passwordFile?: string; + /** Optional NickServ identify/register settings. */ + nickserv?: { + /** Enable NickServ identify/register after connect (default: enabled when password is set). */ + enabled?: boolean; + /** NickServ service nick (default: NickServ). */ + service?: string; + /** NickServ password (sensitive). */ + password?: string; + /** Optional file path containing NickServ password. */ + passwordFile?: string; + /** If true, send NickServ REGISTER on connect. */ + register?: boolean; + /** Email used with NickServ REGISTER. */ + registerEmail?: string; + }; + /** Auto-join channel list at connect (example: ["#openclaw"]). */ + channels?: string[]; + /** Direct message access policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Optional allowlist for inbound DM senders. */ + allowFrom?: Array; + /** Optional allowlist for IRC channel senders. */ + groupAllowFrom?: Array; + /** + * Controls how channel messages are handled: + * - "open": channels bypass allowFrom; mention-gating applies + * - "disabled": block all channel messages entirely + * - "allowlist": only allow channel messages from senders in groupAllowFrom/allowFrom + */ + groupPolicy?: GroupPolicy; + /** Max channel messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by sender ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 350. */ + textChunkLimit?: number; + /** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */ + chunkMode?: "length" | "newline"; + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + groups?: Record< + string, + { + requireMention?: boolean; + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; + allowFrom?: Array; + skills?: string[]; + enabled?: boolean; + systemPrompt?: string; + } + >; + /** Optional mention patterns specific to IRC channel messages. */ + mentionPatterns?: string[]; + /** Heartbeat visibility settings for this channel. */ + heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Outbound response prefix override for this channel/account. */ + responsePrefix?: string; + /** Max outbound media size in MB. */ + mediaMaxMb?: number; +}; + +export type IrcConfig = { + /** Optional per-account IRC configuration (multi-account). */ + accounts?: Record; +} & IrcAccountConfig; diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts index 9e421ddfe88..5795db2b977 100644 --- a/src/config/types.queue.ts +++ b/src/config/types.queue.ts @@ -12,6 +12,7 @@ export type QueueModeByProvider = { whatsapp?: QueueMode; telegram?: QueueMode; discord?: QueueMode; + irc?: QueueMode; googlechat?: QueueMode; slack?: QueueMode; signal?: QueueMode; diff --git a/src/config/types.ts b/src/config/types.ts index d14f1178e83..4260dd43931 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -14,6 +14,7 @@ export * from "./types.googlechat.js"; export * from "./types.gateway.js"; export * from "./types.hooks.js"; export * from "./types.imessage.js"; +export * from "./types.irc.js"; export * from "./types.messages.js"; export * from "./types.models.js"; export * from "./types.node-host.js"; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 721d6252c0c..005ed3effd0 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -309,6 +309,7 @@ export const QueueModeBySurfaceSchema = z whatsapp: QueueModeSchema.optional(), telegram: QueueModeSchema.optional(), discord: QueueModeSchema.optional(), + irc: QueueModeSchema.optional(), slack: QueueModeSchema.optional(), mattermost: QueueModeSchema.optional(), signal: QueueModeSchema.optional(), diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 35e74f7af97..471e422d32e 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -23,6 +23,7 @@ export const HookMappingSchema = z z.literal("whatsapp"), z.literal("telegram"), z.literal("discord"), + z.literal("irc"), z.literal("slack"), z.literal("signal"), z.literal("imessage"), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 89a19e41381..9c4fc422abb 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -622,6 +622,101 @@ export const SignalConfigSchema = SignalAccountSchemaBase.extend({ }); }); +export const IrcGroupSchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +export const IrcNickServSchema = z + .object({ + enabled: z.boolean().optional(), + service: z.string().optional(), + password: z.string().optional(), + passwordFile: z.string().optional(), + register: z.boolean().optional(), + registerEmail: z.string().optional(), + }) + .strict(); + +export const IrcAccountSchemaBase = z + .object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, + enabled: z.boolean().optional(), + configWrites: z.boolean().optional(), + host: z.string().optional(), + port: z.number().int().min(1).max(65535).optional(), + tls: z.boolean().optional(), + nick: z.string().optional(), + username: z.string().optional(), + realname: z.string().optional(), + password: z.string().optional(), + passwordFile: z.string().optional(), + nickserv: IrcNickServSchema.optional(), + channels: z.array(z.string()).optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groups: z.record(z.string(), IrcGroupSchema.optional()).optional(), + mentionPatterns: z.array(z.string()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + chunkMode: z.enum(["length", "newline"]).optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + mediaMaxMb: z.number().positive().optional(), + heartbeat: ChannelHeartbeatVisibilitySchema, + responsePrefix: z.string().optional(), + }) + .strict(); + +export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"', + }); + if (value.nickserv?.register && !value.nickserv.registerEmail?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["nickserv", "registerEmail"], + message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail", + }); + } +}); + +export const IrcConfigSchema = IrcAccountSchemaBase.extend({ + accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"', + }); + if (value.nickserv?.register && !value.nickserv.registerEmail?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["nickserv", "registerEmail"], + message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail", + }); + } +}); + export const IMessageAccountSchemaBase = z .object({ name: z.string().optional(), diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index f227fccf650..8bc961b5d7e 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -6,6 +6,7 @@ import { DiscordConfigSchema, GoogleChatConfigSchema, IMessageConfigSchema, + IrcConfigSchema, MSTeamsConfigSchema, SignalConfigSchema, SlackConfigSchema, @@ -29,6 +30,7 @@ export const ChannelsSchema = z whatsapp: WhatsAppConfigSchema.optional(), telegram: TelegramConfigSchema.optional(), discord: DiscordConfigSchema.optional(), + irc: IrcConfigSchema.optional(), googlechat: GoogleChatConfigSchema.optional(), slack: SlackConfigSchema.optional(), signal: SignalConfigSchema.optional(),