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:
Kris 2026-05-21 11:26:52 +08:00
parent 895602f774
commit b3b6c95094
21 changed files with 3023 additions and 0 deletions

View 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())

View File

@ -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

View 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()

View 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

View 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"},
},
},
},
},
},
}

View 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()

View 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

View 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")

View File

@ -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")

View 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

View File

@ -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])

View 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()

View File

@ -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
}
}

View 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

View 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()

View File

@ -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

View 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

View 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"),
}

View File

@ -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)

View 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 = ""

View File

@ -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)