diff --git a/backend/package/yuxi/channel/extensions/minecraft/__init__.py b/backend/package/yuxi/channel/extensions/minecraft/__init__.py new file mode 100644 index 00000000..34a76234 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/__init__.py @@ -0,0 +1,376 @@ +import logging + +from yuxi.channel.capabilities import ChannelCapabilities +from yuxi.channel.context import ChannelContext +from yuxi.channel.extensions.base import BaseChannelPlugin +from yuxi.channel.extensions.minecraft.accounts import ( + has_configured_state, + list_mc_account_ids, + resolve_mc_account, +) +from yuxi.channel.extensions.minecraft.config import McConfig +from yuxi.channel.extensions.minecraft.dedupe import ( + DEDUP_TTL, + MAX_DEDUP_ENTRIES, + is_duplicate as mc_is_duplicate, + mark_seen as mc_mark_seen, + reset as mc_reset_dedup, +) +from yuxi.channel.extensions.minecraft.gateway import McGateway +from yuxi.channel.extensions.minecraft.outbound import send_mc_chat +from yuxi.channel.extensions.minecraft.pairing import ( + generate_mc_pairing_code, + normalize_mc_allow_entry, + verify_mc_pairing_code, +) +from yuxi.channel.extensions.minecraft.security import ( + check_mc_allowlist, + check_rate_limit, + collect_mc_security_warnings, +) +from yuxi.channel.extensions.minecraft.setup import MinecraftSetupWizard +from yuxi.channel.extensions.minecraft.status import build_mc_status_summary, probe_mc_server +from yuxi.channel.extensions.minecraft.types import ResolvedMcAccount +from yuxi.channel.plugins.registry import ChannelPluginRegistry + +logger = logging.getLogger(__name__) + +_setup_wizard = MinecraftSetupWizard() + + +class McPlugin(BaseChannelPlugin): + id = "minecraft" + name = "Minecraft" + order = 90 + label = "Minecraft (服务器地址 + 用户名)" + aliases = ["mc-chat", "minecraft-chat"] + resolve_reply_to_mode = None + + def __init__(self): + self._config: dict = {} + self._gateway: McGateway | None = None + self._account: ResolvedMcAccount | None = None + + @property + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=["group"], + message_types=["text"], + interactions=[], + reactions=False, + typing_indicator=False, + threads=False, + edit=False, + unsend=False, + reply=False, + media=False, + native_commands=False, + polls=False, + streaming=True, + streaming_mode="block", + block_streaming=True, + block_streaming_chunk_min_chars=200, + block_streaming_chunk_max_chars=256, + ) + + # ── ConfigProtocol ────────────────────────────────── + + def list_account_ids(self, config: dict) -> list[str]: + mc_cfg = config.get("channels", {}).get("minecraft", {}) + mc_config = McConfig(**mc_cfg) + return list_mc_account_ids(mc_config) + + async def resolve_account(self, account_id: str) -> dict: + cfg = getattr(self, "_config", None) or {} + mc_cfg = cfg.get("channels", {}).get("minecraft", {}) + mc_config = McConfig(**mc_cfg) + account = resolve_mc_account(mc_config, account_id) + return { + "account_id": account.account_id, + "enabled": account.enabled, + "host": account.host, + "port": account.port, + "version": account.version, + "auth_mode": account.auth_mode, + "username": account.username, + "configured": account.is_configured, + "group_policy": account.group_policy, + "require_mention": account.require_mention, + "allow_from": account.allow_from, + } + + def is_configured(self, account: dict) -> bool: + return bool(account.get("host") and account.get("configured")) + + def has_configured_state(self, config: dict) -> bool: + mc_cfg = config.get("channels", {}).get("minecraft", {}) + mc_config = McConfig(**mc_cfg) + return has_configured_state(mc_config) + + def config_schema(self) -> dict: + from yuxi.channel.extensions.minecraft.config import build_config_schema + + return build_config_schema() + + # ── GatewayProtocol ───────────────────────────────── + + async def start(self, ctx: ChannelContext) -> object: + self._config = ctx.config + mc_cfg = ctx.config.get("channels", {}).get("minecraft", {}) + mc_config = McConfig(**mc_cfg) + account = resolve_mc_account(mc_config, ctx.account_id) + + if not account.is_configured: + raise RuntimeError(f"Minecraft account '{ctx.account_id}' not configured (host + username required)") + + self._account = account + self._gateway = McGateway(account) + + async def on_inbound(unified_msg): + if unified_msg is None: + return + if not _check_mc_security(unified_msg, account, ctx.logger or logger): + return + if ctx.queue is not None: + await ctx.queue.put(unified_msg) + + self._gateway.on_message = on_inbound + await self._gateway.connect() + + logger.info( + "Minecraft gateway started for %s on %s:%d", + account.username, + account.host, + account.port, + ) + return self._gateway + + async def stop(self, ctx: ChannelContext) -> None: + if self._gateway is not None: + await self._gateway.disconnect() + self._gateway = None + logger.info("Minecraft gateway stopped") + + async def execute_rcon(self, command: str) -> str: + account = self._account + if not account or not account.rcon_password: + return "RCON not configured (rcon_password missing)" + from yuxi.channel.extensions.minecraft.rcon import McRconClient + + client = McRconClient(account.host, account.rcon_port, account.rcon_password) + result = await client.execute(command) + await client.close() + return result + + def get_agent_tools(self) -> list: + return [ + { + "name": "minecraft_rcon", + "description": "Execute an RCON command on the Minecraft server", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The Minecraft command to execute (e.g. 'list', 'say Hello')", + } + }, + "required": ["command"], + }, + }, + ] + + async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict: + if tool_name == "minecraft_rcon": + command = params.get("command", "") + if not command: + return {"success": False, "error": "Command is required"} + result = await self.execute_rcon(command) + return {"success": True, "result": result} + return {"success": False, "error": f"Unknown tool: {tool_name}"} + + # ── OutboundProtocol ──────────────────────────────── + + async def send_text( + self, + target_id: str, + content: str, + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + ) -> None: + gateway = self._gateway + if gateway is None: + logger.error("Minecraft gateway not available for send_text") + return + await send_mc_chat(gateway, content) + + async def send_media( + self, + target_id: str, + media_url: str, + media_type: str = "file", + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + ) -> None: + gateway = self._gateway + if gateway is None: + return + await send_mc_chat(gateway, f"[{media_type}] {media_url}") + + # ── StatusProtocol ────────────────────────────────── + + async def probe(self, account: dict | None = None) -> bool: + if account is not None: + result = await probe_mc_server( + account.get("host", ""), + account.get("port", 25565), + account.get("version", "auto"), + ) + return result.get("reachable", False) + if self._gateway is not None: + return self._gateway.is_connected + return False + + def build_summary(self, snapshot: object) -> dict: + return build_mc_status_summary(snapshot) + + # ── SecurityProtocol ──────────────────────────────── + + async def check_allowlist(self, peer_id: str, channel_type: str) -> bool: + account = self._account + if account is None: + return False + return check_mc_allowlist(peer_id, account.allow_from) + + def resolve_dm_policy(self) -> dict: + account = self._account + if account is None: + return {"mode": "pairing", "allow_from": []} + return { + "mode": account.dm_policy, + "allow_from": account.allow_from, + } + + def collect_warnings(self, config: dict, account_id: str | None = None, account: dict | None = None) -> list[str]: + acct = self._account + if acct is None and account is not None: + host = account.get("host", "") + if not host: + return ["Minecraft server address is not configured"] + if acct is not None: + return collect_mc_security_warnings(acct) + return [] + + # ── PairingProtocol ───────────────────────────────── + + @property + def id_label(self) -> str: + return "mcUsername" + + async def generate_code(self, peer_id: str) -> str: + return await generate_mc_pairing_code(peer_id) + + async def verify_code(self, peer_id: str, code: str) -> bool: + return await verify_mc_pairing_code(peer_id, code) + + def normalize_allow_entry(self, entry: str) -> str: + return normalize_mc_allow_entry(entry) + + # ── GroupsProtocol ────────────────────────────────── + + def resolve_require_mention(self, ctx) -> bool | None: + account = self._account + if account is None: + return True + return account.require_mention + + # ── LifecycleProtocol ─────────────────────────────── + + @property + def config_prefixes(self) -> list[str]: + return ["channels.minecraft"] + + async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None: + self._config = next_cfg + + # ── SetupWizardProtocol ───────────────────────────── + + def setup_wizard_steps(self) -> list: + return _setup_wizard.setup_wizard_steps() + + async def validate_wizard_input(self, step_key: str, value: str) -> str | None: + return _setup_wizard.validate_wizard_input(step_key, value) + + # ── DedupeProtocol ────────────────────────────────── + + def is_duplicate(self, key: str) -> bool: + try: + msg_index = int(key) + except (ValueError, TypeError): + return False + return mc_is_duplicate(msg_index) + + def mark_seen(self, key: str) -> None: + try: + msg_index = int(key) + except (ValueError, TypeError): + return + mc_mark_seen(msg_index) + + def reset(self) -> None: + mc_reset_dedup() + + @property + def ttl_seconds(self) -> int: + return int(DEDUP_TTL) + + @property + def max_entries(self) -> int: + return MAX_DEDUP_ENTRIES + + +def _check_mc_security( + unified_msg, + account: ResolvedMcAccount, + log, +) -> bool: + sender_id = getattr(unified_msg, "sender", None) + sender_name = getattr(sender_id, "username", "") or getattr(sender_id, "display_name", "") or "" + + if unified_msg.group is None: + if account.dm_policy == "disabled": + log.debug("Minecraft DM rejected: DM policy is disabled") + return False + if account.dm_policy == "allowlist": + if not check_mc_allowlist(sender_name, account.allow_from): + log.debug("Minecraft DM rejected: '%s' not in allowlist", sender_name) + return False + return True + + policy = account.group_policy + if policy == "disabled": + log.debug("Minecraft group message rejected: group policy is disabled") + return False + + was_mentioned = getattr(unified_msg, "was_mentioned", False) + if account.require_mention and not was_mentioned: + log.debug("Minecraft group message rejected: @mention required") + return False + + if policy == "allowlist": + if not check_mc_allowlist(sender_name, account.allow_from): + log.debug("Minecraft group message rejected: '%s' not in allowlist", sender_name) + return False + + if not check_rate_limit(sender_name, window=account.rate_limit_window, max_msgs=account.rate_limit_max): + log.debug("Minecraft message rejected: rate limit exceeded for '%s'", sender_name) + return False + + return True + + +mc_plugin = ChannelPluginRegistry.register(McPlugin()) \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/minecraft/accounts.py b/backend/package/yuxi/channel/extensions/minecraft/accounts.py new file mode 100644 index 00000000..5799def8 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/accounts.py @@ -0,0 +1,85 @@ +import logging + +from yuxi.channel.extensions.minecraft.config import McAccountConfig, McConfig +from yuxi.channel.extensions.minecraft.types import ResolvedMcAccount + +logger = logging.getLogger(__name__) + + +def list_mc_account_ids(cfg: McConfig) -> list[str]: + if cfg.accounts: + return [acc.account_id for acc in cfg.accounts] + return ["default"] + + +def resolve_mc_account(cfg: McConfig, account_id: str = "default") -> ResolvedMcAccount: + account_cfg = cfg.get_account(account_id) + if account_cfg is None: + if account_id == "default": + account_cfg = McAccountConfig( + host=cfg.host, + port=cfg.port, + version=cfg.version, + auth_mode=cfg.auth_mode, + username=cfg.username, + dm_policy=cfg.dm_policy, + group_policy=cfg.group_policy, + require_mention=cfg.require_mention, + allow_from=cfg.allow_from, + text_chunk_limit=cfg.text_chunk_limit, + reconnect_max_retries=cfg.reconnect_max_retries, + keep_alive_timeout=cfg.keep_alive_timeout, + rate_limit_window=cfg.rate_limit_window, + rate_limit_max=cfg.rate_limit_max, + ) + else: + raise KeyError(f"Account '{account_id}' not found") + + host = account_cfg.host or cfg.host + port = account_cfg.port or cfg.port + version = account_cfg.version if account_cfg.host else cfg.version + auth_mode = account_cfg.auth_mode if account_cfg.host else cfg.auth_mode + username = account_cfg.username or cfg.username + dm_policy = account_cfg.dm_policy if account_cfg.host else cfg.dm_policy + group_policy = account_cfg.group_policy if account_cfg.host else cfg.group_policy + require_mention = account_cfg.require_mention if account_cfg.host else cfg.require_mention + allow_from = account_cfg.allow_from or cfg.allow_from + text_chunk_limit = account_cfg.text_chunk_limit or cfg.text_chunk_limit + reconnect_max_retries = account_cfg.reconnect_max_retries or cfg.reconnect_max_retries + keep_alive_timeout = account_cfg.keep_alive_timeout or cfg.keep_alive_timeout + rate_limit_window = account_cfg.rate_limit_window or cfg.rate_limit_window + rate_limit_max = account_cfg.rate_limit_max or cfg.rate_limit_max + + return ResolvedMcAccount( + account_id=account_id, + enabled=account_cfg.enabled, + name=account_cfg.name or account_id, + host=host, + port=port, + version=version, + auth_mode=auth_mode, + username=username, + microsoft_email=account_cfg.microsoft_email, + microsoft_password=account_cfg.microsoft_password, + rcon_port=account_cfg.rcon_port or cfg.rcon_port, + rcon_password=account_cfg.rcon_password, + dm_enabled=account_cfg.dm_enabled, + dm_policy=dm_policy, + group_policy=group_policy, + require_mention=require_mention, + allow_from=allow_from, + text_chunk_limit=text_chunk_limit, + reconnect_max_retries=reconnect_max_retries, + keep_alive_timeout=keep_alive_timeout, + rate_limit_window=rate_limit_window, + rate_limit_max=rate_limit_max, + ) + + +def has_configured_state(cfg: McConfig) -> bool: + if cfg.host and cfg.username: + return True + for acc in cfg.accounts: + if acc.host and acc.username: + return True + return False diff --git a/backend/package/yuxi/channel/extensions/minecraft/auth.py b/backend/package/yuxi/channel/extensions/minecraft/auth.py new file mode 100644 index 00000000..f5ee4735 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/auth.py @@ -0,0 +1,92 @@ +import asyncio +import logging + +import httpx + +logger = logging.getLogger(__name__) + +MS_DEVICE_CODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode" +MS_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token" +XBOX_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate" +XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize" +MC_AUTH_URL = "https://api.minecraftservices.com/authentication/login_with_xbox" +MC_PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile" + +CLIENT_ID = "00000000402b5328" + + +class MinecraftAuth: + def __init__(self, email: str | None = None, password: str | None = None): + self.email = email + self.password = password + self._http = httpx.AsyncClient(timeout=30) + + async def close(self): + await self._http.aclose() + + async def authenticate(self) -> dict: + ms_token = await self._get_ms_token() + xbl_token, uhs = await self._auth_xbox_live(ms_token) + xsts_token, _ = await self._auth_xsts(xbl_token) + mc_token = await self._auth_minecraft(uhs, xsts_token) + profile = await self._get_minecraft_profile(mc_token) + return {"access_token": mc_token, "uuid": profile["id"], "username": profile["name"]} + + async def _get_ms_token(self) -> str: + device_resp = await self._http.post( + MS_DEVICE_CODE_URL, data={"client_id": CLIENT_ID, "scope": "XboxLive.signin offline_access"} + ) + device_data = device_resp.json() + user_code = device_data["user_code"] + device_code = device_data["device_code"] + interval = device_data.get("interval", 5) + logger.info("Microsoft login: visit https://microsoft.com/devicelogin and enter code: %s", user_code) + + while True: + await asyncio.sleep(interval) + token_resp = await self._http.post( + MS_TOKEN_URL, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "client_id": CLIENT_ID, + "device_code": device_code, + }, + ) + token_data = token_resp.json() + if "access_token" in token_data: + return token_data["access_token"] + if token_data.get("error") == "authorization_pending": + continue + raise RuntimeError(f"Microsoft OAuth failed: {token_data.get('error')}") + + async def _auth_xbox_live(self, ms_token: str) -> tuple[str, str]: + resp = await self._http.post( + XBOX_AUTH_URL, + json={ + "Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": f"d={ms_token}"}, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT", + }, + ) + data = resp.json() + return data["Token"], data["DisplayClaims"]["xui"][0]["uhs"] + + async def _auth_xsts(self, xbl_token: str) -> tuple[str, str]: + resp = await self._http.post( + XSTS_AUTH_URL, + json={ + "Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl_token]}, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT", + }, + ) + data = resp.json() + return data["Token"], data["DisplayClaims"]["xui"][0]["uhs"] + + async def _auth_minecraft(self, uhs: str, xsts_token: str) -> str: + resp = await self._http.post(MC_AUTH_URL, json={"identityToken": f"XBL3.0 x={uhs};{xsts_token}"}) + return resp.json()["access_token"] + + async def _get_minecraft_profile(self, mc_token: str) -> dict: + resp = await self._http.get(MC_PROFILE_URL, headers={"Authorization": f"Bearer {mc_token}"}) + return resp.json() diff --git a/backend/package/yuxi/channel/extensions/minecraft/client.py b/backend/package/yuxi/channel/extensions/minecraft/client.py new file mode 100644 index 00000000..5fe2ffa0 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/client.py @@ -0,0 +1,114 @@ +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 diff --git a/backend/package/yuxi/channel/extensions/minecraft/config.py b/backend/package/yuxi/channel/extensions/minecraft/config.py new file mode 100644 index 00000000..c930aacc --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/config.py @@ -0,0 +1,176 @@ +from pydantic import BaseModel, Field + + +class McAccountConfig(BaseModel): + account_id: str = "default" + enabled: bool = True + name: str = "" + host: str = Field(default="", description="Minecraft server address") + port: int = Field(default=25565, ge=1, le=65535) + version: str = Field(default="auto", description="auto | 1.20.4 | 1.21.4") + auth_mode: str = Field(default="offline", description="offline | microsoft | mojang") + username: str = Field(default="ForcePilotBot", description="Bot player name") + microsoft_email: str | None = None + microsoft_password: str | None = None + rcon_port: int = 25575 + rcon_password: str | None = None + dm_enabled: bool = False + dm_policy: str = Field(default="pairing", description="open | pairing | allowlist | disabled") + group_policy: str = Field(default="mention", description="always | mention | disabled") + require_mention: bool = True + allow_from: list[str] = Field(default_factory=list) + text_chunk_limit: int = Field(default=256, ge=100, le=256) + reconnect_max_retries: int = Field(default=5, ge=0, le=100) + keep_alive_timeout: int = Field(default=30, ge=10, le=120) + rate_limit_window: int = Field(default=10, ge=1, le=60) + rate_limit_max: int = Field(default=5, ge=1, le=20) + + @property + def is_configured(self) -> bool: + return bool(self.host and self.username) + + +class McConfig(BaseModel): + host: str = "" + port: int = 25565 + version: str = "auto" + auth_mode: str = "offline" + username: str = "ForcePilotBot" + dm_policy: str = "pairing" + group_policy: str = "mention" + require_mention: bool = True + allow_from: list[str] = Field(default_factory=list) + text_chunk_limit: int = 256 + reconnect_max_retries: int = 5 + keep_alive_timeout: int = 30 + rate_limit_window: int = 10 + rate_limit_max: int = 5 + accounts: list[McAccountConfig] = Field(default_factory=list) + + def get_account(self, account_id: str = "default") -> McAccountConfig | None: + for acc in self.accounts: + if acc.account_id == account_id: + return acc + return None + + +def build_config_schema() -> dict: + return { + "type": "object", + "properties": { + "channels.minecraft.host": { + "type": "string", + "description": "Minecraft server address", + }, + "channels.minecraft.port": { + "type": "integer", + "default": 25565, + "description": "Server port", + }, + "channels.minecraft.version": { + "type": "string", + "default": "auto", + "enum": ["auto", "1.20.4", "1.21", "1.21.1", "1.21.2", "1.21.3", "1.21.4", "1.21.5"], + "description": "Minecraft version (auto-detect by default)", + }, + "channels.minecraft.auth_mode": { + "type": "string", + "enum": ["offline", "microsoft", "mojang"], + "default": "offline", + "description": "Authentication mode", + }, + "channels.minecraft.username": { + "type": "string", + "default": "ForcePilotBot", + "description": "Bot player name in game", + }, + "channels.minecraft.group_policy": { + "type": "string", + "enum": ["always", "mention", "disabled"], + "default": "mention", + "description": "Group chat access policy", + }, + "channels.minecraft.require_mention": { + "type": "boolean", + "default": True, + "description": "Require @mention to respond in group chat", + }, + "channels.minecraft.allow_from": { + "type": "array", + "items": {"type": "string"}, + "description": "Allowlist of player usernames", + }, + "channels.minecraft.text_chunk_limit": { + "type": "integer", + "default": 256, + "description": "Max characters per Minecraft chat message", + }, + "channels.minecraft.reconnect_max_retries": { + "type": "integer", + "default": 5, + "description": "Maximum reconnection attempts", + }, + "channels.minecraft.accounts": { + "type": "array", + "description": "Named accounts for multiple servers", + "items": { + "type": "object", + "properties": { + "account_id": {"type": "string"}, + "name": {"type": "string"}, + "enabled": {"type": "boolean", "default": True}, + "host": {"type": "string", "description": "Minecraft server address"}, + "port": {"type": "integer", "default": 25565}, + "version": {"type": "string", "default": "auto"}, + "auth_mode": { + "type": "string", + "enum": ["offline", "microsoft", "mojang"], + "default": "offline", + }, + "username": {"type": "string", "default": "ForcePilotBot"}, + "dm_policy": { + "type": "string", + "enum": ["pairing", "allowlist", "open", "disabled"], + "default": "pairing", + }, + "rcon_port": { + "type": "integer", + "default": 25575, + }, + "rcon_password": { + "type": "string", + "format": "password", + }, + "microsoft_email": { + "type": "string", + }, + "microsoft_password": { + "type": "string", + "format": "password", + }, + "reconnect_max_retries": { + "type": "integer", + "default": 10, + "minimum": 0, + }, + "text_chunk_limit": { + "type": "integer", + "default": 256, + "minimum": 100, + "maximum": 256, + }, + "group_policy": { + "type": "string", + "enum": ["always", "mention", "disabled"], + "default": "mention", + }, + "require_mention": {"type": "boolean", "default": True}, + "allow_from": { + "type": "array", + "items": {"type": "string"}, + }, + }, + }, + }, + }, + } diff --git a/backend/package/yuxi/channel/extensions/minecraft/dedupe.py b/backend/package/yuxi/channel/extensions/minecraft/dedupe.py new file mode 100644 index 00000000..7b0a8ee5 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/dedupe.py @@ -0,0 +1,27 @@ +import time + +DEDUP_TTL = 30.0 +MAX_DEDUP_ENTRIES = 200 + +_dedup_cache: dict[int, float] = {} + + +def is_duplicate(message_index: int) -> bool: + now = time.monotonic() + if message_index in _dedup_cache: + if now - _dedup_cache[message_index] < DEDUP_TTL: + return True + _dedup_cache[message_index] = now + if len(_dedup_cache) > MAX_DEDUP_ENTRIES: + stale = [k for k, v in _dedup_cache.items() if now - v > DEDUP_TTL] + for k in stale: + del _dedup_cache[k] + return False + + +def mark_seen(message_index: int) -> None: + _dedup_cache[message_index] = time.monotonic() + + +def reset() -> None: + _dedup_cache.clear() \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/minecraft/format.py b/backend/package/yuxi/channel/extensions/minecraft/format.py new file mode 100644 index 00000000..eb93edfd --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/format.py @@ -0,0 +1,68 @@ +import re + +SECTION_SIGN = "\u00a7" +FORMAT_CODE_RE = re.compile(SECTION_SIGN + r"[0-9a-fk-or]", re.IGNORECASE) + + +def strip_mc_format_codes(text: str) -> str: + return FORMAT_CODE_RE.sub("", text) + + +_TRANSLATION_TEMPLATES = { + "chat.type.text": "<{0}> {1}", + "chat.type.announcement": "[{0}] {1}", + "multiplayer.player.joined": "{0} joined the game", + "multiplayer.player.left": "{0} left the game", + "death.attack.player": "{0} was slain by {1}", + "death.fell.accident.generic": "{0} fell from a high place", +} + + +def json_chat_to_plain(component) -> str: + if isinstance(component, str): + return component + + if isinstance(component, dict): + parts = [] + + text = component.get("text", "") + if text: + parts.append(text) + + translate = component.get("translate", "") + if translate: + with_parts = [json_chat_to_plain(c) for c in component.get("with", [])] + template = _TRANSLATION_TEMPLATES.get(translate) + if template: + parts.append(template.format(*with_parts)) + else: + parts.append(" ".join(with_parts)) + + extra = component.get("extra", []) + for item in extra: + parts.append(json_chat_to_plain(item)) + + return "".join(parts) + + if isinstance(component, list): + return "".join(json_chat_to_plain(item) for item in component) + + return str(component) + + +def markdown_to_mc_plain(text: str) -> str: + if not text: + return text + ss = SECTION_SIGN + text = re.sub(r"\*\*\*(.+?)\*\*\*", ss + "l" + ss + "o\\1" + ss + "r", text) + text = re.sub(r"\*\*(.+?)\*\*", ss + "l\\1" + ss + "r", text) + text = re.sub(r"\*(.+?)\*", ss + "o\\1" + ss + "r", text) + text = re.sub(r"~~(.+?)~~", ss + "m\\1" + ss + "r", text) + text = re.sub(r"___(.+?)___", ss + "l" + ss + "o\\1" + ss + "r", text) + text = re.sub(r"__(.+?)__", ss + "n\\1" + ss + "r", text) + text = re.sub(r"_(.+?)_", ss + "o\\1" + ss + "r", text) + text = re.sub(r"\[(.+?)\]\(.+?\)", ss + "9\\1" + ss + "r", text) + text = re.sub(r"`(.+?)`", ss + "7\\1" + ss + "r", text) + text = re.sub(r"#{1,6}\s*", "", text) + text = re.sub(r"[-*+]\s", "• ", text) + return text diff --git a/backend/package/yuxi/channel/extensions/minecraft/gateway.py b/backend/package/yuxi/channel/extensions/minecraft/gateway.py new file mode 100644 index 00000000..3f17038a --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/gateway.py @@ -0,0 +1,459 @@ +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") diff --git a/backend/package/yuxi/channel/extensions/minecraft/keepalive.py b/backend/package/yuxi/channel/extensions/minecraft/keepalive.py new file mode 100644 index 00000000..11eb080a --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/keepalive.py @@ -0,0 +1,73 @@ +import asyncio +import logging +import random + +from yuxi.channel.extensions.minecraft.protocol import write_bool, write_double + +logger = logging.getLogger(__name__) + +PLAYER_POSITION_INTERVAL = 60.0 +KEEP_ALIVE_TIMEOUT = 30.0 + + +class KeepAliveManager: + def __init__(self, client): + self.client = client + self._position_task: asyncio.Task | None = None + self._timeout_task: asyncio.Task | None = None + self._last_keep_alive = 0.0 + self._running = False + + async def start(self) -> None: + self._running = True + self._last_keep_alive = asyncio.get_event_loop().time() + self._position_task = asyncio.create_task(self._position_loop()) + self._timeout_task = asyncio.create_task(self._timeout_monitor()) + logger.info("KeepAliveManager started") + + async def stop(self) -> None: + self._running = False + if self._position_task: + self._position_task.cancel() + self._position_task = None + if self._timeout_task: + self._timeout_task.cancel() + self._timeout_task = None + logger.info("KeepAliveManager stopped") + + async def _position_loop(self) -> None: + x, y, z = 0.0, 64.0, 0.0 + + while self._running: + try: + await asyncio.sleep(PLAYER_POSITION_INTERVAL + random.uniform(-5, 5)) + + data = write_double(x) + write_double(y) + write_double(z) + write_bool(True) + await self.client.send_packet(0x1A, data) + logger.debug("Player position sent: (%.1f, %.1f, %.1f)", x, y, z) + + except asyncio.CancelledError: + break + except Exception: + logger.exception("Error sending player position") + + def record_keep_alive(self) -> None: + self._last_keep_alive = asyncio.get_event_loop().time() + + @property + def is_alive(self) -> bool: + elapsed = asyncio.get_event_loop().time() - self._last_keep_alive + return elapsed < KEEP_ALIVE_TIMEOUT + + async def _timeout_monitor(self) -> None: + while self._running: + try: + await asyncio.sleep(KEEP_ALIVE_TIMEOUT) + if not self.is_alive: + logger.warning("Keep-Alive timeout detected, connection may be dead") + if self.client and self.client.writer: + self.client.writer.close() + except asyncio.CancelledError: + break + except Exception: + logger.exception("Error in keep-alive timeout monitor") diff --git a/backend/package/yuxi/channel/extensions/minecraft/monitor.py b/backend/package/yuxi/channel/extensions/minecraft/monitor.py new file mode 100644 index 00000000..7cbda27e --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/monitor.py @@ -0,0 +1,245 @@ +import json +import logging +import uuid +from datetime import datetime, UTC + +from yuxi.channel.extensions.minecraft.dedupe import is_duplicate as check_dedup +from yuxi.channel.extensions.minecraft.protocol import ( + read_string, + read_uuid, + read_varint, + read_bool, + read_varint_at, + read_string_at, +) +from yuxi.channel.extensions.minecraft.format import strip_mc_format_codes, json_chat_to_plain +from yuxi.channel.extensions.minecraft.types import McPacket, ResolvedMcAccount +from yuxi.channel.message.models import GroupContext, MessageType, PeerInfo, UnifiedMessage +from yuxi.channel.routing.models import PeerKind + +logger = logging.getLogger(__name__) + + +def mc_packet_to_unified( + packet: McPacket, + account: ResolvedMcAccount, +) -> UnifiedMessage | None: + if packet.packet_id == 0x37: + return _parse_player_chat_1214(packet, account) + if packet.packet_id == 0x38: + return _parse_player_chat_1204(packet, account) + if packet.packet_id in (0x6B, 0x64): + return _parse_system_chat(packet, account) + return None + + +def _parse_player_chat_1214(packet: McPacket, account: ResolvedMcAccount) -> UnifiedMessage | None: + try: + offset = 0 + sender_uuid, consumed = read_uuid(packet.data, offset) + offset += consumed + msg_index, consumed = read_varint(packet.data, offset) + offset += consumed + _sig_present, consumed = read_bool(packet.data, offset) + offset += consumed + sender_name_len, consumed = read_varint(packet.data, offset) + offset += consumed + sender_name_bytes = packet.data[offset : offset + sender_name_len] + sender_name = sender_name_bytes.decode("utf-8", errors="replace") + offset += sender_name_len + + if check_dedup(msg_index): + return None + + content = _extract_chat_content_1214(packet.data, offset) + content = strip_mc_format_codes(content).strip() + if not content: + return None + + group_id = f"mc:{account.host}:{account.port}" + bot_username = account.username + + was_mentioned = bot_username.lower() in content.lower() if bot_username else False + + return UnifiedMessage( + msg_id=f"mc:chat:{msg_index}:{uuid.uuid4().hex[:8]}", + channel_type="minecraft", + account_id=account.account_id, + content=content, + sender=PeerInfo( + kind=PeerKind.USER, + id=sender_uuid, + display_name=sender_name, + username=sender_name, + ), + message_type=MessageType.TEXT, + group=GroupContext( + id=group_id, + name=account.host, + ), + timestamp=datetime.now(UTC), + raw_payload={ + "sender_uuid": sender_uuid, + "sender_name": sender_name, + "message_index": msg_index, + "world": account.default_dimension, + }, + metadata={ + "minecraft_server": account.host, + "minecraft_port": account.port, + "minecraft_world": account.default_dimension, + "minecraft_message_index": msg_index, + }, + surface="minecraft", + originating_channel="minecraft", + conversation_label=group_id, + was_mentioned=was_mentioned, + explicitly_mentioned_bot=was_mentioned, + ) + except Exception: + logger.exception("Failed to parse MC 1.21.4 player chat packet") + return None + + +def _parse_player_chat_1204(packet: McPacket, account: ResolvedMcAccount) -> UnifiedMessage | None: + try: + offset = 0 + + sender_uuid_bytes = packet.data[offset : offset + 16] + offset += 16 + sender_uuid = str(uuid.UUID(bytes=sender_uuid_bytes)) + + index, offset = read_varint_at(packet.data, offset) + + has_signature = bool(packet.data[offset]) + offset += 1 + if has_signature: + sig_len, offset = read_varint_at(packet.data, offset) + offset += sig_len + + sender_name, offset = read_string_at(packet.data, offset) + + content = _extract_chat_content_1204_at(packet.data, offset) + content = strip_mc_format_codes(content).strip() + if not content: + return None + + if check_dedup(index): + return None + + group_id = f"mc:{account.host}:{account.port}" + bot_username = account.username + + was_mentioned = bot_username.lower() in content.lower() if bot_username else False + + return UnifiedMessage( + msg_id=f"mc:chat:{index}:{uuid.uuid4().hex[:8]}", + channel_type="minecraft", + account_id=account.account_id, + content=content, + sender=PeerInfo( + kind=PeerKind.USER, + id=sender_uuid, + display_name=sender_name, + username=sender_name, + ), + message_type=MessageType.TEXT, + group=GroupContext( + id=group_id, + name=account.host, + ), + timestamp=datetime.now(UTC), + raw_payload={ + "sender_uuid": sender_uuid, + "sender_name": sender_name, + "message_index": index, + "world": account.default_dimension, + }, + metadata={ + "minecraft_server": account.host, + "minecraft_port": account.port, + "minecraft_world": account.default_dimension, + "minecraft_message_index": index, + }, + surface="minecraft", + originating_channel="minecraft", + conversation_label=group_id, + was_mentioned=was_mentioned, + explicitly_mentioned_bot=was_mentioned, + ) + except Exception: + logger.exception("Failed to parse MC 1.20.4 player chat packet") + return None + + +def _parse_system_chat(packet: McPacket, account: ResolvedMcAccount) -> UnifiedMessage | None: + try: + content, _ = read_string(packet.data, 0) + try: + parsed = json.loads(content) + plain_text = json_chat_to_plain(parsed) + except json.JSONDecodeError: + plain_text = content + + plain_text = strip_mc_format_codes(plain_text).strip() + if not plain_text: + return None + + group_id = f"mc:{account.host}:{account.port}" + + return UnifiedMessage( + msg_id=f"mc:system:{uuid.uuid4().hex}", + channel_type="minecraft", + account_id=account.account_id, + content=plain_text, + sender=PeerInfo( + kind=PeerKind.USER, + id="system", + display_name="[Minecraft]", + username="system", + is_bot=True, + ), + message_type=MessageType.EVENT, + group=GroupContext( + id=group_id, + name=account.host, + ), + timestamp=datetime.now(UTC), + metadata={ + "minecraft_server": account.host, + "minecraft_system_message": True, + }, + surface="minecraft", + originating_channel="minecraft", + conversation_label=group_id, + ) + except Exception: + logger.exception("Failed to parse MC system chat packet") + return None + + +def _extract_chat_content_1204(data: bytes) -> str: + content = read_string(data, 0)[0] + try: + parsed = json.loads(content) + return json_chat_to_plain(parsed) + except (json.JSONDecodeError, TypeError): + return content + + +def _extract_chat_content_1204_at(data: bytes, offset: int) -> str: + content = read_string(data, offset)[0] + try: + parsed = json.loads(content) + return json_chat_to_plain(parsed) + except (json.JSONDecodeError, TypeError): + return content + + +def _extract_chat_content_1214(data: bytes, offset: int) -> str: + content = read_string(data, offset)[0] + try: + parsed = json.loads(content) + return json_chat_to_plain(parsed) + except (json.JSONDecodeError, TypeError): + return content diff --git a/backend/package/yuxi/channel/extensions/minecraft/outbound.py b/backend/package/yuxi/channel/extensions/minecraft/outbound.py new file mode 100644 index 00000000..99a47235 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/outbound.py @@ -0,0 +1,45 @@ +import logging + +from yuxi.channel.extensions.minecraft.protocol import write_string +from yuxi.channel.extensions.minecraft.format import strip_mc_format_codes +from yuxi.channel.extensions.minecraft.streaming import ( + stream_block_minecraft, + MC_CHAT_MAX_CHARS, +) + +logger = logging.getLogger(__name__) + + +async def send_mc_chat(gateway, content: str) -> None: + clean = strip_mc_format_codes(content) + + if len(clean) <= MC_CHAT_MAX_CHARS: + await _send_single_chat(gateway, clean) + return + + async def send_one(chunk: str): + await _send_single_chat(gateway, chunk) + + await stream_block_minecraft(send_one, clean) + + +async def _send_single_chat(gateway, text: str) -> None: + if not gateway or not gateway.client: + logger.error("Gateway not available for chat send") + return + + stripped = strip_mc_format_codes(text) + if not stripped: + return + + adapter = gateway.adapter + if stripped.startswith("/"): + packet_id = adapter.sb("chat_command") if adapter else 0x04 + else: + packet_id = adapter.sb("chat_message") if adapter else 0x05 + if packet_id is None: + packet_id = 0x05 + + data = write_string(stripped) + await gateway.client.send_packet(packet_id, data) + logger.debug("MC chat sent: %s", text[:50]) diff --git a/backend/package/yuxi/channel/extensions/minecraft/pairing.py b/backend/package/yuxi/channel/extensions/minecraft/pairing.py new file mode 100644 index 00000000..b70285b8 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/pairing.py @@ -0,0 +1,38 @@ +import logging +import secrets +import time + +logger = logging.getLogger(__name__) + +_PAIRING_CODES: dict[str, tuple[str, float]] = {} +_CODE_TTL_SECONDS = 300 + + +async def generate_mc_pairing_code(peer_id: str) -> str: + code = f"{secrets.randbelow(1_000_000):06d}" + _PAIRING_CODES[peer_id] = (code, time.monotonic()) + logger.info("MC pairing code generated for peer %s", peer_id) + return code + + +async def verify_mc_pairing_code(peer_id: str, code: str) -> bool: + stored = _PAIRING_CODES.get(peer_id) + if stored is None: + return False + stored_code, created_at = stored + if time.monotonic() - created_at > _CODE_TTL_SECONDS: + _PAIRING_CODES.pop(peer_id, None) + return False + if not secrets.compare_digest(stored_code, code): + return False + _PAIRING_CODES.pop(peer_id, None) + logger.info("MC pairing code verified for peer %s", peer_id) + return True + + +def normalize_mc_allow_entry(entry: str) -> str: + stripped = entry.strip() + for prefix in ("minecraft:", "mc:"): + if stripped.lower().startswith(prefix): + stripped = stripped[len(prefix) :] + return stripped.lower() diff --git a/backend/package/yuxi/channel/extensions/minecraft/plugin.json b/backend/package/yuxi/channel/extensions/minecraft/plugin.json new file mode 100644 index 00000000..ea03956b --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/plugin.json @@ -0,0 +1,27 @@ +{ + "id": "minecraft", + "name": "Minecraft", + "version": "1.0.0", + "description": "Minecraft Java Edition chat bridge plugin for ForcePilot. Bot client mode with offline auth, game chat monitoring, and AI-powered responses. Supports protocol versions 1.20.4 through 1.21.4.", + "author": "ForcePilot", + "order": 90, + "enabled": true, + "capabilities": { + "chat_types": ["group"], + "message_types": ["text"], + "reactions": false, + "typing_indicator": false, + "threads": false, + "edit": false, + "unsend": false, + "reply": false, + "media": false, + "native_commands": false, + "polls": false, + "streaming": true, + "streaming_mode": "block", + "block_streaming": true, + "block_streaming_chunk_min_chars": 200, + "block_streaming_chunk_max_chars": 256 + } +} \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/minecraft/protocol.py b/backend/package/yuxi/channel/extensions/minecraft/protocol.py new file mode 100644 index 00000000..19a273b8 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/protocol.py @@ -0,0 +1,203 @@ +import struct + +MAX_VARINT_BYTES = 5 +MAX_VARLONG_BYTES = 10 +SEGMENT_BITS = 0x7F +CONTINUE_BIT = 0x80 + + +def read_varint(stream: bytes, offset: int = 0) -> tuple[int, int]: + value = 0 + position = 0 + consumed = 0 + while True: + if offset + consumed >= len(stream): + raise ValueError("VarInt: unexpected end of stream") + byte = stream[offset + consumed] + value |= (byte & SEGMENT_BITS) << position + consumed += 1 + if not (byte & CONTINUE_BIT): + break + position += 7 + if position >= 32: + raise ValueError("VarInt too large (> 5 bytes)") + return value, consumed + + +def write_varint(value: int) -> bytes: + result = bytearray() + value = value & 0xFFFFFFFF + while True: + if value & ~SEGMENT_BITS: + result.append((value & SEGMENT_BITS) | CONTINUE_BIT) + value >>= 7 + else: + result.append(value & SEGMENT_BITS) + break + return bytes(result) + + +def read_varlong(stream: bytes, offset: int = 0) -> tuple[int, int]: + value = 0 + position = 0 + consumed = 0 + while True: + if offset + consumed >= len(stream): + raise ValueError("VarLong: unexpected end of stream") + byte = stream[offset + consumed] + value |= (byte & SEGMENT_BITS) << position + consumed += 1 + if not (byte & CONTINUE_BIT): + break + position += 7 + if position >= 64: + raise ValueError("VarLong too large (> 10 bytes)") + return value, consumed + + +def write_varlong(value: int) -> bytes: + result = bytearray() + value = value & 0xFFFFFFFFFFFFFFFF + while True: + if value & ~SEGMENT_BITS: + result.append((value & SEGMENT_BITS) | CONTINUE_BIT) + value >>= 7 + else: + result.append(value & SEGMENT_BITS) + break + return bytes(result) + + +def read_string(stream: bytes, offset: int) -> tuple[str, int]: + length, consumed = read_varint(stream, offset) + if length == 0: + return "", consumed + start = offset + consumed + end = start + length + if end > len(stream): + raise ValueError(f"String: expected {length} bytes but only {len(stream) - start} available") + return stream[start:end].decode("utf-8", errors="replace"), consumed + length + + +def write_string(value: str) -> bytes: + encoded = value.encode("utf-8") + return write_varint(len(encoded)) + encoded + + +def read_uuid(stream: bytes, offset: int) -> tuple[str, int]: + if offset + 16 > len(stream): + raise ValueError("UUID: unexpected end of stream") + raw = stream[offset : offset + 16] + uuid_str = "-".join( + [ + raw[0:4].hex(), + raw[4:6].hex(), + raw[6:8].hex(), + raw[8:10].hex(), + raw[10:16].hex(), + ] + ) + return uuid_str, 16 + + +def write_uuid(uuid_str: str) -> bytes: + hex_str = uuid_str.replace("-", "") + return bytes.fromhex(hex_str) + + +def read_byte(stream: bytes, offset: int) -> tuple[int, int]: + if offset >= len(stream): + raise ValueError("Byte: unexpected end of stream") + return stream[offset], 1 + + +def write_byte(value: int) -> bytes: + return bytes([value & 0xFF]) + + +def read_short(stream: bytes, offset: int) -> tuple[int, int]: + return struct.unpack(">h", stream[offset : offset + 2])[0], 2 + + +def write_short(value: int) -> bytes: + return struct.pack(">h", value) + + +def read_ushort(stream: bytes, offset: int) -> tuple[int, int]: + return struct.unpack(">H", stream[offset : offset + 2])[0], 2 + + +def write_ushort(value: int) -> bytes: + return struct.pack(">H", value) + + +def read_int(stream: bytes, offset: int) -> tuple[int, int]: + return struct.unpack(">i", stream[offset : offset + 4])[0], 4 + + +def write_int(value: int) -> bytes: + return struct.pack(">i", value) + + +def read_long(stream: bytes, offset: int) -> tuple[int, int]: + return struct.unpack(">q", stream[offset : offset + 8])[0], 8 + + +def write_long(value: int) -> bytes: + return struct.pack(">q", value) + + +def read_double(stream: bytes, offset: int) -> tuple[float, int]: + return struct.unpack(">d", stream[offset : offset + 8])[0], 8 + + +def write_double(value: float) -> bytes: + return struct.pack(">d", value) + + +def read_float(stream: bytes, offset: int) -> tuple[float, int]: + return struct.unpack(">f", stream[offset : offset + 4])[0], 4 + + +def write_float(value: float) -> bytes: + return struct.pack(">f", value) + + +def read_bool(stream: bytes, offset: int) -> tuple[bool, int]: + val, consumed = read_byte(stream, offset) + return val != 0, consumed + + +def write_bool(value: bool) -> bytes: + return b"\x01" if value else b"\x00" + + +def read_packet_frame(stream: bytes, offset: int = 0) -> tuple[int, int, int, int]: + packet_length, len_consumed = read_varint(stream, offset) + data_start = offset + len_consumed + packet_id, id_consumed = read_varint(stream, data_start) + data_offset = data_start + id_consumed + data_length = packet_length - id_consumed + total_consumed = len_consumed + packet_length + return packet_id, data_offset, data_length, total_consumed + + +def write_packet_frame(packet_id: int, data: bytes = b"") -> bytes: + packet_id_bytes = write_varint(packet_id) + payload = packet_id_bytes + data + length_bytes = write_varint(len(payload)) + return length_bytes + payload + + +def read_varint_at(stream: bytes, offset: int) -> tuple[int, int]: + value, consumed = read_varint(stream, offset) + return value, offset + consumed + + +def read_string_at(stream: bytes, offset: int) -> tuple[str, int]: + length, new_offset = read_varint(stream, offset) + start = new_offset + end = start + length + if end > len(stream): + raise ValueError(f"String: expected {length} bytes but only {len(stream) - start} available") + return stream[start:end].decode("utf-8", errors="replace"), end diff --git a/backend/package/yuxi/channel/extensions/minecraft/rcon.py b/backend/package/yuxi/channel/extensions/minecraft/rcon.py new file mode 100644 index 00000000..223c503a --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/rcon.py @@ -0,0 +1,74 @@ +import asyncio +import logging +import struct + +logger = logging.getLogger(__name__) + +RCON_TYPE_COMMAND = 2 +RCON_TYPE_AUTH = 3 +RCON_TYPE_RESPONSE = 0 + + +class McRconClient: + def __init__(self, host: str, port: int, password: str): + self.host = host + self.port = port + self.password = password + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + self._request_id = 0 + + async def connect(self) -> bool: + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), timeout=10 + ) + except Exception: + logger.exception("RCON connection failed to %s:%d", self.host, self.port) + return False + + self._request_id += 1 + success = await self._send_packet(RCON_TYPE_AUTH, self.password) + if not success: + logger.error("RCON authentication failed") + return False + return True + + async def execute(self, command: str) -> str: + if not self._writer or self._writer.is_closing(): + if not await self.connect(): + return "RCON connection failed" + self._request_id += 1 + return await self._send_packet(RCON_TYPE_COMMAND, command) + + async def _send_packet(self, ptype: int, payload: str) -> str: + req_id = self._request_id + packet = struct.pack(" bool: + if not allow_from: + return True + return sender_name.lower() in [name.lower() for name in allow_from] + + +def is_mc_mentioned(content: str, bot_username: str) -> bool: + return bot_username.lower() in content.lower() + + +def check_rate_limit( + sender_id: str, + window: float = DEFAULT_RATE_LIMIT_WINDOW, + max_msgs: int = DEFAULT_RATE_LIMIT_MAX, +) -> bool: + now = time.monotonic() + timestamps = _sender_timestamps[sender_id] + + cutoff = now - window + while timestamps and timestamps[0] < cutoff: + timestamps.pop(0) + + if len(timestamps) >= max_msgs: + return False + + timestamps.append(now) + + if len(_sender_timestamps) > 500: + stale = [k for k, v in _sender_timestamps.items() if not v] + for k in stale: + del _sender_timestamps[k] + + return True + + +def resolve_mc_group_policy(account) -> str: + return getattr(account, "group_policy", "mention") or "mention" + + +def collect_mc_security_warnings(account) -> list[str]: + warnings = [] + if account.auth_mode == "offline": + warnings.append( + "Minecraft is in offline mode — player UUIDs are not cryptographically verified. " + "allow_from only matches by username." + ) + if account.group_policy == "always": + warnings.append("Minecraft group policy is 'always' — bot will respond to all public chat messages") + return warnings diff --git a/backend/package/yuxi/channel/extensions/minecraft/setup.py b/backend/package/yuxi/channel/extensions/minecraft/setup.py new file mode 100644 index 00000000..8c13af38 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/setup.py @@ -0,0 +1,147 @@ +from yuxi.channel.protocols import SetupWizardStep + + +class MinecraftSetupWizard: + steps = [ + SetupWizardStep( + key="host", + label="服务器地址", + description="Minecraft 服务器 IP 或域名(如 localhost 或 mc.example.com)", + input_type="text", + required=True, + default="localhost", + ), + SetupWizardStep( + key="port", + label="服务器端口", + description="Minecraft 服务器端口(默认 25565)", + input_type="text", + required=True, + default="25565", + ), + SetupWizardStep( + key="username", + label="Bot 用户名", + description="Bot 在游戏中显示的用户名", + input_type="text", + required=True, + default="ForcePilotBot", + ), + SetupWizardStep( + key="auth_mode", + label="认证模式", + description="选择认证方式:offline(离线模式)或 microsoft(正版验证)", + input_type="select", + required=True, + default="offline", + options=[ + {"label": "离线模式(offline)", "value": "offline"}, + {"label": "正版验证(microsoft)", "value": "microsoft"}, + ], + ), + SetupWizardStep( + key="version", + label="游戏版本", + description="选择 Minecraft 版本,auto 表示自动探测", + input_type="select", + required=True, + default="auto", + options=[ + {"label": "自动探测(auto)", "value": "auto"}, + {"label": "1.20.4", "value": "1.20.4"}, + {"label": "1.21-1.21.1", "value": "1.21"}, + {"label": "1.21.2-1.21.3", "value": "1.21.2"}, + {"label": "1.21.4", "value": "1.21.4"}, + {"label": "1.21.5", "value": "1.21.5"}, + ], + ), + SetupWizardStep( + key="rcon_port", + label="RCON 端口", + description="RCON 远程管理端口(默认 25575,不需要可留空)", + input_type="text", + required=False, + default="25575", + ), + SetupWizardStep( + key="rcon_password", + label="RCON 密码", + description="RCON 认证密码(不需要远程管理可留空)", + input_type="password", + required=False, + ), + ] + + def setup_wizard_steps(self) -> list[SetupWizardStep]: + return self.steps + + def validate_wizard_input(self, step_key: str, value: str) -> str | None: + if step_key == "host": + if not value or not value.strip(): + return "服务器地址不能为空" + stripped = value.strip() + if len(stripped) > 253: + return "服务器地址过长(最大 253 字符)" + + elif step_key == "port": + try: + port = int(value) + if not (1 <= port <= 65535): + return "端口必须在 1-65535 之间" + except ValueError: + return "端口必须是有效的整数" + + elif step_key == "username": + if not value or not value.strip(): + return "用户名不能为空" + stripped = value.strip() + if len(stripped) > 16: + return "Minecraft 用户名不能超过 16 个字符" + if not stripped.replace("_", "").isalnum(): + return "用户名只能包含字母、数字和下划线" + + elif step_key == "auth_mode": + if value not in ("offline", "microsoft"): + return "认证模式必须是 offline 或 microsoft" + + elif step_key == "version": + valid_versions = ("auto", "1.20.4", "1.21", "1.21.2", "1.21.4", "1.21.5") + if value not in valid_versions: + return f"版本必须是以下之一: {', '.join(valid_versions)}" + + elif step_key == "rcon_port": + if value: + try: + port = int(value) + if not (1 <= port <= 65535): + return "RCON 端口必须在 1-65535 之间" + except ValueError: + return "RCON 端口必须是有效的整数" + + return None + + async def finalize(self, config: dict) -> dict: + result = dict(config) + + result.setdefault("host", "localhost") + result.setdefault("port", 25565) + result.setdefault("username", "ForcePilotBot") + result.setdefault("auth_mode", "offline") + result.setdefault("version", "auto") + result.setdefault("rcon_port", 25575) + + if "port" in result and isinstance(result["port"], str): + result["port"] = int(result["port"]) + if "rcon_port" in result and isinstance(result["rcon_port"], str): + result["rcon_port"] = int(result["rcon_port"]) + + result.setdefault("group_policy", "mention") + result.setdefault("require_mention", True) + result.setdefault("dm_policy", "pairing") + result.setdefault("allow_from", []) + result.setdefault("text_chunk_limit", 256) + result.setdefault("rate_limit_window", 5.0) + result.setdefault("rate_limit_max", 10) + result.setdefault("reconnect_max_retries", 10) + + return result diff --git a/backend/package/yuxi/channel/extensions/minecraft/status.py b/backend/package/yuxi/channel/extensions/minecraft/status.py new file mode 100644 index 00000000..68fcea2a --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/status.py @@ -0,0 +1,89 @@ +import asyncio +import logging +import struct +import time + +from yuxi.channel.extensions.minecraft.client import McClient +from yuxi.channel.extensions.minecraft.protocol import write_varint, write_string, read_string, write_long + +logger = logging.getLogger(__name__) + + +async def probe_mc_server( + host: str, + port: int, + version: str = "auto", +) -> dict: + result = { + "reachable": False, + "latency_ms": None, + "version_name": None, + "protocol_version": None, + "players_online": 0, + "players_max": 0, + } + try: + client = McClient(host, port) + await client.connect() + + from yuxi.channel.extensions.minecraft.version_adapter import get_adapter, DEFAULT_PROTOCOL + + adapter = get_adapter(version) + protocol_version = adapter.version if adapter else DEFAULT_PROTOCOL + + handshake_data = write_varint(protocol_version) + write_string(host) + struct.pack(">H", port) + write_varint(1) + await client.send_raw(write_varint(len(handshake_data)) + write_varint(0x00) + handshake_data) + client.state = "STATUS" + + await client.send_raw(write_varint(1) + write_varint(0x00)) + + packet = await asyncio.wait_for(client.recv_packet(), timeout=5.0) + if packet.packet_id == 0x00: + json_str, _ = read_string(packet.data, 0) + import json + + status = json.loads(json_str) + version_info = status.get("version", {}) + players_info = status.get("players", {}) + result["version_name"] = version_info.get("name") + result["protocol_version"] = version_info.get("protocol") + result["players_online"] = players_info.get("online", 0) + result["players_max"] = players_info.get("max", 0) + + ping_start = time.time() * 1000 + ping_data = write_long(int(ping_start)) + await client.send_packet(0x01, ping_data) + + pong = await asyncio.wait_for(client.recv_packet(), timeout=5.0) + if pong.packet_id == 0x01: + result["latency_ms"] = round(time.time() * 1000 - ping_start) + + result["reachable"] = True + logger.debug("MC probe success for %s:%d (latency=%s)", host, port, result["latency_ms"]) + await client.disconnect() + return result + + await client.disconnect() + return result + except Exception as e: + logger.warning("MC probe failed for %s:%d: %s", host, port, e) + return result + + +def build_mc_status_summary(snapshot: object) -> dict: + if snapshot is None: + return { + "channel": "minecraft", + "account_id": "default", + "connected": False, + } + + return { + "channel": "minecraft", + "account_id": getattr(snapshot, "account_id", "default"), + "host": getattr(snapshot, "host", ""), + "username": getattr(snapshot, "username", ""), + "connected": getattr(snapshot, "connected", False), + "version": getattr(snapshot, "version", "auto"), + "auth_mode": getattr(snapshot, "auth_mode", "offline"), + } diff --git a/backend/package/yuxi/channel/extensions/minecraft/streaming.py b/backend/package/yuxi/channel/extensions/minecraft/streaming.py new file mode 100644 index 00000000..6003e871 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/streaming.py @@ -0,0 +1,61 @@ +import asyncio +import logging +from collections.abc import Awaitable, Callable + +logger = logging.getLogger(__name__) + +MC_CHAT_MAX_CHARS = 256 +CHUNK_PREFIX_OVERHEAD = 8 +DELAY_BETWEEN_CHUNKS = 0.5 + + +def split_for_minecraft( + content: str, + max_chars: int = MC_CHAT_MAX_CHARS, + page_indicator: bool = True, +) -> list[str]: + result: list[str] = [] + remaining = content + + while remaining: + available = max_chars + + if len(remaining) <= available: + result.append(remaining) + break + + split_at = -1 + for sep in ("\n", "。", "!", "?", ". ", "! ", "? ", ",", ", ", " "): + pos = remaining.rfind(sep, 0, available) + if pos > available * 0.5: + split_at = pos + len(sep) + break + + if split_at == -1: + split_at = available + + result.append(remaining[:split_at].rstrip()) + remaining = remaining[split_at:].lstrip() + + if page_indicator and len(result) > 1: + total = len(result) + result = [f"[{i + 1}/{total}] {chunk}" for i, chunk in enumerate(result)] + + return result + + +async def stream_block_minecraft( + send_fn: Callable[[str], Awaitable[None]], + content: str, + chunk_delay: float = DELAY_BETWEEN_CHUNKS, +) -> None: + chunks = split_for_minecraft(content) + total = len(chunks) + + logger.info("Minecraft streaming: %d chars -> %d chunks", len(content), total) + + for i, chunk in enumerate(chunks): + await send_fn(chunk) + if i < total - 1: + logger.debug("Chunk %d/%d sent, waiting %.1fs", i + 1, total, chunk_delay) + await asyncio.sleep(chunk_delay) diff --git a/backend/package/yuxi/channel/extensions/minecraft/types.py b/backend/package/yuxi/channel/extensions/minecraft/types.py new file mode 100644 index 00000000..58e3e0dd --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/types.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum + + +class ConnectionState(Enum): + HANDSHAKE = "handshake" + STATUS = "status" + LOGIN = "login" + CONFIGURATION = "configuration" + PLAY = "play" + DISCONNECTED = "disconnected" + + +class AuthMode(Enum): + OFFLINE = "offline" + MICROSOFT = "microsoft" + MOJANG = "mojang" + + +class GroupPolicy(Enum): + ALWAYS = "always" + MENTION = "mention" + DISABLED = "disabled" + + +class DmPolicy(Enum): + OPEN = "open" + PAIRING = "pairing" + ALLOWLIST = "allowlist" + DISABLED = "disabled" + + +@dataclass +class McAccount: + account_id: str = "default" + enabled: bool = True + name: str = "" + host: str = "" + port: int = 25565 + version: str = "auto" + auth_mode: str = "offline" + username: str = "ForcePilotBot" + microsoft_email: str | None = None + microsoft_password: str | None = None + rcon_port: int = 25575 + rcon_password: str | None = None + default_dimension: str = "overworld" + listen_dimensions: list[str] = field(default_factory=lambda: ["overworld", "the_nether", "the_end"]) + dm_enabled: bool = False + dm_policy: str = "pairing" + group_policy: str = "mention" + require_mention: bool = True + allow_from: list[str] = field(default_factory=list) + text_chunk_limit: int = 256 + reconnect_max_retries: int = 5 + keep_alive_timeout: int = 30 + rate_limit_window: int = 10 + rate_limit_max: int = 5 + + @property + def is_offline(self) -> bool: + return self.auth_mode == "offline" + + @property + def is_configured(self) -> bool: + return bool(self.host and self.username) + + +@dataclass +class ResolvedMcAccount: + account_id: str = "default" + enabled: bool = True + name: str = "" + host: str = "" + port: int = 25565 + version: str = "auto" + auth_mode: str = "offline" + username: str = "ForcePilotBot" + microsoft_email: str | None = None + microsoft_password: str | None = None + rcon_port: int = 25575 + rcon_password: str | None = None + default_dimension: str = "overworld" + listen_dimensions: list[str] = field(default_factory=list) + dm_enabled: bool = False + dm_policy: str = "pairing" + group_policy: str = "mention" + require_mention: bool = True + allow_from: list[str] = field(default_factory=list) + text_chunk_limit: int = 256 + reconnect_max_retries: int = 5 + keep_alive_timeout: int = 30 + rate_limit_window: int = 10 + rate_limit_max: int = 5 + + @property + def is_configured(self) -> bool: + return bool(self.host and self.username) + + +@dataclass +class McPacket: + packet_id: int + data: bytes + + +@dataclass +class InboundMcMessage: + raw_packet: McPacket + sender_uuid: str + sender_name: str + content: str + message_index: int + timestamp: datetime + is_system: bool = False + is_command: bool = False + world: str = "" + dimension: str = "" diff --git a/backend/package/yuxi/channel/extensions/minecraft/version_adapter.py b/backend/package/yuxi/channel/extensions/minecraft/version_adapter.py new file mode 100644 index 00000000..a2941782 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/minecraft/version_adapter.py @@ -0,0 +1,444 @@ +from dataclasses import dataclass, field + + +@dataclass +class PacketDef: + packet_id: int + name: str + direction: str + fields: list[dict] = field(default_factory=list) + + +@dataclass +class VersionAdapter: + version: int + game_version: str + clientbound: dict[int, PacketDef] = field(default_factory=dict) + serverbound: dict[int, PacketDef] = field(default_factory=dict) + + def get_clientbound(self, packet_id: int) -> PacketDef | None: + return self.clientbound.get(packet_id) + + def get_serverbound(self, packet_id: int) -> PacketDef | None: + return self.serverbound.get(packet_id) + + def cb(self, name: str) -> int | None: + for pkt in self.clientbound.values(): + if pkt.name == name: + return pkt.packet_id + return None + + def sb(self, name: str) -> int | None: + for pkt in self.serverbound.values(): + if pkt.name == name: + return pkt.packet_id + return None + + +V769 = VersionAdapter(version=769, game_version="1.21.4") + +V769.clientbound[0x00] = PacketDef( + 0x00, + "status_response", + "clientbound", + [ + {"name": "json_response", "type": "string"}, + ], +) +V769.clientbound[0x01] = PacketDef( + 0x01, + "pong_response", + "clientbound", + [ + {"name": "payload", "type": "long"}, + ], +) + +V769.clientbound[0x02] = PacketDef( + 0x02, + "login_success", + "clientbound", + [ + {"name": "uuid", "type": "uuid"}, + {"name": "username", "type": "string"}, + {"name": "properties_count", "type": "varint"}, + ], +) +V769.clientbound[0x01] = PacketDef( + 0x01, + "encryption_request", + "clientbound", + [ + {"name": "server_id", "type": "string"}, + {"name": "public_key_length", "type": "varint"}, + {"name": "verify_token_length", "type": "varint"}, + ], +) + +V769.clientbound[0x26] = PacketDef( + 0x26, + "keep_alive", + "clientbound", + [ + {"name": "keep_alive_id", "type": "long"}, + ], +) +V769.clientbound[0x37] = PacketDef( + 0x37, + "player_chat_message", + "clientbound", + [ + {"name": "sender_uuid", "type": "uuid"}, + {"name": "index", "type": "varint"}, + {"name": "message_signature_present", "type": "bool"}, + ], +) +V769.clientbound[0x6B] = PacketDef( + 0x6B, + "system_chat_message", + "clientbound", + [ + {"name": "content", "type": "chat"}, + {"name": "overlay", "type": "bool"}, + ], +) +V769.clientbound[0x1D] = PacketDef( + 0x1D, + "disconnect", + "clientbound", + [ + {"name": "reason", "type": "chat"}, + ], +) +V769.clientbound[0x2B] = PacketDef( + 0x2B, + "login_play", + "clientbound", + [ + {"name": "entity_id", "type": "int"}, + {"name": "hardcore", "type": "bool"}, + {"name": "dimension_count", "type": "varint"}, + {"name": "max_players", "type": "varint"}, + {"name": "view_distance", "type": "varint"}, + {"name": "simulation_distance", "type": "varint"}, + {"name": "reduced_debug_info", "type": "bool"}, + {"name": "enable_respawn_screen", "type": "bool"}, + {"name": "do_limited_crafting", "type": "bool"}, + {"name": "dimension_type", "type": "string"}, + {"name": "dimension_name", "type": "string"}, + {"name": "hashed_seed", "type": "long"}, + {"name": "game_mode", "type": "byte"}, + {"name": "previous_game_mode", "type": "byte"}, + {"name": "is_debug", "type": "bool"}, + {"name": "is_flat", "type": "bool"}, + {"name": "death_location", "type": "optional_string"}, + {"name": "portal_cooldown", "type": "varint"}, + ], +) +V769.clientbound[0x3E] = PacketDef( + 0x3E, + "player_info_update", + "clientbound", + [ + {"name": "actions", "type": "byte"}, + {"name": "number_of_players", "type": "varint"}, + ], +) +V769.clientbound[0x45] = PacketDef(0x45, "respawn", "clientbound", []) + +V769.serverbound[0x18] = PacketDef( + 0x18, + "keep_alive", + "serverbound", + [ + {"name": "keep_alive_id", "type": "long"}, + ], +) +V769.serverbound[0x05] = PacketDef( + 0x05, + "chat_message", + "serverbound", + [ + {"name": "message", "type": "string"}, + ], +) +V769.serverbound[0x04] = PacketDef( + 0x04, + "chat_command", + "serverbound", + [ + {"name": "command", "type": "string"}, + ], +) +V769.serverbound[0x1A] = PacketDef( + 0x1A, + "set_player_position", + "serverbound", + [ + {"name": "x", "type": "double"}, + {"name": "feet_y", "type": "double"}, + {"name": "z", "type": "double"}, + {"name": "on_ground", "type": "bool"}, + ], +) +V769.serverbound[0x1B] = PacketDef( + 0x1B, + "set_player_position_rotation", + "serverbound", + [ + {"name": "x", "type": "double"}, + {"name": "feet_y", "type": "double"}, + {"name": "z", "type": "double"}, + {"name": "yaw", "type": "float"}, + {"name": "pitch", "type": "float"}, + {"name": "on_ground", "type": "bool"}, + ], +) + +V763 = VersionAdapter(version=763, game_version="1.20.4") + +V763.clientbound[0x00] = PacketDef( + 0x00, + "status_response", + "clientbound", + [ + {"name": "json_response", "type": "string"}, + ], +) +V763.clientbound[0x01] = PacketDef( + 0x01, + "pong_response", + "clientbound", + [ + {"name": "payload", "type": "long"}, + ], +) +V763.clientbound[0x26] = PacketDef( + 0x26, + "keep_alive", + "clientbound", + [ + {"name": "keep_alive_id", "type": "long"}, + ], +) +V763.clientbound[0x38] = PacketDef(0x38, "player_chat_message", "clientbound", []) +V763.clientbound[0x64] = PacketDef( + 0x64, + "system_chat_message", + "clientbound", + [ + {"name": "content", "type": "chat"}, + {"name": "overlay", "type": "bool"}, + ], +) +V763.clientbound[0x1A] = PacketDef( + 0x1A, + "disconnect", + "clientbound", + [ + {"name": "reason", "type": "chat"}, + ], +) +V763.serverbound[0x12] = PacketDef( + 0x12, + "keep_alive", + "serverbound", + [ + {"name": "keep_alive_id", "type": "long"}, + ], +) +V763.serverbound[0x05] = PacketDef( + 0x05, + "chat_message", + "serverbound", + [ + {"name": "message", "type": "string"}, + ], +) +V763.serverbound[0x04] = PacketDef( + 0x04, + "chat_command", + "serverbound", + [ + {"name": "command", "type": "string"}, + ], +) + +V767 = VersionAdapter(version=767, game_version="1.21-1.21.1") +V767.clientbound = dict(V769.clientbound) +V767.serverbound = dict(V769.serverbound) + +V768 = VersionAdapter(version=768, game_version="1.21.2-1.21.3") +V768.clientbound = dict(V769.clientbound) +V768.serverbound = dict(V769.serverbound) + +V770 = VersionAdapter(version=770, game_version="1.21.5") +V770.clientbound = { + 0x00: PacketDef( + 0x00, + "status_response", + "clientbound", + [ + {"name": "json_response", "type": "string"}, + ], + ), + 0x01: PacketDef( + 0x01, + "pong_response", + "clientbound", + [ + {"name": "payload", "type": "long"}, + ], + ), + 0x02: PacketDef( + 0x02, + "login_success", + "clientbound", + [ + {"name": "uuid", "type": "uuid"}, + {"name": "username", "type": "string"}, + {"name": "properties_count", "type": "varint"}, + ], + ), + 0x26: PacketDef( + 0x26, + "keep_alive", + "clientbound", + [ + {"name": "keep_alive_id", "type": "long"}, + ], + ), + 0x3C: PacketDef( + 0x3C, + "player_chat_message", + "clientbound", + [ + {"name": "sender_uuid", "type": "uuid"}, + {"name": "index", "type": "varint"}, + {"name": "message_signature_present", "type": "bool"}, + ], + ), + 0x6C: PacketDef( + 0x6C, + "system_chat_message", + "clientbound", + [ + {"name": "content", "type": "chat"}, + {"name": "overlay", "type": "bool"}, + ], + ), + 0x1C: PacketDef( + 0x1C, + "disconnect", + "clientbound", + [ + {"name": "reason", "type": "chat"}, + ], + ), + 0x2B: PacketDef( + 0x2B, + "login_play", + "clientbound", + [ + {"name": "entity_id", "type": "int"}, + {"name": "hardcore", "type": "bool"}, + {"name": "dimension_count", "type": "varint"}, + {"name": "max_players", "type": "varint"}, + {"name": "view_distance", "type": "varint"}, + {"name": "simulation_distance", "type": "varint"}, + {"name": "reduced_debug_info", "type": "bool"}, + {"name": "enable_respawn_screen", "type": "bool"}, + {"name": "do_limited_crafting", "type": "bool"}, + {"name": "dimension_type", "type": "string"}, + {"name": "dimension_name", "type": "string"}, + {"name": "hashed_seed", "type": "long"}, + {"name": "game_mode", "type": "byte"}, + {"name": "previous_game_mode", "type": "byte"}, + {"name": "is_debug", "type": "bool"}, + {"name": "is_flat", "type": "bool"}, + {"name": "death_location", "type": "optional_string"}, + {"name": "portal_cooldown", "type": "varint"}, + ], + ), + 0x3E: PacketDef( + 0x3E, + "player_info_update", + "clientbound", + [ + {"name": "actions", "type": "byte"}, + {"name": "number_of_players", "type": "varint"}, + ], + ), + 0x45: PacketDef(0x45, "respawn", "clientbound", []), +} +V770.serverbound = { + 0x18: PacketDef( + 0x18, + "keep_alive", + "serverbound", + [ + {"name": "keep_alive_id", "type": "long"}, + ], + ), + 0x06: PacketDef( + 0x06, + "chat_message", + "serverbound", + [ + {"name": "message", "type": "string"}, + ], + ), + 0x05: PacketDef( + 0x05, + "chat_command", + "serverbound", + [ + {"name": "command", "type": "string"}, + ], + ), + 0x1A: PacketDef( + 0x1A, + "set_player_position", + "serverbound", + [ + {"name": "x", "type": "double"}, + {"name": "feet_y", "type": "double"}, + {"name": "z", "type": "double"}, + {"name": "on_ground", "type": "bool"}, + ], + ), + 0x1B: PacketDef( + 0x1B, + "set_player_position_rotation", + "serverbound", + [ + {"name": "x", "type": "double"}, + {"name": "feet_y", "type": "double"}, + {"name": "z", "type": "double"}, + {"name": "yaw", "type": "float"}, + {"name": "pitch", "type": "float"}, + {"name": "on_ground", "type": "bool"}, + ], + ), +} + +PROTOCOL_REGISTRY: dict[int, VersionAdapter] = { + 763: V763, + 767: V767, + 768: V768, + 769: V769, + 770: V770, +} + +DEFAULT_PROTOCOL = 769 + + +def get_adapter(version: int | str) -> VersionAdapter: + if isinstance(version, str) and version == "auto": + return V769 + if isinstance(version, str): + try: + version = int(version) + except ValueError: + return V769 + return PROTOCOL_REGISTRY.get(version, V769)