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