新增IRC协议相关的全套工具模块,包括: - 核心协议解析与CTCP处理 - 消息发送缓存与文本 sanitize - 账号配置管理与运行时状态 - 命令处理与权限控制 - 服务发现与诊断工具 - 多账号网关与配置加载
934 lines
35 KiB
Python
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")
|