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

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

246 lines
8.1 KiB
Python

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