ForcePilot/backend/package/yuxi/channels/adapters/irc/adapter.py
Kris 4e07292a81 refactor(irc): 整理导入并修复代码风格
1.  调整导入顺序和移除多余空行
2.  重构IRC NAMES命令的成员解析逻辑,正确剥离所有前缀
3.  更新配置schema:修改默认消息块大小为350字节,新增disableBlockStreaming配置项
4.  完善CTCP处理:新增ACTION支持,提取CTCP动作文本
5.  新增IRC线程上下文模拟器类
6.  新增SRV记录解析支持,自动解析IRC服务器域名
7.  新增多种IRC通知处理:ACCOUNT、AWAY、INVITE、CAP
8.  重构消息发送逻辑,添加熔断器和重试机制
9.  重写流式消息合并逻辑,新增智能合并缓冲
10. 扩展IRC CAP支持,新增多个常用扩展能力
11. 修复配置读取逻辑,适配新的配置结构
2026-05-13 16:10:40 +08:00

1096 lines
42 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.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.sdk.retry import with_retry, RetryConfig
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.channels.utils.streaming_fallback import SmartCoalesceBuffer
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 = {}
streaming_cfg = self.config.get("streaming", {})
if isinstance(streaming_cfg, dict):
block_cfg = streaming_cfg.get("block", {})
if isinstance(block_cfg, dict) and block_cfg.get("coalesce"):
if "delay_ms" not in self._block_streaming_coalesce:
self._block_streaming_coalesce["delay_ms"] = block_cfg.get(
"coalesce_idle_ms", block_cfg.get("idle_ms", 1000)
)
if "min_chars" not in self._block_streaming_coalesce:
self._block_streaming_coalesce["min_chars"] = block_cfg.get(
"coalesce_min_chars", block_cfg.get("min_chars", 1500)
)
self._disable_block_streaming = self.config.get("disableBlockStreaming", False)
self._streaming_buffer: dict[str, list[str]] = {}
self._streaming_timer: dict[str, asyncio.Task | None] = {}
self._coalesce_buffers: dict[str, SmartCoalesceBuffer] = {}
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
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="irc")
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")
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
async def _do_send() -> DeliveryResult:
await with_retry(
lambda: 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,
),
config=RetryConfig(max_retries=2, base_delay=0.5, jitter=True),
)
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)
try:
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
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")
text = sanitize_outbound_text(chunk_text)
text = convert_markdown_tables(text, self._markdown_table_mode)
coalesce_config = self._block_streaming_coalesce
coalesce_enabled = (
bool(coalesce_config)
and isinstance(coalesce_config, dict)
and not self._disable_block_streaming
)
if coalesce_enabled:
return await self._send_coalesced(chat_id, message_id, text, finished, coalesce_config)
if self._disable_block_streaming or not coalesce_enabled:
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)
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)
async def _send_coalesced(
self,
chat_id: str,
message_id: str,
text: str,
finished: bool,
coalesce_config: dict,
) -> DeliveryResult:
key = f"{chat_id}:{message_id}"
coalesce_delay = coalesce_config.get("delay_ms", 1000) / 1000.0
min_chars = coalesce_config.get("min_chars", 1500)
if key not in self._coalesce_buffers:
self._coalesce_buffers[key] = SmartCoalesceBuffer(
min_chars=min_chars,
idle_ms=int(coalesce_delay * 1000),
max_wait_ms=5000,
)
buf = self._coalesce_buffers[key]
if finished:
buf.feed(text)
result = buf.flush()
self._coalesce_buffers.pop(key, None)
self._streaming_timer.pop(key, None)
if result:
await self._send_irc_text(chat_id, result)
if message_id:
self._sent_message_cache.put(message_id, chat_id, result)
return DeliveryResult(success=True)
result = buf.feed(text)
if result:
self._coalesce_buffers.pop(key, None)
await self._send_irc_text(chat_id, result)
return DeliveryResult(success=True)
if key not in self._streaming_timer or (self._streaming_timer.get(key) and self._streaming_timer[key].done()):
if key in self._streaming_timer and self._streaming_timer[key] and not self._streaming_timer[key].done():
self._streaming_timer[key].cancel()
self._streaming_timer[key] = asyncio.create_task(
self._flush_coalesce_buffer(key, chat_id, coalesce_delay)
)
return DeliveryResult(success=True)
async def _flush_coalesce_buffer(self, key: str, chat_id: str, delay: float) -> None:
await asyncio.sleep(delay)
buf = self._coalesce_buffers.pop(key, None)
self._streaming_timer.pop(key, None)
if buf is None:
return
flushed = buf.flush()
if flushed:
await self._send_irc_text(chat_id, flushed)
async def _send_irc_text(self, chat_id: str, text: str) -> None:
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()
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)
if len(args) >= 3:
account = args[1] if len(args) > 1 else None
realname = args[2] if len(args) > 2 else None
logger.debug(f"IRC extended-join: {nick} -> {channel} (account={account}, realname={realname})")
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",
"account-tag",
"message-ids",
"account-notify",
"away-notify",
"extended-join",
"multi-prefix",
"invite-notify",
"cap-notify",
]
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",
"account-tag",
"message-ids",
"account-notify",
"away-notify",
"extended-join",
"multi-prefix",
"invite-notify",
"cap-notify",
]
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")