新增IRC协议相关的全套工具模块,包括: - 核心协议解析与CTCP处理 - 消息发送缓存与文本 sanitize - 账号配置管理与运行时状态 - 命令处理与权限控制 - 服务发现与诊断工具 - 多账号网关与配置加载
205 lines
5.8 KiB
Python
205 lines
5.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ssl
|
|
from collections.abc import Callable
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
from .splits import calc_effective_limit, split_markdown, split_utf8
|
|
|
|
_FLOOD_BURST = 4
|
|
_FLOOD_DELAY = 0.8
|
|
|
|
|
|
def send_raw_line(
|
|
send_fn: Callable[[str], None],
|
|
line: str,
|
|
) -> None:
|
|
send_fn(line)
|
|
|
|
|
|
async def send_privmsg(
|
|
send_fn: Callable[[str], None],
|
|
target: str,
|
|
text: str,
|
|
nick: str = "",
|
|
username: str = "",
|
|
server: str = "",
|
|
reply_to_id: str = "",
|
|
reply_to_nick: str = "",
|
|
chunker_mode: str = "length",
|
|
) -> None:
|
|
if reply_to_id:
|
|
text = f"[reply:{reply_to_id}] {text}"
|
|
elif reply_to_nick:
|
|
text = f"<{reply_to_nick}> {text}"
|
|
limit = calc_effective_limit(target, nick, username, server)
|
|
if chunker_mode == "markdown":
|
|
chunks = split_markdown(text, limit)
|
|
else:
|
|
chunks = split_utf8(text, limit)
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
if i >= _FLOOD_BURST:
|
|
await asyncio.sleep(_FLOOD_DELAY)
|
|
send_raw_line(send_fn, f"PRIVMSG {target} :{chunk}")
|
|
|
|
|
|
async def send_media_text(
|
|
send_fn: Callable[[str], None],
|
|
target: str,
|
|
text: str,
|
|
nick: str = "",
|
|
username: str = "",
|
|
server: str = "",
|
|
) -> None:
|
|
await send_privmsg(send_fn, target, text, nick, username, server)
|
|
|
|
|
|
def send_notice(
|
|
send_fn: Callable[[str], None],
|
|
target: str,
|
|
text: str,
|
|
) -> None:
|
|
send_raw_line(send_fn, f"NOTICE {target} :{text}")
|
|
|
|
|
|
def send_whois(
|
|
send_fn: Callable[[str], None],
|
|
nick: str,
|
|
) -> None:
|
|
send_raw_line(send_fn, f"WHOIS {nick}")
|
|
|
|
|
|
class DeliveryTracker:
|
|
def __init__(self, max_pending: int = 100):
|
|
self._pending: dict[str, asyncio.Future] = {}
|
|
self._max_pending = max_pending
|
|
|
|
def track(self, message_id: str) -> asyncio.Future:
|
|
if len(self._pending) >= self._max_pending:
|
|
oldest = next(iter(self._pending))
|
|
self._pending.pop(oldest, None)
|
|
future: asyncio.Future = asyncio.get_running_loop().create_future()
|
|
self._pending[message_id] = future
|
|
return future
|
|
|
|
def confirm(self, message_id: str) -> bool:
|
|
future = self._pending.pop(message_id, None)
|
|
if future and not future.done():
|
|
future.set_result(True)
|
|
return True
|
|
return False
|
|
|
|
def fail(self, message_id: str, error: str = "") -> bool:
|
|
future = self._pending.pop(message_id, None)
|
|
if future and not future.done():
|
|
future.set_exception(ConnectionError(error or "delivery failed"))
|
|
return True
|
|
return False
|
|
|
|
def pending_count(self) -> int:
|
|
return len(self._pending)
|
|
|
|
def clear(self) -> None:
|
|
for future in self._pending.values():
|
|
if not future.done():
|
|
future.cancel()
|
|
self._pending.clear()
|
|
|
|
|
|
async def send_message_irc(
|
|
server: str,
|
|
port: int,
|
|
use_tls: bool,
|
|
nick: str,
|
|
target: str,
|
|
text: str,
|
|
username: str = "",
|
|
realname: str = "",
|
|
password: str = "",
|
|
timeout: float = 12.0,
|
|
) -> bool:
|
|
|
|
async def _read_line(reader: asyncio.StreamReader) -> str:
|
|
try:
|
|
line = await asyncio.wait_for(reader.readline(), timeout=timeout)
|
|
return line.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
except TimeoutError:
|
|
return ""
|
|
|
|
try:
|
|
if use_tls:
|
|
ssl_context = ssl.create_default_context()
|
|
reader, writer = await asyncio.wait_for(
|
|
asyncio.open_connection(server, port, ssl=ssl_context),
|
|
timeout=timeout,
|
|
)
|
|
else:
|
|
reader, writer = await asyncio.wait_for(
|
|
asyncio.open_connection(server, port),
|
|
timeout=timeout,
|
|
)
|
|
|
|
def send_fn(line: str) -> None:
|
|
writer.write((line + "\r\n").encode("utf-8"))
|
|
|
|
if password:
|
|
send_fn(f"PASS {password}")
|
|
|
|
effective_user = username or nick
|
|
effective_realname = realname or nick
|
|
send_fn(f"NICK {nick}")
|
|
send_fn(f"USER {effective_user} 0 * :{effective_realname}")
|
|
|
|
deadline = asyncio.get_event_loop().time() + min(timeout, 10.0)
|
|
registered = False
|
|
while asyncio.get_event_loop().time() < deadline:
|
|
line = await _read_line(reader)
|
|
if not line:
|
|
continue
|
|
parts = line.split()
|
|
if len(parts) >= 2 and parts[1] in ("376", "422", "001", "002", "003", "004"):
|
|
registered = True
|
|
break
|
|
if len(parts) >= 2 and parts[0] == "PING":
|
|
send_fn(f"PONG {parts[1] if len(parts) > 1 else 'standalone'}")
|
|
if len(parts) >= 2 and parts[1] == "433":
|
|
logger.warning(f"send_message_irc: nick {nick} in use on {server}")
|
|
writer.close()
|
|
return False
|
|
|
|
if not registered:
|
|
writer.close()
|
|
return False
|
|
|
|
await asyncio.sleep(0.5)
|
|
|
|
effective_nick = nick
|
|
effective_user_name = username or nick
|
|
limit = calc_effective_limit(target, effective_nick, effective_user_name, server)
|
|
chunks = split_utf8(text, limit)
|
|
|
|
for i, chunk in enumerate(chunks):
|
|
if i >= _FLOOD_BURST:
|
|
await asyncio.sleep(_FLOOD_DELAY)
|
|
writer.write(f"PRIVMSG {target} :{chunk}\r\n".encode())
|
|
|
|
await writer.drain()
|
|
|
|
await asyncio.sleep(0.5)
|
|
writer.write(b"QUIT :send_message_irc done\r\n")
|
|
await writer.drain()
|
|
|
|
await asyncio.sleep(0.3)
|
|
writer.close()
|
|
return True
|
|
|
|
except TimeoutError:
|
|
logger.warning(f"send_message_irc: timed out connecting to {server}:{port}")
|
|
return False
|
|
except Exception as e:
|
|
logger.warning(f"send_message_irc: error connecting to {server}:{port}: {e}")
|
|
return False
|