feat(channel): 添加 Minecraft 渠道扩展
新增 Minecraft 渠道扩展,支持在 Yuxi 平台中集成 Minecraft 游戏服务器渠道。 包含以下功能模块: - client: Minecraft 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - auth: 认证管理 - accounts: 账户管理 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - protocol: Minecraft 协议处理 - rcon: RCON 远程控制 - keepalive: 连接保活 - version_adapter: 版本适配 - setup: 初始化设置 - types: 类型定义
This commit is contained in:
parent
895602f774
commit
b3b6c95094
376
backend/package/yuxi/channel/extensions/minecraft/__init__.py
Normal file
376
backend/package/yuxi/channel/extensions/minecraft/__init__.py
Normal file
@ -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())
|
||||
@ -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
|
||||
92
backend/package/yuxi/channel/extensions/minecraft/auth.py
Normal file
92
backend/package/yuxi/channel/extensions/minecraft/auth.py
Normal file
@ -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()
|
||||
114
backend/package/yuxi/channel/extensions/minecraft/client.py
Normal file
114
backend/package/yuxi/channel/extensions/minecraft/client.py
Normal file
@ -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
|
||||
176
backend/package/yuxi/channel/extensions/minecraft/config.py
Normal file
176
backend/package/yuxi/channel/extensions/minecraft/config.py
Normal file
@ -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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
27
backend/package/yuxi/channel/extensions/minecraft/dedupe.py
Normal file
27
backend/package/yuxi/channel/extensions/minecraft/dedupe.py
Normal file
@ -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()
|
||||
68
backend/package/yuxi/channel/extensions/minecraft/format.py
Normal file
68
backend/package/yuxi/channel/extensions/minecraft/format.py
Normal file
@ -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
|
||||
459
backend/package/yuxi/channel/extensions/minecraft/gateway.py
Normal file
459
backend/package/yuxi/channel/extensions/minecraft/gateway.py
Normal file
@ -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")
|
||||
@ -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")
|
||||
245
backend/package/yuxi/channel/extensions/minecraft/monitor.py
Normal file
245
backend/package/yuxi/channel/extensions/minecraft/monitor.py
Normal file
@ -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
|
||||
@ -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])
|
||||
38
backend/package/yuxi/channel/extensions/minecraft/pairing.py
Normal file
38
backend/package/yuxi/channel/extensions/minecraft/pairing.py
Normal file
@ -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()
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
203
backend/package/yuxi/channel/extensions/minecraft/protocol.py
Normal file
203
backend/package/yuxi/channel/extensions/minecraft/protocol.py
Normal file
@ -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
|
||||
74
backend/package/yuxi/channel/extensions/minecraft/rcon.py
Normal file
74
backend/package/yuxi/channel/extensions/minecraft/rcon.py
Normal file
@ -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("<iii", len(payload) + 10, req_id, ptype) + payload.encode("utf-8") + b"\x00\x00"
|
||||
self._writer.write(packet)
|
||||
await self._writer.drain()
|
||||
|
||||
response_parts = []
|
||||
while True:
|
||||
length_data = await self._reader.readexactly(4)
|
||||
length = struct.unpack("<i", length_data)[0]
|
||||
remaining = await self._reader.readexactly(length)
|
||||
resp_id = struct.unpack("<i", remaining[:4])[0]
|
||||
body = remaining[8:-2].decode("utf-8", errors="replace")
|
||||
|
||||
if resp_id != req_id:
|
||||
continue
|
||||
|
||||
response_parts.append(body)
|
||||
|
||||
if ptype == RCON_TYPE_AUTH:
|
||||
return resp_id != -1
|
||||
|
||||
if len(body) < 4096:
|
||||
break
|
||||
|
||||
return "".join(response_parts)
|
||||
|
||||
async def close(self):
|
||||
if self._writer:
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
||||
@ -0,0 +1,61 @@
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_RATE_LIMIT_WINDOW = 10
|
||||
DEFAULT_RATE_LIMIT_MAX = 5
|
||||
|
||||
_sender_timestamps: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
|
||||
def check_mc_allowlist(sender_name: str, allow_from: list[str]) -> 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
|
||||
147
backend/package/yuxi/channel/extensions/minecraft/setup.py
Normal file
147
backend/package/yuxi/channel/extensions/minecraft/setup.py
Normal file
@ -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
|
||||
89
backend/package/yuxi/channel/extensions/minecraft/status.py
Normal file
89
backend/package/yuxi/channel/extensions/minecraft/status.py
Normal file
@ -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"),
|
||||
}
|
||||
@ -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)
|
||||
119
backend/package/yuxi/channel/extensions/minecraft/types.py
Normal file
119
backend/package/yuxi/channel/extensions/minecraft/types.py
Normal file
@ -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 = ""
|
||||
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user