mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-22 07:01:44 +03:00
feat: IRC — add first-class channel support
Adds IRC as a first-class channel with core config surfaces (schema/hints/dock), plugin auto-enable detection, routing/policy alignment, and docs/tests. Co-authored-by: Vignesh <vigneshnatarajan92@gmail.com>
This commit is contained in:
@@ -9,6 +9,11 @@
|
|||||||
- "src/discord/**"
|
- "src/discord/**"
|
||||||
- "extensions/discord/**"
|
- "extensions/discord/**"
|
||||||
- "docs/channels/discord.md"
|
- "docs/channels/discord.md"
|
||||||
|
"channel: irc":
|
||||||
|
- changed-files:
|
||||||
|
- any-glob-to-any-file:
|
||||||
|
- "extensions/irc/**"
|
||||||
|
- "docs/channels/irc.md"
|
||||||
"channel: feishu":
|
"channel: feishu":
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
|
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
|
||||||
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
|
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
|
||||||
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
|
- 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.
|
- 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.
|
- 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.
|
- Gateway: add agent management RPC methods for the web UI (`agents.create`, `agents.update`, `agents.delete`). (#11045) Thanks @advaitpaliwal.
|
||||||
|
|||||||
@@ -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.
|
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
|
||||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||||
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
|
- [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.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately).
|
- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately).
|
||||||
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
|
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -863,6 +863,7 @@
|
|||||||
"channels/telegram",
|
"channels/telegram",
|
||||||
"channels/grammy",
|
"channels/grammy",
|
||||||
"channels/discord",
|
"channels/discord",
|
||||||
|
"channels/irc",
|
||||||
"channels/slack",
|
"channels/slack",
|
||||||
"channels/feishu",
|
"channels/feishu",
|
||||||
"channels/googlechat",
|
"channels/googlechat",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"id": "irc",
|
||||||
|
"channels": ["irc"],
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<ResolvedIrcAccount, IrcProbe> = {
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>;
|
||||||
|
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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||||
|
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<IrcClient> {
|
||||||
|
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<void>((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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 "*"',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
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<void>;
|
||||||
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||||
|
}): Promise<void> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 | number>): 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<string>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<IrcAccountConfig>,
|
||||||
|
): CoreConfig {
|
||||||
|
const current = cfg.channels?.irc ?? {};
|
||||||
|
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
irc: {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
irc: {
|
||||||
|
...current,
|
||||||
|
accounts: {
|
||||||
|
...current.accounts,
|
||||||
|
[accountId]: {
|
||||||
|
...current.accounts?.[accountId],
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
||||||
|
const allowFrom =
|
||||||
|
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.irc?.allowFrom) : undefined;
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
irc: {
|
||||||
|
...cfg.channels?.irc,
|
||||||
|
dmPolicy,
|
||||||
|
...(allowFrom ? { allowFrom } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig {
|
||||||
|
return {
|
||||||
|
...cfg,
|
||||||
|
channels: {
|
||||||
|
...cfg.channels,
|
||||||
|
irc: {
|
||||||
|
...cfg.channels?.irc,
|
||||||
|
allowFrom,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIrcNickServ(
|
||||||
|
cfg: CoreConfig,
|
||||||
|
accountId: string,
|
||||||
|
nickserv?: IrcNickServConfig,
|
||||||
|
): CoreConfig {
|
||||||
|
return updateIrcAccountConfig(cfg, accountId, { nickserv });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIrcGroupAccess(
|
||||||
|
cfg: CoreConfig,
|
||||||
|
accountId: string,
|
||||||
|
policy: "open" | "allowlist" | "disabled",
|
||||||
|
entries: string[],
|
||||||
|
): CoreConfig {
|
||||||
|
if (policy !== "allowlist") {
|
||||||
|
return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy });
|
||||||
|
}
|
||||||
|
const normalizedEntries = [
|
||||||
|
...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)),
|
||||||
|
];
|
||||||
|
const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}]));
|
||||||
|
return updateIrcAccountConfig(cfg, accountId, {
|
||||||
|
enabled: true,
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function noteIrcSetupHelp(prompter: WizardPrompter): Promise<void> {
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"IRC needs server host + bot nick.",
|
||||||
|
"Recommended: TLS on port 6697.",
|
||||||
|
"Optional: NickServ identify/register can be configured in onboarding.",
|
||||||
|
'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.',
|
||||||
|
'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).',
|
||||||
|
"Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.",
|
||||||
|
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
|
||||||
|
].join("\n"),
|
||||||
|
"IRC setup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptIrcAllowFrom(params: {
|
||||||
|
cfg: CoreConfig;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
accountId?: string;
|
||||||
|
}): Promise<CoreConfig> {
|
||||||
|
const existing = params.cfg.channels?.irc?.allowFrom ?? [];
|
||||||
|
|
||||||
|
await params.prompter.note(
|
||||||
|
[
|
||||||
|
"Allowlist IRC DMs by sender.",
|
||||||
|
"Examples:",
|
||||||
|
"- alice",
|
||||||
|
"- alice!ident@example.org",
|
||||||
|
"Multiple entries: comma-separated.",
|
||||||
|
].join("\n"),
|
||||||
|
"IRC allowlist",
|
||||||
|
);
|
||||||
|
|
||||||
|
const raw = await params.prompter.text({
|
||||||
|
message: "IRC allowFrom (nick or nick!user@host)",
|
||||||
|
placeholder: "alice, bob!ident@example.org",
|
||||||
|
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||||
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = parseListInput(String(raw));
|
||||||
|
const normalized = [
|
||||||
|
...new Set(
|
||||||
|
parsed
|
||||||
|
.map((entry) => normalizeIrcAllowEntry(entry))
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return setIrcAllowFrom(params.cfg, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptIrcNickServConfig(params: {
|
||||||
|
cfg: CoreConfig;
|
||||||
|
prompter: WizardPrompter;
|
||||||
|
accountId: string;
|
||||||
|
}): Promise<CoreConfig> {
|
||||||
|
const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
const existing = resolved.config.nickserv;
|
||||||
|
const hasExisting = Boolean(existing?.password || existing?.passwordFile);
|
||||||
|
const wants = await params.prompter.confirm({
|
||||||
|
message: hasExisting ? "Update NickServ settings?" : "Configure NickServ identify/register?",
|
||||||
|
initialValue: hasExisting,
|
||||||
|
});
|
||||||
|
if (!wants) {
|
||||||
|
return params.cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = String(
|
||||||
|
await params.prompter.text({
|
||||||
|
message: "NickServ service nick",
|
||||||
|
initialValue: existing?.service || "NickServ",
|
||||||
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const useEnvPassword =
|
||||||
|
params.accountId === DEFAULT_ACCOUNT_ID &&
|
||||||
|
Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) &&
|
||||||
|
!(existing?.password || existing?.passwordFile)
|
||||||
|
? await params.prompter.confirm({
|
||||||
|
message: "IRC_NICKSERV_PASSWORD detected. Use env var?",
|
||||||
|
initialValue: true,
|
||||||
|
})
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const password = useEnvPassword
|
||||||
|
? undefined
|
||||||
|
: String(
|
||||||
|
await params.prompter.text({
|
||||||
|
message: "NickServ password (blank to disable NickServ auth)",
|
||||||
|
validate: () => undefined,
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
if (!password && !useEnvPassword) {
|
||||||
|
return setIrcNickServ(params.cfg, params.accountId, {
|
||||||
|
enabled: false,
|
||||||
|
service,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = await params.prompter.confirm({
|
||||||
|
message: "Send NickServ REGISTER on connect?",
|
||||||
|
initialValue: existing?.register ?? false,
|
||||||
|
});
|
||||||
|
const registerEmail = register
|
||||||
|
? String(
|
||||||
|
await params.prompter.text({
|
||||||
|
message: "NickServ register email",
|
||||||
|
initialValue:
|
||||||
|
existing?.registerEmail ||
|
||||||
|
(params.accountId === DEFAULT_ACCOUNT_ID
|
||||||
|
? process.env.IRC_NICKSERV_REGISTER_EMAIL
|
||||||
|
: undefined),
|
||||||
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return setIrcNickServ(params.cfg, params.accountId, {
|
||||||
|
enabled: true,
|
||||||
|
service,
|
||||||
|
...(password ? { password } : {}),
|
||||||
|
register,
|
||||||
|
...(registerEmail ? { registerEmail } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||||
|
label: "IRC",
|
||||||
|
channel,
|
||||||
|
policyKey: "channels.irc.dmPolicy",
|
||||||
|
allowFromKey: "channels.irc.allowFrom",
|
||||||
|
getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing",
|
||||||
|
setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy),
|
||||||
|
promptAllowFrom: promptIrcAllowFrom,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ircOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||||
|
channel,
|
||||||
|
getStatus: async ({ cfg }) => {
|
||||||
|
const coreCfg = cfg as CoreConfig;
|
||||||
|
const configured = listIrcAccountIds(coreCfg).some(
|
||||||
|
(accountId) => resolveIrcAccount({ cfg: coreCfg, accountId }).configured,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
channel,
|
||||||
|
configured,
|
||||||
|
statusLines: [`IRC: ${configured ? "configured" : "needs host + nick"}`],
|
||||||
|
selectionHint: configured ? "configured" : "needs host + nick",
|
||||||
|
quickstartScore: configured ? 1 : 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
configure: async ({
|
||||||
|
cfg,
|
||||||
|
prompter,
|
||||||
|
accountOverrides,
|
||||||
|
shouldPromptAccountIds,
|
||||||
|
forceAllowFrom,
|
||||||
|
}) => {
|
||||||
|
let next = cfg as CoreConfig;
|
||||||
|
const ircOverride = accountOverrides.irc?.trim();
|
||||||
|
const defaultAccountId = resolveDefaultIrcAccountId(next);
|
||||||
|
let accountId = ircOverride || defaultAccountId;
|
||||||
|
if (shouldPromptAccountIds && !ircOverride) {
|
||||||
|
accountId = await promptAccountId({
|
||||||
|
cfg: next,
|
||||||
|
prompter,
|
||||||
|
label: "IRC",
|
||||||
|
currentId: accountId,
|
||||||
|
listAccountIds: listIrcAccountIds,
|
||||||
|
defaultAccountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = resolveIrcAccount({ cfg: next, accountId });
|
||||||
|
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
|
const envHost = isDefaultAccount ? process.env.IRC_HOST?.trim() : "";
|
||||||
|
const envNick = isDefaultAccount ? process.env.IRC_NICK?.trim() : "";
|
||||||
|
const envReady = Boolean(envHost && envNick);
|
||||||
|
|
||||||
|
if (!resolved.configured) {
|
||||||
|
await noteIrcSetupHelp(prompter);
|
||||||
|
}
|
||||||
|
|
||||||
|
let useEnv = false;
|
||||||
|
if (envReady && isDefaultAccount && !resolved.config.host && !resolved.config.nick) {
|
||||||
|
useEnv = await prompter.confirm({
|
||||||
|
message: "IRC_HOST and IRC_NICK detected. Use env vars?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useEnv) {
|
||||||
|
next = updateIrcAccountConfig(next, accountId, { enabled: true });
|
||||||
|
} else {
|
||||||
|
const host = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "IRC server host",
|
||||||
|
initialValue: resolved.config.host || envHost || undefined,
|
||||||
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const tls = await prompter.confirm({
|
||||||
|
message: "Use TLS for IRC?",
|
||||||
|
initialValue: resolved.config.tls ?? true,
|
||||||
|
});
|
||||||
|
const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667);
|
||||||
|
const portInput = await prompter.text({
|
||||||
|
message: "IRC server port",
|
||||||
|
initialValue: String(defaultPort),
|
||||||
|
validate: (value) => {
|
||||||
|
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535
|
||||||
|
? undefined
|
||||||
|
: "Use a port between 1 and 65535";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const port = parsePort(String(portInput), defaultPort);
|
||||||
|
|
||||||
|
const nick = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "IRC nick",
|
||||||
|
initialValue: resolved.config.nick || envNick || undefined,
|
||||||
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const username = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "IRC username",
|
||||||
|
initialValue: resolved.config.username || nick || "openclaw",
|
||||||
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const realname = String(
|
||||||
|
await prompter.text({
|
||||||
|
message: "IRC real name",
|
||||||
|
initialValue: resolved.config.realname || "OpenClaw",
|
||||||
|
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||||
|
}),
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
const channelsRaw = await prompter.text({
|
||||||
|
message: "Auto-join IRC channels (optional, comma-separated)",
|
||||||
|
placeholder: "#openclaw, #ops",
|
||||||
|
initialValue: (resolved.config.channels ?? []).join(", "),
|
||||||
|
});
|
||||||
|
const channels = [
|
||||||
|
...new Set(
|
||||||
|
parseListInput(String(channelsRaw))
|
||||||
|
.map((entry) => normalizeGroupEntry(entry))
|
||||||
|
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
|
||||||
|
.filter((entry) => isChannelTarget(entry)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
next = updateIrcAccountConfig(next, accountId, {
|
||||||
|
enabled: true,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
tls,
|
||||||
|
nick,
|
||||||
|
username,
|
||||||
|
realname,
|
||||||
|
channels: channels.length > 0 ? channels : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterConfig = resolveIrcAccount({ cfg: next, accountId });
|
||||||
|
const accessConfig = await promptChannelAccessConfig({
|
||||||
|
prompter,
|
||||||
|
label: "IRC channels",
|
||||||
|
currentPolicy: afterConfig.config.groupPolicy ?? "allowlist",
|
||||||
|
currentEntries: Object.keys(afterConfig.config.groups ?? {}),
|
||||||
|
placeholder: "#openclaw, #ops, *",
|
||||||
|
updatePrompt: Boolean(afterConfig.config.groups),
|
||||||
|
});
|
||||||
|
if (accessConfig) {
|
||||||
|
next = setIrcGroupAccess(next, accountId, accessConfig.policy, accessConfig.entries);
|
||||||
|
|
||||||
|
// Mention gating: groups/channels are mention-gated by default. Make this explicit in onboarding.
|
||||||
|
const wantsMentions = await prompter.confirm({
|
||||||
|
message: "Require @mention to reply in IRC channels?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (!wantsMentions) {
|
||||||
|
const resolvedAfter = resolveIrcAccount({ cfg: next, accountId });
|
||||||
|
const groups = resolvedAfter.config.groups ?? {};
|
||||||
|
const patched = Object.fromEntries(
|
||||||
|
Object.entries(groups).map(([key, value]) => [key, { ...value, requireMention: false }]),
|
||||||
|
);
|
||||||
|
next = updateIrcAccountConfig(next, accountId, { groups: patched });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceAllowFrom) {
|
||||||
|
next = await promptIrcAllowFrom({ cfg: next, prompter, accountId });
|
||||||
|
}
|
||||||
|
next = await promptIrcNickServConfig({
|
||||||
|
cfg: next,
|
||||||
|
prompter,
|
||||||
|
accountId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prompter.note(
|
||||||
|
[
|
||||||
|
"Next: restart gateway and verify status.",
|
||||||
|
"Command: openclaw channels status --probe",
|
||||||
|
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
|
||||||
|
].join("\n"),
|
||||||
|
"IRC next steps",
|
||||||
|
);
|
||||||
|
|
||||||
|
return { cfg: next, accountId };
|
||||||
|
},
|
||||||
|
dmPolicy,
|
||||||
|
disable: (cfg) => ({
|
||||||
|
...(cfg as CoreConfig),
|
||||||
|
channels: {
|
||||||
|
...(cfg as CoreConfig).channels,
|
||||||
|
irc: {
|
||||||
|
...(cfg as CoreConfig).channels?.irc,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, IrcChannelConfig>;
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -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<IrcProbe> {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<SendIrcResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<string | number>;
|
||||||
|
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<string | number>;
|
||||||
|
groupPolicy?: GroupPolicy;
|
||||||
|
groupAllowFrom?: Array<string | number>;
|
||||||
|
groups?: Record<string, IrcChannelConfig>;
|
||||||
|
channels?: string[];
|
||||||
|
mentionPatterns?: string[];
|
||||||
|
markdown?: MarkdownConfig;
|
||||||
|
historyLimit?: number;
|
||||||
|
dmHistoryLimit?: number;
|
||||||
|
dms?: Record<string, DmConfig>;
|
||||||
|
textChunkLimit?: number;
|
||||||
|
chunkMode?: "length" | "newline";
|
||||||
|
blockStreaming?: boolean;
|
||||||
|
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||||
|
responsePrefix?: string;
|
||||||
|
mediaMaxMb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IrcConfig = IrcAccountConfig & {
|
||||||
|
accounts?: Record<string, IrcAccountConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
Generated
+6
@@ -342,6 +342,12 @@ importers:
|
|||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../..
|
version: link:../..
|
||||||
|
|
||||||
|
extensions/irc:
|
||||||
|
devDependencies:
|
||||||
|
openclaw:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../..
|
||||||
|
|
||||||
extensions/line:
|
extensions/line:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
openclaw:
|
openclaw:
|
||||||
|
|||||||
+71
-1
@@ -10,6 +10,10 @@ import type {
|
|||||||
ChannelPlugin,
|
ChannelPlugin,
|
||||||
ChannelThreadingAdapter,
|
ChannelThreadingAdapter,
|
||||||
} from "./plugins/types.js";
|
} from "./plugins/types.js";
|
||||||
|
import {
|
||||||
|
resolveChannelGroupRequireMention,
|
||||||
|
resolveChannelGroupToolsPolicy,
|
||||||
|
} from "../config/group-policy.js";
|
||||||
import { resolveDiscordAccount } from "../discord/accounts.js";
|
import { resolveDiscordAccount } from "../discord/accounts.js";
|
||||||
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
import { resolveIMessageAccount } from "../imessage/accounts.js";
|
||||||
import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
@@ -75,7 +79,6 @@ const formatLower = (allowFrom: Array<string | number>) =>
|
|||||||
.map((entry) => String(entry).trim())
|
.map((entry) => String(entry).trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((entry) => entry.toLowerCase());
|
.map((entry) => entry.toLowerCase());
|
||||||
|
|
||||||
// Channel docks: lightweight channel metadata/behavior for shared code paths.
|
// Channel docks: lightweight channel metadata/behavior for shared code paths.
|
||||||
//
|
//
|
||||||
// Rules:
|
// Rules:
|
||||||
@@ -213,6 +216,73 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
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: {
|
googlechat: {
|
||||||
id: "googlechat",
|
id: "googlechat",
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ describe("channel registry", () => {
|
|||||||
expect(normalizeChatChannelId("imsg")).toBe("imessage");
|
expect(normalizeChatChannelId("imsg")).toBe("imessage");
|
||||||
expect(normalizeChatChannelId("gchat")).toBe("googlechat");
|
expect(normalizeChatChannelId("gchat")).toBe("googlechat");
|
||||||
expect(normalizeChatChannelId("google-chat")).toBe("googlechat");
|
expect(normalizeChatChannelId("google-chat")).toBe("googlechat");
|
||||||
|
expect(normalizeChatChannelId("internet-relay-chat")).toBe("irc");
|
||||||
expect(normalizeChatChannelId("web")).toBeNull();
|
expect(normalizeChatChannelId("web")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const CHAT_CHANNEL_ORDER = [
|
|||||||
"telegram",
|
"telegram",
|
||||||
"whatsapp",
|
"whatsapp",
|
||||||
"discord",
|
"discord",
|
||||||
|
"irc",
|
||||||
"googlechat",
|
"googlechat",
|
||||||
"slack",
|
"slack",
|
||||||
"signal",
|
"signal",
|
||||||
@@ -58,6 +59,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
|||||||
blurb: "very well supported right now.",
|
blurb: "very well supported right now.",
|
||||||
systemImage: "bubble.left.and.bubble.right",
|
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: {
|
googlechat: {
|
||||||
id: "googlechat",
|
id: "googlechat",
|
||||||
label: "Google Chat",
|
label: "Google Chat",
|
||||||
@@ -102,6 +113,7 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
|
|||||||
|
|
||||||
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
|
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
|
||||||
imsg: "imessage",
|
imsg: "imessage",
|
||||||
|
"internet-relay-chat": "irc",
|
||||||
"google-chat": "googlechat",
|
"google-chat": "googlechat",
|
||||||
gchat: "googlechat",
|
gchat: "googlechat",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,29 @@ export type ChannelGroupPolicy = {
|
|||||||
|
|
||||||
type ChannelGroups = Record<string, ChannelGroupConfig>;
|
type ChannelGroups = Record<string, ChannelGroupConfig>;
|
||||||
|
|
||||||
|
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 = {
|
export type GroupToolPolicySender = {
|
||||||
senderId?: string | null;
|
senderId?: string | null;
|
||||||
senderName?: string | null;
|
senderName?: string | null;
|
||||||
@@ -125,18 +148,18 @@ export function resolveChannelGroupPolicy(params: {
|
|||||||
channel: GroupPolicyChannel;
|
channel: GroupPolicyChannel;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
groupIdCaseInsensitive?: boolean;
|
||||||
}): ChannelGroupPolicy {
|
}): ChannelGroupPolicy {
|
||||||
const { cfg, channel } = params;
|
const { cfg, channel } = params;
|
||||||
const groups = resolveChannelGroups(cfg, channel, params.accountId);
|
const groups = resolveChannelGroups(cfg, channel, params.accountId);
|
||||||
const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
|
const allowlistEnabled = Boolean(groups && Object.keys(groups).length > 0);
|
||||||
const normalizedId = params.groupId?.trim();
|
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 defaultConfig = groups?.["*"];
|
||||||
const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*"));
|
const allowAll = allowlistEnabled && Boolean(groups && Object.hasOwn(groups, "*"));
|
||||||
const allowed =
|
const allowed = !allowlistEnabled || allowAll || Boolean(groupConfig);
|
||||||
!allowlistEnabled ||
|
|
||||||
allowAll ||
|
|
||||||
(normalizedId ? Boolean(groups && Object.hasOwn(groups, normalizedId)) : false);
|
|
||||||
return {
|
return {
|
||||||
allowlistEnabled,
|
allowlistEnabled,
|
||||||
allowed,
|
allowed,
|
||||||
@@ -150,6 +173,7 @@ export function resolveChannelGroupRequireMention(params: {
|
|||||||
channel: GroupPolicyChannel;
|
channel: GroupPolicyChannel;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
groupIdCaseInsensitive?: boolean;
|
||||||
requireMentionOverride?: boolean;
|
requireMentionOverride?: boolean;
|
||||||
overrideOrder?: "before-config" | "after-config";
|
overrideOrder?: "before-config" | "after-config";
|
||||||
}): boolean {
|
}): boolean {
|
||||||
@@ -180,6 +204,7 @@ export function resolveChannelGroupToolsPolicy(
|
|||||||
channel: GroupPolicyChannel;
|
channel: GroupPolicyChannel;
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
groupIdCaseInsensitive?: boolean;
|
||||||
} & GroupToolPolicySender,
|
} & GroupToolPolicySender,
|
||||||
): GroupToolPolicyConfig | undefined {
|
): GroupToolPolicyConfig | undefined {
|
||||||
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ describe("applyPluginAutoEnable", () => {
|
|||||||
expect(result.changes).toEqual([]);
|
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", () => {
|
it("configures provider auth plugins as disabled when profiles exist", () => {
|
||||||
const result = applyPluginAutoEnable({
|
const result = applyPluginAutoEnable({
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -105,6 +105,23 @@ function isDiscordConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boole
|
|||||||
return recordHasKeys(entry);
|
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 {
|
function isSlackConfigured(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
|
||||||
if (
|
if (
|
||||||
hasNonEmptyString(env.SLACK_BOT_TOKEN) ||
|
hasNonEmptyString(env.SLACK_BOT_TOKEN) ||
|
||||||
@@ -189,6 +206,8 @@ export function isChannelConfigured(
|
|||||||
return isTelegramConfigured(cfg, env);
|
return isTelegramConfigured(cfg, env);
|
||||||
case "discord":
|
case "discord":
|
||||||
return isDiscordConfigured(cfg, env);
|
return isDiscordConfigured(cfg, env);
|
||||||
|
case "irc":
|
||||||
|
return isIrcConfigured(cfg, env);
|
||||||
case "slack":
|
case "slack":
|
||||||
return isSlackConfigured(cfg, env);
|
return isSlackConfigured(cfg, env);
|
||||||
case "signal":
|
case "signal":
|
||||||
|
|||||||
@@ -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<string, ConfigUiHint>;
|
||||||
|
|
||||||
|
const GROUP_LABELS: Record<string, string> = {
|
||||||
|
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<string, number> = {
|
||||||
|
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<string, string> = {
|
||||||
|
"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<string, string> = {
|
||||||
|
"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/<id>).",
|
||||||
|
"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<string, string> = {
|
||||||
|
"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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const IRC_FIELD_LABELS: Record<string, string> = {
|
||||||
|
"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<string, string> = {
|
||||||
|
"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).",
|
||||||
|
};
|
||||||
+3
-785
@@ -1,19 +1,10 @@
|
|||||||
|
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
|
import { applySensitiveHints, buildBaseHints } from "./schema.hints.js";
|
||||||
import { OpenClawSchema } from "./zod-schema.js";
|
import { OpenClawSchema } from "./zod-schema.js";
|
||||||
|
|
||||||
export type ConfigUiHint = {
|
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
|
||||||
label?: string;
|
|
||||||
help?: string;
|
|
||||||
group?: string;
|
|
||||||
order?: number;
|
|
||||||
advanced?: boolean;
|
|
||||||
sensitive?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
itemTemplate?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ConfigUiHints = Record<string, ConfigUiHint>;
|
|
||||||
|
|
||||||
export type ConfigSchema = ReturnType<typeof OpenClawSchema.toJSONSchema>;
|
export type ConfigSchema = ReturnType<typeof OpenClawSchema.toJSONSchema>;
|
||||||
|
|
||||||
@@ -45,745 +36,6 @@ export type ChannelUiMetadata = {
|
|||||||
configUiHints?: Record<string, ConfigUiHint>;
|
configUiHints?: Record<string, ConfigUiHint>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const GROUP_LABELS: Record<string, string> = {
|
|
||||||
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<string, number> = {
|
|
||||||
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<string, string> = {
|
|
||||||
"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<string, string> = {
|
|
||||||
"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/<id>).",
|
|
||||||
"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<string, string> = {
|
|
||||||
"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 JsonSchemaObject = JsonSchemaNode & {
|
||||||
type?: string | string[];
|
type?: string | string[];
|
||||||
properties?: Record<string, JsonSchemaObject>;
|
properties?: Record<string, JsonSchemaObject>;
|
||||||
@@ -836,40 +88,6 @@ function mergeObjectSchema(base: JsonSchemaObject, extension: JsonSchemaObject):
|
|||||||
return merged;
|
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 {
|
function applyPluginHints(hints: ConfigUiHints, plugins: PluginUiMetadata[]): ConfigUiHints {
|
||||||
const next: ConfigUiHints = { ...hints };
|
const next: ConfigUiHints = { ...hints };
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { GroupPolicy } from "./types.base.js";
|
|||||||
import type { DiscordConfig } from "./types.discord.js";
|
import type { DiscordConfig } from "./types.discord.js";
|
||||||
import type { GoogleChatConfig } from "./types.googlechat.js";
|
import type { GoogleChatConfig } from "./types.googlechat.js";
|
||||||
import type { IMessageConfig } from "./types.imessage.js";
|
import type { IMessageConfig } from "./types.imessage.js";
|
||||||
|
import type { IrcConfig } from "./types.irc.js";
|
||||||
import type { MSTeamsConfig } from "./types.msteams.js";
|
import type { MSTeamsConfig } from "./types.msteams.js";
|
||||||
import type { SignalConfig } from "./types.signal.js";
|
import type { SignalConfig } from "./types.signal.js";
|
||||||
import type { SlackConfig } from "./types.slack.js";
|
import type { SlackConfig } from "./types.slack.js";
|
||||||
@@ -41,6 +42,7 @@ export type ChannelsConfig = {
|
|||||||
whatsapp?: WhatsAppConfig;
|
whatsapp?: WhatsAppConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
discord?: DiscordConfig;
|
discord?: DiscordConfig;
|
||||||
|
irc?: IrcConfig;
|
||||||
googlechat?: GoogleChatConfig;
|
googlechat?: GoogleChatConfig;
|
||||||
slack?: SlackConfig;
|
slack?: SlackConfig;
|
||||||
signal?: SignalConfig;
|
signal?: SignalConfig;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export type HookMappingConfig = {
|
|||||||
| "whatsapp"
|
| "whatsapp"
|
||||||
| "telegram"
|
| "telegram"
|
||||||
| "discord"
|
| "discord"
|
||||||
|
| "irc"
|
||||||
| "googlechat"
|
| "googlechat"
|
||||||
| "slack"
|
| "slack"
|
||||||
| "signal"
|
| "signal"
|
||||||
|
|||||||
@@ -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<string | number>;
|
||||||
|
/** Optional allowlist for IRC channel senders. */
|
||||||
|
groupAllowFrom?: Array<string | number>;
|
||||||
|
/**
|
||||||
|
* 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<string, DmConfig>;
|
||||||
|
/** 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<string | number>;
|
||||||
|
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<string, IrcAccountConfig>;
|
||||||
|
} & IrcAccountConfig;
|
||||||
@@ -12,6 +12,7 @@ export type QueueModeByProvider = {
|
|||||||
whatsapp?: QueueMode;
|
whatsapp?: QueueMode;
|
||||||
telegram?: QueueMode;
|
telegram?: QueueMode;
|
||||||
discord?: QueueMode;
|
discord?: QueueMode;
|
||||||
|
irc?: QueueMode;
|
||||||
googlechat?: QueueMode;
|
googlechat?: QueueMode;
|
||||||
slack?: QueueMode;
|
slack?: QueueMode;
|
||||||
signal?: QueueMode;
|
signal?: QueueMode;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export * from "./types.googlechat.js";
|
|||||||
export * from "./types.gateway.js";
|
export * from "./types.gateway.js";
|
||||||
export * from "./types.hooks.js";
|
export * from "./types.hooks.js";
|
||||||
export * from "./types.imessage.js";
|
export * from "./types.imessage.js";
|
||||||
|
export * from "./types.irc.js";
|
||||||
export * from "./types.messages.js";
|
export * from "./types.messages.js";
|
||||||
export * from "./types.models.js";
|
export * from "./types.models.js";
|
||||||
export * from "./types.node-host.js";
|
export * from "./types.node-host.js";
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ export const QueueModeBySurfaceSchema = z
|
|||||||
whatsapp: QueueModeSchema.optional(),
|
whatsapp: QueueModeSchema.optional(),
|
||||||
telegram: QueueModeSchema.optional(),
|
telegram: QueueModeSchema.optional(),
|
||||||
discord: QueueModeSchema.optional(),
|
discord: QueueModeSchema.optional(),
|
||||||
|
irc: QueueModeSchema.optional(),
|
||||||
slack: QueueModeSchema.optional(),
|
slack: QueueModeSchema.optional(),
|
||||||
mattermost: QueueModeSchema.optional(),
|
mattermost: QueueModeSchema.optional(),
|
||||||
signal: QueueModeSchema.optional(),
|
signal: QueueModeSchema.optional(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const HookMappingSchema = z
|
|||||||
z.literal("whatsapp"),
|
z.literal("whatsapp"),
|
||||||
z.literal("telegram"),
|
z.literal("telegram"),
|
||||||
z.literal("discord"),
|
z.literal("discord"),
|
||||||
|
z.literal("irc"),
|
||||||
z.literal("slack"),
|
z.literal("slack"),
|
||||||
z.literal("signal"),
|
z.literal("signal"),
|
||||||
z.literal("imessage"),
|
z.literal("imessage"),
|
||||||
|
|||||||
@@ -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
|
export const IMessageAccountSchemaBase = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DiscordConfigSchema,
|
DiscordConfigSchema,
|
||||||
GoogleChatConfigSchema,
|
GoogleChatConfigSchema,
|
||||||
IMessageConfigSchema,
|
IMessageConfigSchema,
|
||||||
|
IrcConfigSchema,
|
||||||
MSTeamsConfigSchema,
|
MSTeamsConfigSchema,
|
||||||
SignalConfigSchema,
|
SignalConfigSchema,
|
||||||
SlackConfigSchema,
|
SlackConfigSchema,
|
||||||
@@ -29,6 +30,7 @@ export const ChannelsSchema = z
|
|||||||
whatsapp: WhatsAppConfigSchema.optional(),
|
whatsapp: WhatsAppConfigSchema.optional(),
|
||||||
telegram: TelegramConfigSchema.optional(),
|
telegram: TelegramConfigSchema.optional(),
|
||||||
discord: DiscordConfigSchema.optional(),
|
discord: DiscordConfigSchema.optional(),
|
||||||
|
irc: IrcConfigSchema.optional(),
|
||||||
googlechat: GoogleChatConfigSchema.optional(),
|
googlechat: GoogleChatConfigSchema.optional(),
|
||||||
slack: SlackConfigSchema.optional(),
|
slack: SlackConfigSchema.optional(),
|
||||||
signal: SignalConfigSchema.optional(),
|
signal: SignalConfigSchema.optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user