1. 调整导入顺序和移除多余空行 2. 重构IRC NAMES命令的成员解析逻辑,正确剥离所有前缀 3. 更新配置schema:修改默认消息块大小为350字节,新增disableBlockStreaming配置项 4. 完善CTCP处理:新增ACTION支持,提取CTCP动作文本 5. 新增IRC线程上下文模拟器类 6. 新增SRV记录解析支持,自动解析IRC服务器域名 7. 新增多种IRC通知处理:ACCOUNT、AWAY、INVITE、CAP 8. 重构消息发送逻辑,添加熔断器和重试机制 9. 重写流式消息合并逻辑,新增智能合并缓冲 10. 扩展IRC CAP支持,新增多个常用扩展能力 11. 修复配置读取逻辑,适配新的配置结构
339 lines
10 KiB
Python
339 lines
10 KiB
Python
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 '*'")
|