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 @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 <: {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() 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 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 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 <: {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.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") 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")