diff --git a/mosfet/commands.py b/mosfet/commands.py index 771357e..c9027c3 100644 --- a/mosfet/commands.py +++ b/mosfet/commands.py @@ -20,8 +20,18 @@ class Commands: self.g.chat.set_handler(self.handle_chat) + def _send_reply(self, reply, private=False, sender=None): + if private and not reply.startswith('/'): + if isinstance(sender, str) and re.fullmatch(r'[A-Za-z0-9_]{1,16}', sender): + self.g.chat.send('/msg {} {}'.format(sender, reply)) + else: + self.g.chat.send('/r ' + reply) + else: + self.g.chat.send(reply) + def handle_chat(self, message): - source, sender, text = message + source, sender, text = message[:3] + reply_target = message[3] if len(message) > 3 else sender reply = '' private = False for_me = False @@ -46,7 +56,7 @@ class Commands: else: return - if '-> me' in meta: + if source == 'DM' or '-> me' in meta: private = True if prefix == bot_num: @@ -64,10 +74,7 @@ class Commands: (command == 'farm' and not data.lower().startswith('crop'))): reply = 'not migrated to Minecraft 26.3 yet' print('Reply:', reply) - if private: - self.g.chat.send('/r ' + reply) - else: - self.g.chat.send(reply) + self._send_reply(reply, private, reply_target) return try: @@ -642,8 +649,5 @@ class Commands: if len(reply) >= 256: reply = 'reply too long, check console' - if private and not reply.startswith('/'): - self.g.chat.send('/r ' + reply) - else: - self.g.chat.send(reply) + self._send_reply(reply, private, reply_target) diff --git a/mosfet/connect.py b/mosfet/connect.py index acc18fd..8065367 100644 --- a/mosfet/connect.py +++ b/mosfet/connect.py @@ -34,7 +34,13 @@ def component_text(value): if 'text' in fields: return component_text(fields['text']) + component_text(fields.get('extra', [])) if 'translate' in fields: - return component_text(fields['translate']) + component_text(fields.get('with', [])) + translation = component_text(fields['translate']) + arguments = fields.get('with', []) + if translation == 'commands.message.display.incoming' and len(arguments) >= 2: + return '{} whispers to you: {}'.format( + component_text(arguments[0]), + component_text(arguments[1])) + return translation + component_text(arguments) return ''.join(component_text(v) for v in fields.values()) if isinstance(value, (list, tuple)): return ''.join(component_text(item) for item in value) @@ -135,12 +141,14 @@ def main(global_state=None): @connection.listener(SystemChat) def on_system_chat(packet): message = component_text(packet.content) + chat_handled = False if global_state is not None: global_state.last_chat = message chat = global_state.get('chat') if chat is not None: - chat.handle_system_chat(packet, message) - print('[server] ' + message, flush=True) + chat_handled = chat.handle_system_chat(packet, message) + if not chat_handled: + print('[server] ' + message, flush=True) lowered = message.lower() if 'already registered' in lowered: send_auth_command('login') diff --git a/mosfet/protocol/modern.py b/mosfet/protocol/modern.py index bb7ebe9..bc7acf3 100644 --- a/mosfet/protocol/modern.py +++ b/mosfet/protocol/modern.py @@ -1,4 +1,5 @@ """Chat adapter for the Minecraft 26.3 play protocol.""" +import re import time from contextlib import nullcontext @@ -7,6 +8,20 @@ from minecraft.networking.modern import ( ) +def parse_incoming_private_message(message): + """Return (sender_name, body) for common Minecraft incoming-DM forms.""" + patterns = ( + r'^([A-Za-z0-9_]{1,16}) whispers(?: to you)?:\s*(.*)$', + r'^([A-Za-z0-9_]{1,16})\s*->\s*me:\s*(.*)$', + r'^From ([A-Za-z0-9_]{1,16}):\s*(.*)$', + ) + for pattern in patterns: + match = re.match(pattern, message, re.DOTALL) + if match: + return match.group(1), match.group(2) + return None + + class ModernChatManager: def __init__(self, global_state): self.g = global_state @@ -19,16 +34,48 @@ class ModernChatManager: def handle_player_chat(self, packet): sender = str(packet.sender) - text = '{}: {}'.format(sender, packet.message) - print('[chat] {}: {}'.format(sender, packet.message), flush=True) + sender_name = self.g.get('player_names', {}).get(sender, sender) + registry = getattr(self.g.connection, 'registry_ids', {}) + chat_types = registry.get('minecraft:chat_type', {}) + chat_type_id = getattr(packet, 'chat_type_id', None) + chat_type = next((name for name, index in chat_types.items() + if index == chat_type_id), None) + is_dm = ( + getattr(packet, 'network_target_name_present', False) or + chat_type in ( + 'minecraft:msg_command_incoming', + 'minecraft:msg_command_outgoing', + 'minecraft:team_msg_command_incoming', + 'minecraft:team_msg_command_outgoing', + ) + ) + label = '[DM] ' if is_dm else '' + print('[chat] {}{}: {}'.format(label, sender_name, packet.message), + flush=True) if self.handler: with self.g.get('reload_lock') or nullcontext(): - self.handler(('CHAT', sender, text)) + if is_dm: + text = '{} -> me: {}'.format(sender_name, packet.message) + self.handler(('DM', sender, text, sender_name)) + else: + text = '{}: {}'.format(sender_name, packet.message) + self.handler(('CHAT', sender, text)) def handle_system_chat(self, packet, message): + incoming = parse_incoming_private_message(message) + if incoming: + sender_name, body = incoming + print('[chat] [DM] {}: {}'.format(sender_name, body), flush=True) + if self.handler: + text = '{} -> me: {}'.format(sender_name, body) + with self.g.get('reload_lock') or nullcontext(): + self.handler(('DM', sender_name, text, sender_name)) + return True + if self.handler: with self.g.get('reload_lock') or nullcontext(): self.handler(('SYSTEM', None, message)) + return False def send(self, message): if not message: diff --git a/tests/test_modern_chat.py b/tests/test_modern_chat.py new file mode 100644 index 0000000..b4d24da --- /dev/null +++ b/tests/test_modern_chat.py @@ -0,0 +1,188 @@ +import contextlib +import io +import struct +import unittest +import uuid + +from munch import Munch +from minecraft.networking.modern import ClientboundPlayerChatPacket + +from mosfet.commands import Commands +from mosfet.connect import component_text +from mosfet.protocol.modern import ( + ModernChatManager, parse_incoming_private_message, +) + + +class FakeConnection: + def register_packet_listener(self, *_args, **_kwargs): + pass + + +class FakeChat: + def __init__(self): + self.messages = [] + + def set_handler(self, _handler): + pass + + def send(self, message): + self.messages.append(message) + + +class TestModernChat(unittest.TestCase): + def test_incoming_dm_translation_is_rendered_and_parsed(self): + rendered = component_text({ + 'translate': 'commands.message.display.incoming', + 'with': ['tanner', '!ping'], + }) + self.assertEqual(rendered, 'tanner whispers to you: !ping') + self.assertEqual(parse_incoming_private_message(rendered), + ('tanner', '!ping')) + self.assertEqual(parse_incoming_private_message( + 'tanner -> me: 1ping'), ('tanner', '1ping')) + + def test_player_chat_console_uses_name_but_keeps_uuid_for_commands(self): + player_uuid = 'f6126022-2c68-37f7-be93-aaa551374663' + state = Munch( + connection=FakeConnection(), + player_names={player_uuid: 'tanner'}, + reload_lock=None, + ) + manager = ModernChatManager(state) + received = [] + manager.set_handler(received.append) + output = io.StringIO() + with contextlib.redirect_stdout(output): + manager.handle_player_chat(Munch( + sender=player_uuid, message='!ping', chat_type_id=1, + network_target_name_present=False)) + + self.assertEqual(output.getvalue(), '[chat] tanner: !ping\n') + self.assertEqual(received, [ + ('CHAT', player_uuid, 'tanner: !ping'), + ]) + + def test_player_chat_target_name_marks_dm_and_routes_reply(self): + player_uuid = 'f6126022-2c68-37f7-be93-aaa551374663' + state = Munch( + connection=FakeConnection(), + player_names={player_uuid: 'tanner'}, + reload_lock=None, + ) + manager = ModernChatManager(state) + received = [] + manager.set_handler(received.append) + output = io.StringIO() + packet = Munch( + sender=player_uuid, message='!ping', chat_type_id=2, + network_target_name_present=True) + with contextlib.redirect_stdout(output): + manager.handle_player_chat(packet) + + self.assertEqual(output.getvalue(), '[chat] [DM] tanner: !ping\n') + self.assertEqual(received, [ + ('DM', player_uuid, 'tanner -> me: !ping', 'tanner'), + ]) + + def test_player_chat_parser_reads_optional_target_name(self): + def varint(value): + encoded = bytearray() + while value > 0x7f: + encoded.append((value & 0x7f) | 0x80) + value >>= 7 + encoded.append(value) + return bytes(encoded) + + message = b'!ping' + payload = ( + varint(0) + uuid.UUID(int=0).bytes + varint(0) + b'\x00' + + varint(len(message)) + message + + struct.pack('>qq', 0, 0) + varint(0) + b'\x00' + + varint(0) + varint(2) + b'\x00\x01\x00' + ) + packet = ClientboundPlayerChatPacket() + packet.read(io.BytesIO(payload)) + + self.assertEqual(packet.message, '!ping') + self.assertEqual(packet.chat_type_id, 2) + self.assertTrue(packet.network_target_name_present) + + def test_chat_type_registry_identifies_dm_without_target_name(self): + player_uuid = 'f6126022-2c68-37f7-be93-aaa551374663' + state = Munch( + connection=FakeConnection(), + player_names={player_uuid: 'tanner'}, + reload_lock=None, + ) + state.connection.registry_ids = { + 'minecraft:chat_type': { + 'minecraft:msg_command_incoming': 2, + }, + } + manager = ModernChatManager(state) + received = [] + manager.set_handler(received.append) + output = io.StringIO() + with contextlib.redirect_stdout(output): + manager.handle_player_chat(Munch( + sender=player_uuid, message='!ping', chat_type_id=2, + network_target_name_present=False)) + + self.assertEqual(output.getvalue(), '[chat] [DM] tanner: !ping\n') + self.assertEqual(received, [ + ('DM', player_uuid, 'tanner -> me: !ping', 'tanner'), + ]) + + def test_system_dm_console_uses_dm_prefix(self): + state = Munch(connection=FakeConnection(), reload_lock=None) + manager = ModernChatManager(state) + received = [] + manager.set_handler(received.append) + output = io.StringIO() + with contextlib.redirect_stdout(output): + handled = manager.handle_system_chat( + None, 'tanner whispers to you: !ping') + + self.assertTrue(handled) + self.assertEqual(output.getvalue(), '[chat] [DM] tanner: !ping\n') + self.assertEqual(received, [ + ('DM', 'tanner', 'tanner -> me: !ping', 'tanner'), + ]) + + def test_dm_command_replies_to_named_sender(self): + chat = FakeChat() + command = Commands.__new__(Commands) + command.g = Munch(name='mosfet1', chat=chat) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + command.handle_chat(( + 'DM', 'f6126022-2c68-37f7-be93-aaa551374663', + 'tanner -> me: !ping', 'tanner')) + + self.assertIn('Reply: pong', output.getvalue()) + self.assertEqual(chat.messages, ['/msg tanner pong']) + + def test_here_dm_uses_sender_uuid_and_private_reply_name(self): + sender_uuid = 'f6126022-2c68-37f7-be93-aaa551374663' + chat = FakeChat() + command = Commands.__new__(Commands) + command.g = Munch( + name='mosfet1', chat=chat, pos=(0, 64, 0), path=[], job=None, + players={1: Munch(player_uuid=sender_uuid, x=10, y=64, z=20)}, + world=Munch(path_to_place=lambda start, goal: [start, goal]), + ) + + output = io.StringIO() + with contextlib.redirect_stdout(output): + command.handle_chat(( + 'DM', sender_uuid, 'tanner -> me: 1here', 'tanner')) + + self.assertEqual(command.g.path, [(0, 64, 0), (10, 64, 20)]) + self.assertIn('Reply: ok', output.getvalue()) + self.assertEqual(chat.messages, ['/msg tanner ok']) + + +if __name__ == '__main__': + unittest.main()