from __future__ import annotations from typing import Any _CONFIG_SCHEMA: dict[str, dict[str, Any]] = { "server": { "type": "str", "required": True, "default": "irc.libera.chat", "description": "IRC server hostname", "env": "IRC_HOST", }, "port": { "type": "int", "required": False, "default": 6697, "description": "IRC server port", "env": "IRC_PORT", "min": 1, "max": 65535, }, "use_tls": { "type": "bool", "required": False, "default": True, "description": "Enable TLS encryption", "env": "IRC_TLS", }, "nick": { "type": "str", "required": True, "default": "ForcePilotBot", "description": "IRC nickname", "env": "IRC_NICK", }, "username": { "type": "str", "required": False, "description": "IRC username (defaults to nick)", "env": "IRC_USERNAME", }, "realname": { "type": "str", "required": False, "default": "ForcePilot IRC Bot", "description": "IRC real name / GECOS", "env": "IRC_REALNAME", }, "password": { "type": "str", "required": False, "default": "", "description": "IRC server connection password", "env": "IRC_PASSWORD", "sensitive": True, }, "sasl_username": { "type": "str", "required": False, "default": "", "description": "SASL authentication username", "env": "IRC_SASL_USERNAME", }, "sasl_password": { "type": "str", "required": False, "default": "", "description": "SASL authentication password", "env": "IRC_SASL_PASSWORD", "sensitive": True, }, "use_sasl": { "type": "bool", "required": False, "default": True, "description": "Enable SASL authentication when credentials available", }, "sasl_mechanism": { "type": "str", "required": False, "default": "PLAIN", "description": "SASL mechanism (PLAIN, EXTERNAL, SCRAM-SHA-256)", "choices": ["PLAIN", "EXTERNAL", "SCRAM-SHA-256"], }, "nickserv_password": { "type": "str", "required": False, "default": "", "description": "NickServ password", "env": "IRC_NICKSERV_PASSWORD", "sensitive": True, }, "nickserv_password_file": { "type": "str", "required": False, "default": "", "description": "Path to NickServ password file", "env": "IRC_NICKSERV_PASSWORD_FILE", }, "nickserv_register": { "type": "bool", "required": False, "default": False, "description": "Attempt NickServ REGISTER on connect", }, "nickserv_register_email": { "type": "str", "required": False, "default": "", "description": "Email for NickServ REGISTER", "env": "IRC_NICKSERV_REGISTER_EMAIL", }, "nickserv_service": { "type": "str", "required": False, "default": "NickServ", "description": "NickServ service name", "env": "IRC_NICKSERV_SERVICE", }, "auto_join_channels": { "type": "list[str]", "required": False, "default": [], "description": "Channels to auto-join on connect", "env": "IRC_CHANNELS", }, "nick_recovery": { "type": "bool", "required": False, "default": True, "description": "Attempt ghost recovery on SASL login", }, "dm_policy": { "type": "str", "required": False, "default": "pairing", "description": "DM policy: open, pairing, or disabled", "choices": ["open", "pairing", "disabled"], "env": "IRC_DM_POLICY", }, "group_policy": { "type": "str", "required": False, "default": "allowlist", "description": "Group policy: open, allowlist, or disabled", "choices": ["open", "allowlist", "disabled"], "env": "IRC_GROUP_POLICY", }, "allowFrom": { "type": "list[str]", "required": False, "default": [], "description": "DM allowlist entries (nick!user@host format)", }, "groupAllowFrom": { "type": "list[str]", "required": False, "default": [], "description": "Group allowlist entries (nick!user@host format)", }, "connect_timeout": { "type": "float", "required": False, "default": 30.0, "description": "Connection timeout in seconds", }, "probe_timeout": { "type": "float", "required": False, "default": 8.0, "description": "Health probe timeout in seconds", }, "chunker_mode": { "type": "str", "required": False, "default": "length", "description": "Message splitting strategy: length or markdown", "choices": ["length", "markdown"], }, "text_chunk_limit": { "type": "int", "required": False, "default": 350, "description": "Maximum bytes per IRC message chunk", }, "reply_enabled": { "type": "bool", "required": False, "default": False, "description": "Enable reply-to prefix [reply:id]", }, "blockStreaming": { "type": "bool", "required": False, "default": True, "description": "Enable block streaming mode", }, "blockStreamingCoalesce": { "type": "dict", "required": False, "default": {}, "description": "Block streaming coalesce config (delay_ms)", }, "disableBlockStreaming": { "type": "bool", "required": False, "default": False, "description": "Disable block streaming mode, send each chunk immediately", }, "markdown.tableMode": { "type": "str", "required": False, "default": "off", "description": "Markdown table conversion mode: off, plain, list", "choices": ["off", "plain", "list"], }, "dangerouslyAllowNameMatching": { "type": "bool", "required": False, "default": False, "description": "Allow name-only matching in allowlists (less secure)", }, "groupAllowFromFallbackToAllowFrom": { "type": "bool", "required": False, "default": False, "description": "Fall back to allowFrom when groupAllowFrom is empty", }, "knownServer": { "type": "str", "required": False, "description": "Use a known server from the built-in server list", }, "dnsSrv": { "type": "bool", "required": False, "default": False, "description": "Use DNS SRV records for server discovery", }, "mentionPatterns": { "type": "list[str]", "required": False, "default": [], "description": "Custom mention detection patterns", }, "commands.enabled": { "type": "bool", "required": False, "default": False, "description": "Enable built-in text commands (!help, !ping, etc.)", }, "server_password": { "type": "str", "required": False, "default": "", "description": "IRC server password", "env": "IRC_SERVER_PASSWORD", "sensitive": True, }, "server_password_file": { "type": "str", "required": False, "default": "", "description": "Path to IRC server password file", "env": "IRC_SERVER_PASSWORD_FILE", }, "accounts": { "type": "dict", "required": False, "default": {}, "description": "Multi-account configuration", }, "dedup.max_entries": { "type": "int", "required": False, "default": 1000, "description": "Maximum deduplication entries", }, } def get_config_schema() -> dict[str, dict[str, Any]]: return dict(_CONFIG_SCHEMA) def get_schema_keys() -> list[str]: return list(_CONFIG_SCHEMA.keys()) def validate_config(config: dict[str, Any]) -> list[str]: errors: list[str] = [] schema = _CONFIG_SCHEMA for key, spec in schema.items(): if "." in key: top_key, sub_key = key.split(".", 1) top_val = config.get(top_key, {}) if isinstance(top_val, dict): value = top_val.get(sub_key) else: value = None else: value = config.get(key) if spec.get("required") and (value is None or value == ""): errors.append(f"{key}: {spec.get('description', 'required field')} is required") if value is not None and value != "" and "choices" in spec: if value not in spec["choices"]: errors.append(f"{key}: must be one of {spec['choices']}, got '{value}'") if value is not None and spec.get("type") == "int": try: int_val = int(value) if "min" in spec and int_val < spec["min"]: errors.append(f"{key}: must be >= {spec['min']}, got {int_val}") if "max" in spec and int_val > spec["max"]: errors.append(f"{key}: must be <= {spec['max']}, got {int_val}") except (ValueError, TypeError): errors.append(f"{key}: expected int, got {type(value).__name__}") _add_cross_field_errors(config, errors) return errors def _add_cross_field_errors(config: dict[str, Any], errors: list[str]) -> None: nickserv_register = config.get("nickserv_register", False) if nickserv_register: email = config.get("nickserv_register_email", "") if not email: errors.append("nickserv_register: NickServ REGISTER requires nickserv_register_email") dm_policy = config.get("dm_policy", "pairing") if dm_policy == "open": allow_from = config.get("allowFrom", []) if isinstance(allow_from, list) and "*" not in allow_from: errors.append("dm_policy: dm_policy=open requires allowFrom to include '*'")