ForcePilot/backend/package/yuxi/channel/extensions/minecraft/client.py
Kris b3b6c95094 feat(channel): 添加 Minecraft 渠道扩展
新增 Minecraft 渠道扩展,支持在 Yuxi 平台中集成 Minecraft 游戏服务器渠道。

包含以下功能模块:
- client: Minecraft 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- auth: 认证管理
- accounts: 账户管理
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- protocol: Minecraft 协议处理
- rcon: RCON 远程控制
- keepalive: 连接保活
- version_adapter: 版本适配
- setup: 初始化设置
- types: 类型定义
2026-05-21 11:26:52 +08:00

115 lines
3.9 KiB
Python

import asyncio
import logging
import zlib
from collections.abc import Callable, Awaitable
from yuxi.channel.extensions.minecraft.protocol import (
read_varint,
write_varint,
write_packet_frame,
)
from yuxi.channel.extensions.minecraft.types import ConnectionState, McPacket
logger = logging.getLogger(__name__)
class McClient:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
self.reader: asyncio.StreamReader | None = None
self.writer: asyncio.StreamWriter | None = None
self.state: ConnectionState = ConnectionState.DISCONNECTED
self.compression_threshold: int = -1
self._running = False
self._on_packet: Callable[[McPacket], Awaitable[None]] | None = None
self._send_lock = asyncio.Lock()
@property
def is_connected(self) -> bool:
return self.writer is not None and not self.writer.is_closing()
async def connect(self) -> None:
self.reader, self.writer = await asyncio.open_connection(self.host, self.port)
logger.info("TCP connected to %s:%d", self.host, self.port)
self.state = ConnectionState.HANDSHAKE
async def disconnect(self) -> None:
self._running = False
if self.writer:
self.writer.close()
try:
await self.writer.wait_closed()
except Exception:
pass
self.writer = None
self.reader = None
self.state = ConnectionState.DISCONNECTED
def set_packet_handler(self, handler: Callable[[McPacket], Awaitable[None]]) -> None:
self._on_packet = handler
async def send_raw(self, data: bytes) -> None:
if not self.writer:
raise ConnectionError("Not connected")
self.writer.write(data)
await self.writer.drain()
async def send_packet(self, packet_id: int, data: bytes = b"") -> None:
frame = write_packet_frame(packet_id, data)
if self.compression_threshold >= 0:
uncompressed_length = len(frame)
if uncompressed_length >= self.compression_threshold:
compressed_data = zlib.compress(frame)
frame = write_varint(uncompressed_length) + compressed_data
else:
frame = write_varint(0) + frame
async with self._send_lock:
await self.send_raw(frame)
async def recv_packet(self) -> McPacket:
if not self.reader:
raise ConnectionError("Not connected")
raw_length = bytearray()
while True:
byte = await self.reader.readexactly(1)
raw_length.append(byte[0])
if not (byte[0] & 0x80):
break
if len(raw_length) > 5:
raise ValueError("Packet length VarInt too large")
packet_length, _ = read_varint(bytes(raw_length))
packet_data = await self.reader.readexactly(packet_length)
if self.compression_threshold >= 0:
data_length_val, d_len = read_varint(packet_data)
if data_length_val == 0:
payload = packet_data[d_len:]
else:
payload = zlib.decompress(packet_data[d_len:])
else:
payload = packet_data
packet_id, id_len = read_varint(payload)
return McPacket(packet_id=packet_id, data=payload[id_len:])
async def run_recv_loop(self) -> None:
self._running = True
while self._running and self.reader:
try:
packet = await self.recv_packet()
if self._on_packet:
await self._on_packet(packet)
except asyncio.IncompleteReadError:
logger.warning("MC connection closed by server")
break
except ConnectionError:
break
except Exception:
logger.exception("Error in MC recv loop")
break