ForcePilot/backend/package/yuxi/channels/adapters/irc/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

1014 lines
38 KiB
Python

from __future__ import annotations
import asyncio
import os
import time
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any, ClassVar
from dataclasses import dataclass, field
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelException,
)
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
HealthStatus,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.datetime_utils import utc_now_naive
from yuxi.utils.logging_config import logger
from ._isupport import ISupport
from .auth import (
get_server_password,
nick_recover,
nickserv_identify,
nickserv_register,
resolve_password,
sasl_authenticate,
sasl_external_authenticate,
sasl_scram_sha256_authenticate,
send_pass,
)
from .channel_runtime import ChannelRuntime
from .connection import IRCConnection
from .formatter import convert_markdown_tables, format_outbound
from .monitor import IRCMonitor
from .normalizer import normalize_inbound as _normalizer_inbound
from .normalizer import resolve_chat_id
from .probe import ping_probe
from .protocol import extract_nick, parse_irc_line
from .sanitize import sanitize_outbound_text
from .security import (
IRCSecurityConfig,
collect_security_warnings,
)
from .send import send_media_text, send_privmsg
from .send_cache import SentMessageCache
from .session import resolve_channel_user_id, resolve_thread_id
_JOIN_DELAY = 0.5
_DEFAULT_CONNECT_TIMEOUT = 30.0
_MAX_NICK_RETRIES = 3
_TOPIC_TIMEOUT = 5.0
_SENSITIVE_COMMANDS = ("AUTHENTICATE", "PASS", "PRIVMSG NickServ")
def _sanitize_irc_line(line: str) -> str:
upper = line.upper()
for cmd in _SENSITIVE_COMMANDS:
if cmd in upper:
return "[SENSITIVE]"
return line
@dataclass
class IrcStatus:
host: str = ""
port: int = 6697
tls: bool = True
nick: str = ""
connected: bool = False
configured: bool = False
probe_ok: bool | None = None
last_inbound_at: float | None = None
last_outbound_at: float | None = None
last_error: str | None = None
joined_channels: list[str] = field(default_factory=list)
account_id: str = "default"
def _env_or(key: str, default: Any, config: dict) -> Any:
env_map: dict[str, tuple[str, type]] = {
"server": ("IRC_HOST", str),
"port": ("IRC_PORT", int),
"use_tls": ("IRC_TLS", bool),
"nick": ("IRC_NICK", str),
"username": ("IRC_USERNAME", str),
"realname": ("IRC_REALNAME", str),
"password": ("IRC_PASSWORD", str),
"sasl_username": ("IRC_SASL_USERNAME", str),
"sasl_password": ("IRC_SASL_PASSWORD", str),
"nickserv_service": ("IRC_NICKSERV_SERVICE", str),
"nickserv_register_email": ("IRC_NICKSERV_REGISTER_EMAIL", str),
"nickserv_password": ("IRC_NICKSERV_PASSWORD", str),
"nickserv_password_file": ("IRC_NICKSERV_PASSWORD_FILE", str),
"server_password": ("IRC_SERVER_PASSWORD", str),
"server_password_file": ("IRC_SERVER_PASSWORD_FILE", str),
"dm_policy": ("IRC_DM_POLICY", str),
"group_policy": ("IRC_GROUP_POLICY", str),
"channels": ("IRC_CHANNELS", str),
}
env_entry = env_map.get(key)
if env_entry is None:
return config.get(key, default)
env_var, py_type = env_entry
env_val = os.environ.get(env_var, "")
if env_val:
if py_type is bool:
return env_val.lower() in ("1", "true", "yes")
if py_type is int:
try:
return int(env_val)
except ValueError:
return config.get(key, default)
return env_val
return config.get(key, default)
@register_builtin_adapter
class IRCAdapter(BaseChannelAdapter):
channel_id: ClassVar[str] = "irc"
channel_type: ClassVar[ChannelType] = ChannelType.IRC
webhook_path: ClassVar[str | None] = None
text_chunk_limit: ClassVar[int] = 350
supports_markdown: ClassVar[bool] = True
supports_media: ClassVar[bool] = True
supports_streaming: ClassVar[bool] = True
streaming_modes: ClassVar[list[str]] = ["off", "block"]
max_media_size_mb: ClassVar[int] = 100
capabilities = ChannelCapabilities(
chat_types=["direct", "group"],
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "block"],
text_chunk_limit=350,
max_media_size_mb=100,
)
meta = ChannelMeta(
id="irc",
label="IRC",
selection_label="IRC (Server + Nick)",
blurb="IRC 渠道适配器,支持 SASL/NickServ 认证、多账户、频道管理",
order=80,
docs_path="docs/channels/irc",
docs_label="IRC 文档",
system_image="number",
markdown_capable=True,
detail_label="IRC",
aliases=["internet-relay-chat"],
quickstart_allow_from=["*"],
)
MAX_CHANNELS: int = 20
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status = ChannelStatus.DISCONNECTED
self._server = _env_or("server", "irc.libera.chat", self.config)
self._port = _env_or("port", 6697, self.config)
self._use_tls = _env_or("use_tls", True, self.config)
self._connect_timeout = float(self.config.get("connect_timeout", _DEFAULT_CONNECT_TIMEOUT))
self._nick = _env_or("nick", "ForcePilotBot", self.config)
self._username = _env_or("username", self._nick, self.config)
self._realname = _env_or("realname", "ForcePilot IRC Bot", self.config)
self._password = _env_or("password", "", self.config)
self._sasl_username = _env_or("sasl_username", "", self.config)
self._sasl_password = _env_or("sasl_password", "", self.config)
self._use_sasl = self.config.get("use_sasl", True) and bool(self._sasl_username)
self._sasl_mechanism = self.config.get("sasl_mechanism", "PLAIN")
self._server_password = get_server_password(self.config)
self._nickserv_service = _env_or("nickserv_service", "NickServ", self.config)
self._nickserv_password = (
resolve_password(
password=_env_or("nickserv_password", "", self.config),
password_file=_env_or("nickserv_password_file", "", self.config),
env_var="IRC_NICKSERV_PASSWORD",
)
or self._password
)
self._nickserv_register = self.config.get("nickserv_register", False)
self._nickserv_register_email = _env_or("nickserv_register_email", "", self.config)
self._nickserv_enabled = bool(self._nickserv_password)
self._auto_join = self._resolve_auto_join()
self._nick_recovery = self.config.get("nick_recovery", True)
self.dm_policy = _env_or("dm_policy", "pairing", self.config)
self.group_policy = _env_or("group_policy", "allowlist", self.config)
self.allow_from = self.config.get("allowFrom", [])
self.group_allow_from = self.config.get("groupAllowFrom", [])
self.groups = self.config.get("groups", {})
self._reply_enabled = self.config.get("reply_enabled", False)
self._probe_timeout = float(self.config.get("probe_timeout", 8.0))
self._markdown_table_mode = self.config.get("markdown", {}).get("tableMode", "off")
self._chunker_mode = self.config.get("chunker_mode", "length")
self._block_streaming_coalesce = self.config.get(
"blockStreamingCoalesce",
)
if self._block_streaming_coalesce is None:
self._block_streaming_coalesce = {}
self._disable_block_streaming = self.config.get("disable_block_streaming", False)
self._streaming_buffer: dict[str, list[str]] = {}
self._streaming_timer: dict[str, asyncio.Task | None] = {}
self.security_config = IRCSecurityConfig(
dm_policy=self.dm_policy,
group_policy=self.group_policy,
allow_from=list(self.allow_from) if isinstance(self.allow_from, list) else [],
group_allow_from=list(self.group_allow_from) if isinstance(self.group_allow_from, list) else [],
groups=self.groups if isinstance(self.groups, dict) else {},
dangerously_allow_name_matching=self.config.get("dangerouslyAllowNameMatching", False),
group_allow_from_fallback_to_allow_from=self.config.get("groupAllowFromFallbackToAllowFrom", False),
)
self._connection = IRCConnection(
{
"server": self._server,
"port": self._port,
"use_tls": self._use_tls,
"connect_timeout": self._connect_timeout,
}
)
self._isupport = ISupport()
self._channel_runtime = ChannelRuntime()
self._monitor: IRCMonitor | None = None
self._ready_event = asyncio.Event()
self._ready_at: datetime | None = None
self._last_inbound_at: float | None = None
self._last_outbound_at: float | None = None
self._last_error: str | None = None
self._connected = False
self._disconnecting = False
self._sent_message_cache = SentMessageCache()
self._topic_futures: dict[str, asyncio.Future] = {}
self._account_id = config.get("_account_id", "default")
self._extra_caps: list[str] = []
self._delivery_tracker: Any = None
def _resolve_auto_join(self) -> list:
channels_env = _env_or("channels", "", self.config)
if isinstance(channels_env, str) and channels_env:
return [ch.strip() for ch in channels_env.split(",") if ch.strip()]
return self.config.get("auto_join_channels", [])
@property
def nick(self) -> str:
return self._nick
def mark_pong(self) -> None:
self._connection.mark_pong()
def _check_abort(self) -> None:
if self._disconnecting:
raise ChannelException("IRC adapter is disconnecting", retryable=False)
async def _do_sasl_auth(self, deadline: float) -> bool | None:
if self._sasl_mechanism == "EXTERNAL":
await sasl_external_authenticate(
self._connection.send_line,
self._connection.read_line,
timeout=max(1.0, deadline - time.monotonic()),
)
elif self._sasl_mechanism == "SCRAM-SHA-256":
await sasl_scram_sha256_authenticate(
self._connection.send_line,
self._connection.read_line,
self._sasl_username,
self._sasl_password,
timeout=max(1.0, deadline - time.monotonic()),
)
else:
await sasl_authenticate(
self._connection.send_line,
self._connection.read_line,
self._sasl_username,
self._sasl_password,
timeout=max(1.0, deadline - time.monotonic()),
)
return True
def send_line(self, line: str) -> None:
self._connection.send_line(line)
@property
def isupport(self) -> ISupport:
return self._isupport
async def read_line(self) -> str | None:
return await self._connection.read_line()
async def connect(self) -> None:
self._status = ChannelStatus.CONNECTING
self._ready_event.clear()
try:
await self._connection.connect()
if self._server_password:
await send_pass(self._connection.send_line, self._server_password)
self._send_cap_and_nickuser()
registered = False
sasl_done = False
nick_retries = 0
deadline = time.monotonic() + self._connect_timeout
while time.monotonic() < deadline:
line = await self._connection.read_line()
if line is None:
continue
logger.debug(f"IRC <: {_sanitize_irc_line(line)}")
_prefix, command, _args = parse_irc_line(line)
if command == "005":
self._isupport.feed_line(line)
continue
if command == "CAP" and "ACK" in line and self._use_sasl and not sasl_done:
await self._do_sasl_auth(deadline)
sasl_done = True
self._connection.send_line("CAP END")
await self._connection.drain()
continue
if command in ("376", "422"):
logger.info("IRC registration complete")
registered = True
break
if command == "433":
if nick_retries < _MAX_NICK_RETRIES:
nick_retries += 1
fallback_nick = f"{self._nick}_{nick_retries}"
logger.warning(f"IRC nick {self._nick} in use, trying {fallback_nick}")
self._nick = fallback_nick
self._connection.send_line(f"NICK {fallback_nick}")
await self._connection.drain()
continue
logger.warning(f"IRC nick {self._nick} in use after {_MAX_NICK_RETRIES} retries")
registered = True
break
if command == "436":
if nick_retries < _MAX_NICK_RETRIES:
nick_retries += 1
fallback_nick = f"{self._nick}_{nick_retries}"
logger.warning(f"IRC nick collision for {self._nick}, trying {fallback_nick}")
self._nick = fallback_nick
self._connection.send_line(f"NICK {fallback_nick}")
await self._connection.drain()
continue
raise ChannelAuthenticationError(
"IRC nick collision after retries",
retryable=False,
)
if command == "432":
raise ChannelAuthenticationError(
f"IRC erroneous nickname: {self._nick}",
retryable=False,
)
if command == "464":
raise ChannelAuthenticationError(
"IRC password required or incorrect",
retryable=False,
)
if command == "465":
raise ChannelAuthenticationError(
"IRC you are banned from this server",
retryable=False,
)
if command == "ERROR":
args_str = " ".join(_args)
raise ChannelException(f"IRC server error: {args_str}")
if not registered:
raise ChannelException("IRC registration timed out", retryable=True)
if not sasl_done and self._use_sasl and self._sasl_username:
await self._do_sasl_auth(time.monotonic() + 30.0)
elif self._nickserv_password:
await nickserv_identify(
self._connection.send_line,
self._nickserv_password,
service=self._nickserv_service,
)
if self._nickserv_register and self._nickserv_password:
await nickserv_register(
self._connection.send_line,
self._nickserv_password,
email=self._nickserv_register_email,
service=self._nickserv_service,
)
if self._nick_recovery and self._use_sasl:
await nick_recover(
self._connection.send_line,
self._nick,
self._sasl_password or self._nickserv_password,
service=self._nickserv_service,
)
await self._connection.drain()
await self._join_channels()
self._connection.on_reconnect(self._on_reconnect_handler)
self._monitor = IRCMonitor(self.config, self)
self._monitor.on_message(self._handle_message)
await self._monitor.start()
await self._connection.start_keepalive()
self._ready_event.set()
self._ready_at = utc_now_naive()
self._status = ChannelStatus.CONNECTED
self._connected = True
logger.info(
f"IRC adapter connected: {self._nick} @ {self._server}, "
f"joined {len(self._channel_runtime.joined_channels)} "
f"channels"
)
warnings = collect_security_warnings(
self.security_config,
use_tls=self._use_tls,
nickserv_enabled=self._nickserv_enabled,
)
for w in warnings:
logger.warning(f"IRC security warning: {w}")
except ChannelAuthenticationError:
self._status = ChannelStatus.ERROR
self._last_error = str(ChannelAuthenticationError.__name__)
raise
except Exception as e:
self._status = ChannelStatus.ERROR
self._last_error = str(e)
raise ChannelException(f"IRC connection failed: {e}", retryable=True)
async def disconnect(self) -> None:
self._disconnecting = True
self._status = ChannelStatus.DISCONNECTED
self._connected = False
self._ready_event.clear()
for key, timer in list(self._streaming_timer.items()):
if timer and not timer.done():
timer.cancel()
try:
await timer
except asyncio.CancelledError:
pass
self._streaming_timer.clear()
self._streaming_buffer.clear()
for channel, fut in list(self._topic_futures.items()):
if fut and not fut.done():
fut.set_exception(ChannelException("IRC disconnected", retryable=False))
self._topic_futures.clear()
if self._monitor:
await self._monitor.stop()
self._monitor = None
try:
self._connection.send_line("QUIT :ForcePilot shutting down")
await self._connection.drain()
except Exception:
pass
await self._connection.disconnect()
self._ready_at = None
logger.info("IRC adapter disconnected")
async def send(self, response: ChannelResponse) -> DeliveryResult:
self._check_abort()
if self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="IRC not connected")
try:
chat_id = response.identity.channel_chat_id or self._nick
text = sanitize_outbound_text(response.content)
text = convert_markdown_tables(text, self._markdown_table_mode)
reply_to_id = ""
if self._reply_enabled and response.reply_to_message_id:
reply_to_id = response.reply_to_message_id
await send_privmsg(
self._connection.send_line,
chat_id,
text,
nick=self._nick,
username=self._username,
server=self._server,
reply_to_id=reply_to_id,
chunker_mode=self._chunker_mode,
)
await self._connection.drain()
self._last_outbound_at = time.monotonic()
msg_id = response.identity.channel_message_id or ""
if msg_id:
self._sent_message_cache.put(msg_id, chat_id, text)
return DeliveryResult(success=True)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="IRC not connected")
try:
if media_type == "url" or (isinstance(data, str) and data.startswith(("http://", "https://"))):
url = data if isinstance(data, str) else str(data)
text = f"Attachment: {url}"
await send_media_text(
self._connection.send_line,
chat_id,
text,
nick=self._nick,
username=self._username,
server=self._server,
)
await self._connection.drain()
self._last_outbound_at = time.monotonic()
return DeliveryResult(success=True)
return DeliveryResult(
success=False,
error="IRC only supports URL attachments",
)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def receive(self) -> AsyncIterator[ChannelMessage]:
if False:
yield
def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage:
return _normalizer_inbound(raw, self.channel_id, self.channel_type, self._nick)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
return format_outbound(response)
async def health_check(self) -> HealthStatus:
if self._status != ChannelStatus.CONNECTED:
return HealthStatus(status="degraded", last_error="IRC not connected")
return await ping_probe(
self._connection.send_line,
self._connection.read_line,
self._server,
timeout=self._probe_timeout,
metadata={
"server": self._server,
"nick": self._nick,
"joined_channels": list(self._channel_runtime.joined_channels),
"tls": self._use_tls,
"dm_policy": self.dm_policy,
"group_policy": self.group_policy,
},
)
async def send_stream_chunk(
self,
chat_id: str,
message_id: str,
chunk_text: str,
finished: bool = False,
) -> DeliveryResult:
self._check_abort()
if self._status != ChannelStatus.CONNECTED:
return DeliveryResult(success=False, error="IRC not connected")
if self._disable_block_streaming:
text = sanitize_outbound_text(chunk_text)
text = convert_markdown_tables(text, self._markdown_table_mode)
await send_privmsg(
self._connection.send_line,
chat_id,
text,
nick=self._nick,
username=self._username,
server=self._server,
chunker_mode=self._chunker_mode,
)
await self._connection.drain()
self._last_outbound_at = time.monotonic()
return DeliveryResult(success=True)
coalesce_config = self._block_streaming_coalesce
coalesce_delay = coalesce_config.get("delay_ms", 0) / 1000.0 if isinstance(coalesce_config, dict) else 0
try:
text = sanitize_outbound_text(chunk_text)
text = convert_markdown_tables(text, self._markdown_table_mode)
if coalesce_delay > 0 and not finished:
key = f"{chat_id}:{message_id}"
self._streaming_buffer.setdefault(key, []).append(text)
if self._streaming_timer.get(key):
self._streaming_timer[key].cancel() # type: ignore[union-attr]
self._streaming_timer[key] = asyncio.create_task(self._flush_coalesced(key, chat_id, coalesce_delay))
return DeliveryResult(success=True)
if coalesce_delay > 0 and finished:
key = f"{chat_id}:{message_id}"
buffered = self._streaming_buffer.pop(key, [])
timer = self._streaming_timer.pop(key, None)
if timer:
timer.cancel()
text = "".join(buffered) + text if buffered else text
if finished:
final_text = text
else:
ellipsis = "\u2026"
ellipsis_bytes = len(ellipsis.encode("utf-8"))
chunk_bytes = text.encode("utf-8")
if len(chunk_bytes) < 30:
return DeliveryResult(success=True)
if len(chunk_bytes) + ellipsis_bytes > self.text_chunk_limit:
cut_bytes = self.text_chunk_limit - ellipsis_bytes
final_text = _truncate_utf8(chunk_bytes, cut_bytes) + ellipsis
else:
final_text = text + ellipsis
await send_privmsg(
self._connection.send_line,
chat_id,
final_text,
nick=self._nick,
username=self._username,
server=self._server,
chunker_mode=self._chunker_mode,
)
await self._connection.drain()
self._last_outbound_at = time.monotonic()
if finished and message_id:
self._sent_message_cache.put(message_id, chat_id, final_text)
return DeliveryResult(success=True)
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def _flush_coalesced(self, key: str, chat_id: str, delay: float) -> None:
await asyncio.sleep(delay)
buffered = self._streaming_buffer.pop(key, None)
self._streaming_timer.pop(key, None)
if not buffered:
return
combined = "".join(buffered)
try:
await send_privmsg(
self._connection.send_line,
chat_id,
combined,
nick=self._nick,
username=self._username,
server=self._server,
chunker_mode=self._chunker_mode,
)
await self._connection.drain()
self._last_outbound_at = time.monotonic()
except Exception as e:
logger.warning(f"IRC coalesce flush failed: {e}")
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
return {"nick": resolve_channel_user_id(channel_user_id)}
async def get_topic(self, channel: str) -> str | None:
if self._status != ChannelStatus.CONNECTED:
return None
try:
loop = asyncio.get_running_loop()
fut: asyncio.Future = loop.create_future()
self._topic_futures[channel.lower()] = fut
self._connection.send_line(f"TOPIC {channel}")
await self._connection.drain()
result = await asyncio.wait_for(fut, timeout=_TOPIC_TIMEOUT)
return result if result else None
except TimeoutError:
logger.warning(f"IRC get_topic timed out for {channel}")
return None
except Exception as e:
logger.warning(f"IRC get_topic failed for {channel}: {e}")
return None
finally:
self._topic_futures.pop(channel.lower(), None)
async def set_topic(self, channel: str, topic: str) -> bool:
if self._status != ChannelStatus.CONNECTED:
return False
try:
self._connection.send_line(f"TOPIC {channel} :{topic}")
await self._connection.drain()
logger.info(f"IRC set_topic for {channel}: {topic[:50]}...")
return True
except Exception as e:
logger.warning(f"IRC set_topic failed for {channel}: {e}")
return False
def _resolve_chat_id(self, target: str, sender_nick: str) -> str:
return resolve_chat_id(target, sender_nick)
def _resolve_channel_user_id(self, nick: str) -> str:
return resolve_channel_user_id(nick)
def _resolve_thread_id(self, agent_id: str, chat_id: str) -> str:
return resolve_thread_id(agent_id, chat_id)
def _handle_channel_event(self, command: str, prefix: str | None, args: list[str]) -> None:
if command == "JOIN":
channel = args[0] if args else ""
nick = extract_nick(prefix)
self._channel_runtime.handle_join(channel, nick)
elif command == "PART":
channel = args[0] if args else ""
nick = extract_nick(prefix)
self._channel_runtime.handle_part(channel, nick)
elif command == "KICK":
channel = args[0] if len(args) >= 1 else ""
kicked_nick = args[1] if len(args) >= 2 else ""
self._channel_runtime.handle_kick(channel, kicked_nick)
elif command == "QUIT":
nick = extract_nick(prefix)
reason = args[0] if args else ""
self._channel_runtime.handle_quit(nick)
logger.debug(f"IRC QUIT: {nick} ({reason})")
elif command == "NICK":
old_nick = extract_nick(prefix)
new_nick = args[0] if args else ""
self._channel_runtime.handle_nick_change(old_nick, new_nick)
elif command == "MODE":
channel = args[0] if args else ""
modes = " ".join(args[1:]) if len(args) > 1 else ""
self._channel_runtime.handle_mode(channel, modes)
logger.debug(f"IRC MODE {channel}: {modes}")
elif command == "TOPIC":
channel = args[0] if args else ""
topic = args[1] if len(args) >= 2 else ""
logger.debug(f"IRC TOPIC {channel}: {topic}")
elif command == "332":
if len(args) >= 3:
channel = args[1]
topic = args[2]
logger.debug(f"IRC TOPIC for {channel}: {topic}")
topic_fut = self._topic_futures.get(channel.lower())
if topic_fut and not topic_fut.done():
topic_fut.set_result(topic)
elif command == "333":
if len(args) >= 4:
channel = args[1]
who = args[2]
when = args[3]
logger.debug(f"IRC TOPIC set by {who} at {when} on {channel}")
topic_fut = self._topic_futures.get(channel.lower())
if topic_fut and not topic_fut.done():
topic_fut.set_result("")
elif command == "353":
if len(args) >= 4:
channel = args[2]
names_str = args[3]
self._channel_runtime.handle_names(channel, names_str)
elif command == "366":
channel = args[1] if len(args) >= 2 else ""
if channel:
logger.info(f"IRC NAMES complete for {channel}")
def _send_cap_and_nickuser(self) -> None:
self._connection.send_line("CAP LS 302")
caps = ["server-time"]
if self._use_sasl:
caps.append("sasl")
caps.extend(self._extra_caps)
self._connection.send_line(f"CAP REQ :{' '.join(caps)}")
self._connection.send_line(f"NICK {self._nick}")
self._connection.send_line(f"USER {self._username} 0 * :{self._realname}")
self._connection.drain()
def status(self) -> IrcStatus:
return IrcStatus(
host=self._server,
port=self._port,
tls=self._use_tls,
nick=self._nick,
connected=self._connected,
configured=bool(self._server and self._nick),
last_inbound_at=self._last_inbound_at,
last_outbound_at=self._last_outbound_at,
last_error=self._last_error,
joined_channels=list(self._channel_runtime.joined_channels),
account_id=self._account_id,
)
async def join_channel(self, channel: str) -> bool:
if self._status != ChannelStatus.CONNECTED:
return False
if not channel.startswith("#") and not channel.startswith("&"):
channel = f"#{channel}"
if channel.lower() in self._channel_runtime.joined_channels:
return True
self._connection.send_line(f"JOIN {channel}")
self._channel_runtime.add_channel(channel)
await self._connection.drain()
logger.info(f"IRC joined channel: {channel}")
return True
async def part_channel(self, channel: str, reason: str = "") -> bool:
if self._status != ChannelStatus.CONNECTED:
return False
if not channel.startswith("#") and not channel.startswith("&"):
channel = f"#{channel}"
if channel.lower() not in self._channel_runtime.joined_channels:
return False
cmd = f"PART {channel}"
if reason:
cmd += f" :{reason}"
self._connection.send_line(cmd)
self._channel_runtime.remove_channel(channel)
await self._connection.drain()
logger.info(f"IRC parted channel: {channel}")
return True
def set_extra_caps(self, caps: list[str]) -> None:
self._extra_caps = list(caps)
def resolve_topic_future(self, channel: str, topic: str) -> None:
fut = self._topic_futures.get(channel.lower())
if fut and not fut.done():
fut.set_result(topic)
async def _join_channels(self) -> None:
for channel_config in self._auto_join:
channel_name = channel_config if isinstance(channel_config, str) else channel_config.get("name", "")
if channel_name and (channel_name.startswith("#") or channel_name.startswith("&")):
self._connection.send_line(f"JOIN {channel_name}")
self._channel_runtime.add_channel(channel_name)
await asyncio.sleep(_JOIN_DELAY)
await self._connection.drain()
logger.info(f"IRC joined channels: {self._channel_runtime.joined_channels}")
async def _on_reconnect_handler(self) -> None:
caps_to_request = ["server-time"]
if self._use_sasl:
caps_to_request.append("sasl")
caps_to_request.extend(self._extra_caps)
self._connection.send_line("CAP LS 302")
self._connection.send_line(f"CAP REQ :{' '.join(caps_to_request)}")
self._connection.send_line(f"NICK {self._nick}")
self._connection.send_line(f"USER {self._username} 0 * :{self._realname}")
await self._connection.drain()
deadline = time.monotonic() + self._connect_timeout
registered = False
sasl_done = False
while time.monotonic() < deadline:
line = await self._connection.read_line()
if line is None:
continue
logger.debug(f"IRC <: {_sanitize_irc_line(line)}")
_prefix, command, _args = parse_irc_line(line)
if command == "CAP" and "ACK" in line and self._use_sasl and not sasl_done:
await self._do_sasl_auth(deadline)
sasl_done = True
self._connection.send_line("CAP END")
await self._connection.drain()
continue
if command in ("376", "422"):
registered = True
break
if command in ("433", "436"):
registered = True
break
if not registered:
logger.error("IRC re-registration timed out after reconnect")
return
if not sasl_done and self._use_sasl and self._sasl_username:
await self._do_sasl_auth(time.monotonic() + 30.0)
elif self._nickserv_password:
await nickserv_identify(
self._connection.send_line,
self._nickserv_password,
service=self._nickserv_service,
)
if self._nick_recovery and self._use_sasl:
await nick_recover(
self._connection.send_line,
self._nick,
self._sasl_password or self._nickserv_password,
service=self._nickserv_service,
)
await self._connection.drain()
await self._join_channels()
if self._monitor:
await self._monitor.stop()
self._monitor = IRCMonitor(self.config, self)
self._monitor.on_message(self._handle_message)
await self._monitor.start()
await self._connection.start_keepalive()
self._ready_event.set()
self._ready_at = utc_now_naive()
self._status = ChannelStatus.CONNECTED
logger.info("IRC adapter reconnected and channels rejoined")
async def reload_config(self, new_config: dict[str, Any]) -> None:
old_config = dict(self.config)
self.config.update(new_config)
self.dm_policy = _env_or("dm_policy", "pairing", self.config)
self.group_policy = _env_or("group_policy", "allowlist", self.config)
self.allow_from = self.config.get("allowFrom", [])
self.group_allow_from = self.config.get("groupAllowFrom", [])
self.groups = self.config.get("groups", {})
self.security_config = IRCSecurityConfig(
dm_policy=self.dm_policy,
group_policy=self.group_policy,
allow_from=list(self.allow_from) if isinstance(self.allow_from, list) else [],
group_allow_from=list(self.group_allow_from) if isinstance(self.group_allow_from, list) else [],
groups=self.groups if isinstance(self.groups, dict) else {},
dangerously_allow_name_matching=self.config.get("dangerouslyAllowNameMatching", False),
group_allow_from_fallback_to_allow_from=self.config.get("groupAllowFromFallbackToAllowFrom", False),
)
reconnect_keys = ("server", "port", "use_tls", "nick", "username", "password",
"sasl_username", "sasl_password", "sasl_mechanism")
need_reconnect = any(
new_config.get(k) != old_config.get(k) for k in reconnect_keys
)
if need_reconnect:
logger.info("IRC critical config changed, reconnecting...")
await self.disconnect()
await self.connect()
return
new_auto_join = self._resolve_auto_join()
old_channels = {
ch if isinstance(ch, str) else ch.get("name", "") for ch in self._auto_join
}
new_channels = {
ch if isinstance(ch, str) else ch.get("name", "") for ch in new_auto_join
}
for ch in new_channels - old_channels:
if ch:
await self.join_channel(ch)
for ch in old_channels - new_channels:
if ch:
await self.part_channel(ch)
self._auto_join = new_auto_join
logger.info("IRC config reloaded without reconnect")
def _truncate_utf8(data: bytes, max_bytes: int) -> str:
if len(data) <= max_bytes:
return data.decode("utf-8")
cut = max_bytes
while cut > 0 and (data[cut - 1] & 0xC0) == 0x80:
cut -= 1
if cut == 0:
cut = max(1, max_bytes)
return data[:cut].decode("utf-8")