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

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