新增 IRC 渠道的完整扩展实现,包括客户端连接管理、消息收发与流式处理、安全策略与配对验证、消息去重与规范化、状态监控与健康探测、配置模型与账户管理、错误处理等模块。
164 lines
5.2 KiB
Python
164 lines
5.2 KiB
Python
import logging
|
|
import re
|
|
import secrets
|
|
import time
|
|
|
|
from yuxi.channel.extensions.irc.config import DmPolicy, GroupPolicy, IrcConfig
|
|
from yuxi.channel.extensions.irc.normalize import build_irc_allowlist_candidates
|
|
from yuxi.channel.extensions.irc.types import ResolvedIrcAccount
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_PAIRING_CODES: dict[str, tuple[str, float]] = {}
|
|
_CODE_TTL_SECONDS = 300
|
|
_ID_LABEL = "ircNick"
|
|
|
|
|
|
def build_irc_mention_regex(nick: str) -> re.Pattern:
|
|
escaped = re.escape(nick)
|
|
return re.compile(rf"\b{escaped}\b[:,]?", re.IGNORECASE)
|
|
|
|
|
|
def is_mentioned(nick: str, text: str) -> bool:
|
|
pattern = build_irc_mention_regex(nick)
|
|
return bool(pattern.search(text))
|
|
|
|
|
|
def resolve_dm_policy(account: ResolvedIrcAccount) -> DmPolicy:
|
|
return account.config.dm_policy
|
|
|
|
|
|
def resolve_group_policy(account: ResolvedIrcAccount) -> GroupPolicy:
|
|
return account.config.group_policy
|
|
|
|
|
|
def match_irc_group(
|
|
target: str,
|
|
groups: dict[str, object],
|
|
channels: list[str],
|
|
account_nick: str,
|
|
) -> object | None:
|
|
normalized = target.lower()
|
|
for channel in channels:
|
|
if channel.lower() == normalized:
|
|
key = channel
|
|
if key in groups:
|
|
return (key, groups[key])
|
|
return (key, None)
|
|
for group_key, group_cfg in groups.items():
|
|
if group_key.lower() == normalized:
|
|
return (group_key, group_cfg)
|
|
if group_key == "*":
|
|
return (group_key, group_cfg)
|
|
return None
|
|
|
|
|
|
def check_allowlist_match(
|
|
nick: str,
|
|
user: str | None,
|
|
host: str | None,
|
|
allowlist: list[str],
|
|
allow_name_matching: bool = False,
|
|
) -> bool:
|
|
if "*" in allowlist:
|
|
return True
|
|
|
|
candidates = build_irc_allowlist_candidates(nick, user, host, allow_name_matching)
|
|
allowlist_lower = {entry.lower() for entry in allowlist}
|
|
for candidate in candidates:
|
|
if candidate in allowlist_lower:
|
|
return True
|
|
return False
|
|
|
|
|
|
def resolve_irc_require_mention(
|
|
target: str,
|
|
groups: dict[str, object],
|
|
channels: list[str],
|
|
) -> bool:
|
|
normalized = target.lower()
|
|
for channel in channels:
|
|
if channel.lower() == normalized:
|
|
match = groups.get(channel)
|
|
if match is not None and hasattr(match, "require_mention"):
|
|
return match.require_mention
|
|
break
|
|
wildcard = groups.get("*")
|
|
if wildcard is not None and hasattr(wildcard, "require_mention"):
|
|
return wildcard.require_mention
|
|
return True
|
|
|
|
|
|
def collect_irc_security_warnings(account: ResolvedIrcAccount) -> list[str]:
|
|
warnings = []
|
|
if not account.tls:
|
|
warnings.append(
|
|
"IRC TLS is disabled — traffic and credentials are transmitted in plaintext"
|
|
)
|
|
nickserv = account.config.nickserv
|
|
if nickserv.register_nick:
|
|
if not account.nickserv_password:
|
|
warnings.append(
|
|
"NickServ registration is enabled but no NickServ password is configured"
|
|
)
|
|
else:
|
|
warnings.append(
|
|
"NickServ registration is enabled — disable 'register' after initial registration"
|
|
)
|
|
if account.config.group_policy == GroupPolicy.OPEN:
|
|
warnings.append(
|
|
"Group policy is set to 'open' — all channels can receive messages (mention-gated). "
|
|
"Consider setting to 'allowlist'."
|
|
)
|
|
for entry in account.config.allow_from:
|
|
if "!" not in entry and "@" not in entry and entry != "*":
|
|
warnings.append(
|
|
f"Allowlist entry '{entry}' is a bare nick without !user@host — "
|
|
"it could be spoofed. Use full nick!user@host format for security."
|
|
)
|
|
return warnings
|
|
|
|
|
|
def collect_mutable_allowlist_warnings(cfg: IrcConfig) -> list[str]:
|
|
warnings = []
|
|
for account_cfg in cfg.accounts.values():
|
|
for entry in account_cfg.allow_from:
|
|
if "!" not in entry and "@" not in entry and entry != "*":
|
|
warnings.append(
|
|
f"Account '{account_cfg.name or 'unknown'}': mutable allowlist entry '{entry}' "
|
|
"(bare nick, could be spoofed)"
|
|
)
|
|
return warnings
|
|
|
|
|
|
async def generate_irc_pairing_code(peer_id: str) -> str:
|
|
code = f"{secrets.randbelow(1_000_000):06d}"
|
|
_PAIRING_CODES[peer_id] = (code, time.monotonic())
|
|
logger.info("IRC pairing code generated for peer %s", peer_id)
|
|
return code
|
|
|
|
|
|
async def verify_irc_pairing_code(peer_id: str, code: str) -> bool:
|
|
stored = _PAIRING_CODES.get(peer_id)
|
|
if stored is None:
|
|
return False
|
|
stored_code, created_at = stored
|
|
if time.monotonic() - created_at > _CODE_TTL_SECONDS:
|
|
_PAIRING_CODES.pop(peer_id, None)
|
|
return False
|
|
if not secrets.compare_digest(stored_code, code):
|
|
return False
|
|
_PAIRING_CODES.pop(peer_id, None)
|
|
logger.info("IRC pairing code verified for peer %s", peer_id)
|
|
return True
|
|
|
|
|
|
def normalize_irc_allow_entry(entry: str) -> str:
|
|
stripped = entry.strip()
|
|
for prefix in ("irc:", "IRC:"):
|
|
if stripped.startswith(prefix):
|
|
stripped = stripped[len(prefix):]
|
|
if "!" in stripped or "@" in stripped:
|
|
return stripped.lower()
|
|
return stripped
|