新增 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: 类型定义
460 lines
18 KiB
Python
460 lines
18 KiB
Python
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import random
|
|
import struct
|
|
from collections.abc import Callable, Awaitable
|
|
|
|
from yuxi.channel.extensions.minecraft.client import McClient
|
|
from yuxi.channel.extensions.minecraft.protocol import (
|
|
write_varint,
|
|
write_string,
|
|
write_long,
|
|
read_string,
|
|
read_varint,
|
|
read_uuid,
|
|
)
|
|
from yuxi.channel.extensions.minecraft.version_adapter import get_adapter, VersionAdapter, DEFAULT_PROTOCOL
|
|
from yuxi.channel.extensions.minecraft.types import ConnectionState, ResolvedMcAccount, McPacket
|
|
from yuxi.channel.extensions.minecraft.keepalive import KeepAliveManager
|
|
from yuxi.channel.extensions.minecraft.monitor import mc_packet_to_unified
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MAX_RECONNECT_DELAY = 120.0
|
|
BASE_RECONNECT_DELAY = 1.0
|
|
|
|
|
|
class McGateway:
|
|
def __init__(self, account: ResolvedMcAccount):
|
|
self.account = account
|
|
self.client = McClient(account.host, account.port)
|
|
self.adapter: VersionAdapter | None = None
|
|
self.keepalive: KeepAliveManager | None = None
|
|
self.on_message: Callable[[object], Awaitable[None]] | None = None
|
|
self._reconnect_task: asyncio.Task | None = None
|
|
self._stop_event = asyncio.Event()
|
|
self._entity_id: int = 0
|
|
self._player_x: float = 0.0
|
|
self._player_y: float = 0.0
|
|
self._player_z: float = 0.0
|
|
self._player_name_cache: dict[str, str] = {}
|
|
self._player_cache: dict[str, dict] = {}
|
|
|
|
@property
|
|
def is_connected(self) -> bool:
|
|
return self.client.is_connected and self.client.state == ConnectionState.PLAY
|
|
|
|
async def connect(self) -> None:
|
|
self._stop_event.clear()
|
|
await self._do_connect_cycle()
|
|
if self._reconnect_task is None or self._reconnect_task.done():
|
|
self._reconnect_task = asyncio.create_task(self._reconnect_monitor())
|
|
|
|
async def _do_connect_cycle(self) -> None:
|
|
await self.client.connect()
|
|
await self._do_handshake()
|
|
await self._do_status_probe()
|
|
await self._do_login()
|
|
await self._enter_play()
|
|
|
|
async def disconnect(self) -> None:
|
|
self._stop_event.set()
|
|
if self._reconnect_task:
|
|
self._reconnect_task.cancel()
|
|
self._reconnect_task = None
|
|
if self.keepalive:
|
|
await self.keepalive.stop()
|
|
await self.client.disconnect()
|
|
|
|
async def _reconnect_monitor(self) -> None:
|
|
while not self._stop_event.is_set():
|
|
try:
|
|
await asyncio.wait_for(self._stop_event.wait(), timeout=5.0)
|
|
break
|
|
except TimeoutError:
|
|
pass
|
|
if self._stop_event.is_set():
|
|
break
|
|
if not self.client.is_connected:
|
|
logger.info("Minecraft connection lost, attempting reconnect...")
|
|
try:
|
|
await self.reconnect()
|
|
except Exception:
|
|
logger.exception("Minecraft reconnect failed")
|
|
|
|
async def reconnect(self) -> None:
|
|
delay = BASE_RECONNECT_DELAY
|
|
for attempt in range(self.account.reconnect_max_retries):
|
|
jitter = random.uniform(0, delay * 0.3)
|
|
wait = delay + jitter
|
|
logger.info(
|
|
"Minecraft reconnect attempt %d/%d in %.1fs",
|
|
attempt + 1,
|
|
self.account.reconnect_max_retries,
|
|
wait,
|
|
)
|
|
await asyncio.sleep(wait)
|
|
try:
|
|
await self.connect()
|
|
logger.info("Minecraft reconnected successfully (attempt %d)", attempt + 1)
|
|
return
|
|
except Exception:
|
|
logger.exception("Minecraft reconnect attempt %d failed", attempt + 1)
|
|
delay = min(delay * 2, MAX_RECONNECT_DELAY)
|
|
|
|
logger.error("Minecraft reconnect failed after %d attempts", self.account.reconnect_max_retries)
|
|
|
|
async def _do_handshake(self) -> None:
|
|
protocol_version = DEFAULT_PROTOCOL
|
|
if self.account.version not in ("auto", ""):
|
|
try:
|
|
protocol_version = int(self.account.version)
|
|
except ValueError:
|
|
protocol_version = DEFAULT_PROTOCOL
|
|
|
|
packet_id_bytes = write_varint(0x00)
|
|
data = (
|
|
write_varint(protocol_version)
|
|
+ write_string(self.account.host)
|
|
+ struct.pack(">H", self.account.port)
|
|
+ write_varint(2)
|
|
)
|
|
payload_length = len(packet_id_bytes) + len(data)
|
|
await self.client.send_raw(write_varint(payload_length) + packet_id_bytes + data)
|
|
self.client.state = ConnectionState.LOGIN
|
|
logger.info("Handshake sent (protocol %d, next_state=Login)", protocol_version)
|
|
|
|
async def _do_status_probe(self) -> None:
|
|
try:
|
|
temp_client = McClient(self.account.host, self.account.port)
|
|
await temp_client.connect()
|
|
|
|
# Handshake for Status state
|
|
handshake_data = (
|
|
write_varint(DEFAULT_PROTOCOL)
|
|
+ write_string(self.account.host)
|
|
+ struct.pack(">H", self.account.port)
|
|
+ write_varint(1)
|
|
)
|
|
h_pid = write_varint(0x00)
|
|
await temp_client.send_raw(write_varint(len(h_pid) + len(handshake_data)) + h_pid + handshake_data)
|
|
temp_client.state = ConnectionState.STATUS
|
|
|
|
# Status Request (0x00)
|
|
await temp_client.send_raw(write_varint(1) + write_varint(0x00))
|
|
|
|
packet = await temp_client.recv_packet()
|
|
if packet.packet_id != 0x00:
|
|
logger.warning("Expected Status Response (0x00), got 0x%02X", packet.packet_id)
|
|
await temp_client.disconnect()
|
|
return
|
|
|
|
json_str, _ = read_string(packet.data, 0)
|
|
status = json.loads(json_str)
|
|
version_info = status.get("version", {})
|
|
protocol_version = version_info.get("protocol", DEFAULT_PROTOCOL)
|
|
version_name = version_info.get("name", "unknown")
|
|
players_info = status.get("players", {})
|
|
online = players_info.get("online", 0)
|
|
max_players = players_info.get("max", 0)
|
|
|
|
self.adapter = get_adapter(protocol_version)
|
|
logger.info(
|
|
"Server: %s (protocol %d), Players: %d/%d",
|
|
version_name,
|
|
protocol_version,
|
|
online,
|
|
max_players,
|
|
)
|
|
|
|
ping_data = write_long(int(asyncio.get_event_loop().time() * 1000))
|
|
await temp_client.send_packet(0x01, ping_data)
|
|
|
|
pong = await temp_client.recv_packet()
|
|
if pong.packet_id == 0x01:
|
|
logger.debug("Ping/Pong OK, latency normal")
|
|
|
|
await temp_client.disconnect()
|
|
except Exception as e:
|
|
logger.warning("Status probe failed: %s (continuing with default protocol)", e)
|
|
self.adapter = get_adapter("auto")
|
|
|
|
async def _do_login(self) -> None:
|
|
username = self.account.username
|
|
|
|
if self.account.auth_mode == "microsoft":
|
|
await self._do_microsoft_login(username)
|
|
return
|
|
|
|
offline_uuid = hashlib.md5(f"OfflinePlayer:{username}".encode()).hexdigest()
|
|
uuid_bytes = bytes.fromhex(offline_uuid)
|
|
|
|
await self._send_login_start(username, uuid_bytes)
|
|
await self._handle_login_packets()
|
|
|
|
async def _do_microsoft_login(self, username: str) -> None:
|
|
from yuxi.channel.extensions.minecraft.auth import MinecraftAuth
|
|
|
|
auth = MinecraftAuth()
|
|
try:
|
|
result = await auth.authenticate()
|
|
username = result["username"]
|
|
uuid_str = result["uuid"]
|
|
self._ms_access_token = result.get("access_token")
|
|
finally:
|
|
await auth.close()
|
|
|
|
uuid_bytes = __import__("uuid").UUID(uuid_str).bytes
|
|
await self._send_login_start(username, uuid_bytes)
|
|
await self._handle_login_packets()
|
|
|
|
def _get_protocol_version(self) -> int:
|
|
if self.adapter:
|
|
return self.adapter.version
|
|
return DEFAULT_PROTOCOL
|
|
|
|
async def _send_login_start(self, username: str, uuid_bytes: bytes) -> None:
|
|
data = bytearray()
|
|
data += write_string(username)
|
|
if self._get_protocol_version() >= 764:
|
|
data += uuid_bytes
|
|
await self.client.send_packet(0x00, bytes(data))
|
|
logger.info("Login Start sent: username=%s", username)
|
|
|
|
async def _handle_login_packets(self) -> None:
|
|
while True:
|
|
packet = await self.client.recv_packet()
|
|
if packet.packet_id == 0x01:
|
|
logger.error("Server requested encryption — online-mode server rejects offline login")
|
|
raise RuntimeError("Server requires online-mode authentication (Microsoft OAuth not yet supported)")
|
|
elif packet.packet_id == 0x02:
|
|
offset = 0
|
|
uuid_str, consumed = read_uuid(packet.data, offset)
|
|
offset += consumed
|
|
server_username, _ = read_string(packet.data, offset)
|
|
logger.info("Login Success: uuid=%s username=%s", uuid_str, server_username)
|
|
self.client.state = ConnectionState.CONFIGURATION
|
|
break
|
|
elif packet.packet_id == 0x03:
|
|
try:
|
|
reason, _ = read_string(packet.data, 0)
|
|
logger.error("Login Disconnect: %s", reason)
|
|
except Exception:
|
|
logger.error("Login Disconnect (could not parse reason)")
|
|
raise RuntimeError("Login rejected by server")
|
|
elif packet.packet_id == 0x04:
|
|
await self._handle_login_plugin_request(packet.data)
|
|
else:
|
|
logger.error("Unexpected Login response: 0x%02X", packet.packet_id)
|
|
raise RuntimeError(f"Unexpected packet during login: 0x{packet.packet_id:02X}")
|
|
|
|
async def _handle_login_plugin_request(self, data: bytes) -> None:
|
|
message_id, offset = read_varint(data, 0)
|
|
channel, offset = read_string(data, offset)
|
|
logger.debug("Login Plugin Request: channel=%s", channel)
|
|
response = bytearray()
|
|
response += write_varint(message_id)
|
|
response += b"\x00"
|
|
await self.client.send_packet(0x02, bytes(response))
|
|
|
|
async def _enter_play(self) -> None:
|
|
self.client.state = ConnectionState.CONFIGURATION
|
|
logger.debug("Entered Configuration state")
|
|
|
|
while True:
|
|
packet = await self.client.recv_packet()
|
|
|
|
if packet.packet_id == 0x03:
|
|
threshold, _ = read_varint(packet.data, 0)
|
|
self.client.compression_threshold = threshold
|
|
logger.info("Compression enabled (threshold=%d)", threshold)
|
|
|
|
elif packet.packet_id == 0x02:
|
|
logger.debug("Configuration finished")
|
|
await self._send_client_information()
|
|
await self.client.send_packet(0x02)
|
|
break
|
|
|
|
elif packet.packet_id == 0x04:
|
|
keep_alive_id = struct.unpack(">q", packet.data[:8])[0]
|
|
response = write_long(keep_alive_id)
|
|
await self.client.send_packet(0x04, response)
|
|
logger.debug("Configuration Keep-Alive acknowledged")
|
|
|
|
elif packet.packet_id == 0x05:
|
|
ping_id = struct.unpack(">q", packet.data[:8])[0]
|
|
response = write_long(ping_id)
|
|
await self.client.send_packet(0x05, response)
|
|
logger.debug("Configuration Ping/Pong acknowledged")
|
|
|
|
elif packet.packet_id == 0x01:
|
|
channel, offset = read_string(packet.data, 0)
|
|
logger.debug("Configuration Plugin Message: channel=%s", channel)
|
|
if channel == "minecraft:brand":
|
|
response = bytearray()
|
|
response += write_string("minecraft:brand")
|
|
response += write_string("yuxi")
|
|
await self.client.send_packet(0x01, bytes(response))
|
|
|
|
elif packet.packet_id == 0x07:
|
|
logger.debug("Registry Data received")
|
|
|
|
elif packet.packet_id == 0x09:
|
|
logger.debug("Resource Pack Push received, accepting")
|
|
response = bytearray()
|
|
response += write_varint(0)
|
|
await self.client.send_packet(0x09, bytes(response))
|
|
|
|
else:
|
|
logger.debug("Unhandled Configuration packet: 0x%02X", packet.packet_id)
|
|
|
|
packet = await self.client.recv_packet()
|
|
login_play_id = self.adapter.cb("login_play") if self.adapter else 0x2B
|
|
if login_play_id is None:
|
|
login_play_id = 0x2B
|
|
if packet.packet_id == login_play_id:
|
|
self.client.state = ConnectionState.PLAY
|
|
logger.info("Entered Play state")
|
|
|
|
self.keepalive = KeepAliveManager(self.client)
|
|
await self.keepalive.start()
|
|
|
|
self.client.set_packet_handler(self._handle_packet)
|
|
asyncio.create_task(self.client.run_recv_loop())
|
|
else:
|
|
logger.error("Expected Login (Play, 0x%02X), got 0x%02X", login_play_id, packet.packet_id)
|
|
|
|
async def _handle_packet(self, packet: McPacket) -> None:
|
|
pid = packet.packet_id
|
|
|
|
keep_alive_cb = self.adapter.cb("keep_alive") if self.adapter else None
|
|
if keep_alive_cb is None:
|
|
keep_alive_cb = 0x26
|
|
if pid == keep_alive_cb:
|
|
keep_alive_id = struct.unpack(">q", packet.data[:8])[0]
|
|
keep_alive_sb = self.adapter.sb("keep_alive") if self.adapter else 0x18
|
|
if keep_alive_sb is None:
|
|
keep_alive_sb = 0x18
|
|
response = write_long(keep_alive_id)
|
|
await self.client.send_packet(keep_alive_sb, response)
|
|
if self.keepalive:
|
|
self.keepalive.record_keep_alive()
|
|
logger.debug("Keep-Alive: id=%d acknowledged", keep_alive_id)
|
|
return
|
|
|
|
player_chat_cb = self.adapter.cb("player_chat_message") if self.adapter else None
|
|
if player_chat_cb and pid == player_chat_cb:
|
|
if self.on_message:
|
|
unified = mc_packet_to_unified(packet, self.account)
|
|
if unified:
|
|
await self.on_message(unified)
|
|
return
|
|
|
|
system_chat_cb = self.adapter.cb("system_chat_message") if self.adapter else None
|
|
if system_chat_cb and pid == system_chat_cb:
|
|
if self.on_message:
|
|
unified = mc_packet_to_unified(packet, self.account)
|
|
if unified:
|
|
await self.on_message(unified)
|
|
return
|
|
|
|
player_info_cb = self.adapter.cb("player_info_update") if self.adapter else None
|
|
if player_info_cb and pid == player_info_cb:
|
|
await self._handle_player_info(packet.data)
|
|
return
|
|
|
|
respawn_cb = self.adapter.cb("respawn") if self.adapter else None
|
|
if respawn_cb and pid == respawn_cb:
|
|
logger.debug("Respawn packet received, re-entering dimension")
|
|
if self.keepalive:
|
|
await self.keepalive.stop()
|
|
await self.keepalive.start()
|
|
return
|
|
|
|
disconnect_cb = self.adapter.cb("disconnect") if self.adapter else None
|
|
if disconnect_cb is None:
|
|
disconnect_cb = 0x1D
|
|
if pid == disconnect_cb:
|
|
reason = "Unknown"
|
|
try:
|
|
reason, _ = read_string(packet.data, 0)
|
|
except Exception:
|
|
pass
|
|
logger.warning("Disconnected by server: %s", reason)
|
|
if self.on_message:
|
|
unified = mc_packet_to_unified(packet, self.account)
|
|
if unified:
|
|
await self.on_message(unified)
|
|
await self.client.disconnect()
|
|
if self.keepalive:
|
|
await self.keepalive.stop()
|
|
return
|
|
|
|
async def _handle_player_info(self, data: bytes) -> None:
|
|
import uuid as uuid_mod
|
|
|
|
if not data:
|
|
return
|
|
action = data[0]
|
|
offset = 1
|
|
count, offset = read_varint(data, offset)
|
|
|
|
for _ in range(count):
|
|
if offset + 16 > len(data):
|
|
break
|
|
uuid_bytes = data[offset : offset + 16]
|
|
offset += 16
|
|
uuid_str = str(uuid_mod.UUID(bytes=uuid_bytes))
|
|
|
|
if action == 0:
|
|
name, offset = read_string(data, offset)
|
|
prop_count, offset = read_varint(data, offset)
|
|
for __ in range(prop_count):
|
|
_, offset = read_string(data, offset)
|
|
_, offset = read_string(data, offset)
|
|
if offset < len(data) and data[offset]:
|
|
offset += 1
|
|
sig_len, offset = read_varint(data, offset)
|
|
offset += sig_len
|
|
else:
|
|
offset += 1
|
|
if offset + 1 > len(data):
|
|
break
|
|
gamemode, offset = read_varint(data, offset)
|
|
ping, offset = read_varint(data, offset)
|
|
if offset < len(data) and data[offset]:
|
|
offset += 1
|
|
_, offset = read_string(data, offset)
|
|
else:
|
|
offset += 1
|
|
if offset < len(data) and data[offset]:
|
|
offset += 1
|
|
_, offset = read_string(data, offset)
|
|
else:
|
|
offset += 1
|
|
self._player_cache[uuid_str] = {"name": name, "ping": ping, "gamemode": gamemode}
|
|
logger.debug("Player joined: %s (uuid=%s)", name, uuid_str)
|
|
|
|
elif action == 4:
|
|
removed = self._player_cache.pop(uuid_str, None)
|
|
if removed:
|
|
logger.debug("Player left: %s", removed["name"])
|
|
|
|
else:
|
|
break
|
|
|
|
async def _send_client_information(self) -> None:
|
|
data = bytearray()
|
|
data += write_string("en_US")
|
|
data += b"\x08"
|
|
data += b"\x01"
|
|
data += b"\x7f"
|
|
data += b"\x00"
|
|
data += b"\x01"
|
|
data += b"\x01"
|
|
data += write_varint(0)
|
|
await self.client.send_packet(0x00, bytes(data))
|
|
logger.debug("Client Information sent")
|