ForcePilot/backend/package/yuxi/channels/adapters/irc/adapter.py
Kris 2dd1a075f2 feat(irc): 实现完整的IRC适配器基础功能模块
新增IRC协议相关的全套工具模块,包括:
- 核心协议解析与CTCP处理
- 消息发送缓存与文本 sanitize
- 账号配置管理与运行时状态
- 命令处理与权限控制
- 服务发现与诊断工具
- 多账号网关与配置加载
2026-05-12 00:45:10 +08:00

934 lines
35 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
@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")