新增 IRC 渠道的完整扩展实现,包括客户端连接管理、消息收发与流式处理、安全策略与配对验证、消息去重与规范化、状态监控与健康探测、配置模型与账户管理、错误处理等模块。
190 lines
6.2 KiB
Python
190 lines
6.2 KiB
Python
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from yuxi.channel.extensions.irc.config import IrcAccountConfig, IrcConfig, IrcNickServConfig
|
|
from yuxi.channel.extensions.irc.types import ResolvedIrcAccount
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _read_password_file(file_path: str) -> str:
|
|
p = Path(file_path)
|
|
if not p.is_file():
|
|
logger.warning("Password file not found: %s", file_path)
|
|
return ""
|
|
if p.is_symlink():
|
|
logger.warning("Password file is a symlink, refusing to read: %s", file_path)
|
|
return ""
|
|
try:
|
|
content = p.read_text(encoding="utf-8").strip()
|
|
return content
|
|
except Exception as e:
|
|
logger.warning("Failed to read password file %s: %s", file_path, e)
|
|
return ""
|
|
|
|
|
|
def _resolve_password(password: str, password_file: str | None, account_id: str) -> tuple[str, str]:
|
|
if account_id == "default":
|
|
env_val = os.environ.get("IRC_PASSWORD")
|
|
if env_val:
|
|
return env_val, "env"
|
|
if password_file:
|
|
content = _read_password_file(password_file)
|
|
if content:
|
|
return content, "passwordFile"
|
|
if password:
|
|
return password, "config"
|
|
return "", "none"
|
|
|
|
|
|
def _resolve_nickserv_password(nickserv: IrcNickServConfig, account_id: str) -> tuple[str, str]:
|
|
if nickserv.password:
|
|
return nickserv.password, "config"
|
|
if account_id == "default":
|
|
env_val = os.environ.get("IRC_NICKSERV_PASSWORD")
|
|
if env_val:
|
|
return env_val, "env"
|
|
if nickserv.password_file:
|
|
content = _read_password_file(nickserv.password_file)
|
|
if content:
|
|
return content, "passwordFile"
|
|
return "", "none"
|
|
|
|
|
|
def _merge_channels(global_channels: list[str], account_channels: list[str]) -> list[str]:
|
|
if account_channels:
|
|
return account_channels
|
|
return global_channels
|
|
|
|
|
|
def _merge_groups(
|
|
global_groups: dict[str, object],
|
|
account_groups: dict[str, object],
|
|
) -> dict[str, object]:
|
|
merged = dict(global_groups)
|
|
merged.update(account_groups)
|
|
return merged
|
|
|
|
|
|
def resolve_irc_account(cfg: IrcConfig, account_id: str = "default") -> ResolvedIrcAccount:
|
|
account_cfg = cfg.accounts.get(account_id)
|
|
if account_cfg is None:
|
|
if account_id == "default":
|
|
account_cfg = IrcAccountConfig(
|
|
host=cfg.host,
|
|
port=cfg.port,
|
|
tls=cfg.tls,
|
|
nick=cfg.nick,
|
|
username=cfg.username,
|
|
realname=cfg.realname,
|
|
password=cfg.password,
|
|
password_file=cfg.password_file,
|
|
nickserv=cfg.nickserv,
|
|
dm_policy=cfg.dm_policy,
|
|
allow_from=cfg.allow_from,
|
|
group_policy=cfg.group_policy,
|
|
group_allow_from=cfg.group_allow_from,
|
|
groups=cfg.groups,
|
|
channels=cfg.channels,
|
|
dangerously_allow_name_matching=cfg.dangerously_allow_name_matching,
|
|
text_chunk_limit=cfg.text_chunk_limit,
|
|
)
|
|
else:
|
|
raise KeyError(f"Account '{account_id}' not found")
|
|
|
|
host = account_cfg.host or cfg.host
|
|
port = account_cfg.port or cfg.port
|
|
tls = account_cfg.tls if account_cfg.host else cfg.tls
|
|
nick = account_cfg.nick or cfg.nick
|
|
username = account_cfg.username or cfg.username
|
|
realname = account_cfg.realname or cfg.realname
|
|
|
|
password, password_source = _resolve_password(
|
|
account_cfg.password or cfg.password,
|
|
account_cfg.password_file or cfg.password_file,
|
|
account_id,
|
|
)
|
|
|
|
nickserv_cfg = account_cfg.nickserv
|
|
if not nickserv_cfg.password and not nickserv_cfg.password_file:
|
|
nickserv_cfg = cfg.nickserv
|
|
|
|
nickserv_password, nickserv_password_source = _resolve_nickserv_password(
|
|
nickserv_cfg, account_id
|
|
)
|
|
|
|
channels = _merge_channels(cfg.channels, account_cfg.channels)
|
|
groups = _merge_groups(cfg.groups, account_cfg.groups)
|
|
dm_policy = account_cfg.dm_policy if account_cfg.host else cfg.dm_policy
|
|
allow_from = account_cfg.allow_from or cfg.allow_from
|
|
group_policy = account_cfg.group_policy if account_cfg.host else cfg.group_policy
|
|
group_allow_from = account_cfg.group_allow_from or cfg.group_allow_from
|
|
dangerously_allow_name_matching = (
|
|
account_cfg.dangerously_allow_name_matching or cfg.dangerously_allow_name_matching
|
|
)
|
|
|
|
resolved_config = IrcAccountConfig(
|
|
name=account_cfg.name or account_id,
|
|
enabled=account_cfg.enabled,
|
|
dangerously_allow_name_matching=dangerously_allow_name_matching,
|
|
host=host,
|
|
port=port,
|
|
tls=tls,
|
|
nick=nick,
|
|
username=username,
|
|
realname=realname,
|
|
password=password,
|
|
password_file=account_cfg.password_file or cfg.password_file,
|
|
nickserv=nickserv_cfg,
|
|
dm_policy=dm_policy,
|
|
allow_from=allow_from,
|
|
group_policy=group_policy,
|
|
group_allow_from=group_allow_from,
|
|
groups=groups,
|
|
channels=channels,
|
|
text_chunk_limit=account_cfg.text_chunk_limit,
|
|
block_streaming=account_cfg.block_streaming,
|
|
)
|
|
|
|
configured = bool(host and nick)
|
|
if not configured and account_id == "default":
|
|
env_host = os.environ.get("IRC_HOST")
|
|
env_nick = os.environ.get("IRC_NICK")
|
|
if env_host and env_nick:
|
|
configured = True
|
|
|
|
return ResolvedIrcAccount(
|
|
account_id=account_id,
|
|
enabled=account_cfg.enabled,
|
|
name=resolved_config.name,
|
|
configured=configured,
|
|
host=host,
|
|
port=port,
|
|
tls=tls,
|
|
nick=nick,
|
|
username=username,
|
|
realname=realname,
|
|
password=password,
|
|
password_source=password_source,
|
|
nickserv_password=nickserv_password,
|
|
nickserv_password_source=nickserv_password_source,
|
|
config=resolved_config,
|
|
)
|
|
|
|
|
|
def list_irc_account_ids(cfg: IrcConfig) -> list[str]:
|
|
if cfg.accounts:
|
|
return list(cfg.accounts.keys())
|
|
return ["default"]
|
|
|
|
|
|
def has_configured_state(cfg: IrcConfig) -> bool:
|
|
if cfg.host and cfg.nick:
|
|
return True
|
|
for acc in cfg.accounts.values():
|
|
if acc.host and acc.nick:
|
|
return True
|
|
if os.environ.get("IRC_HOST") and os.environ.get("IRC_NICK"):
|
|
return True
|
|
return False |