From 0c381a32f5a01cff6a50ba75c8d783f5bd56e4e3 Mon Sep 17 00:00:00 2001 From: Bram Date: Sun, 27 Jun 2021 21:19:05 +0200 Subject: [PATCH] Add Minecraft bridge --- main.py | 66 +++++++++++++++++++++++++++ mc_wrapper.py | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++ nbsr.py | 39 ++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 main.py create mode 100644 mc_wrapper.py create mode 100644 nbsr.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..4fedfdb --- /dev/null +++ b/main.py @@ -0,0 +1,66 @@ +import asyncio +import time +import re + +from nio import AsyncClient, MatrixRoom, RoomMessageText +import mc_wrapper + +import config + +STARTUP_TIME = time.time() + +client = AsyncClient(config.MATRIX_HOMESERVER, config.MATRIX_USERNAME) + +async def message_callback(room: MatrixRoom, event: RoomMessageText) -> None: + if room.machine_name != config.MC_CHANNEL: + return + if event.sender == client.user_id: + return + if int(event.server_timestamp) < STARTUP_TIME: + return + + # Determine platform + platform = 'Matrix' + if re.fullmatch(r"@_discord_\d+:t2bot\.io", event.sender): + platform = 'Discord' + + # Determine how to display username + name = room.users[event.sender].display_name + for user in room.users: + if user == event.sender: + continue + + if room.users[user].display_name == name: + name = room.users[event.sender].disambiguated_name + break + + mc_wrapper.reply_to_mc( + event.body, name, + admin=(event.sender in config.MC_ADMINS), + platform=platform + ) +client.add_event_callback(message_callback, RoomMessageText) + + +async def activate_client() -> None: + print(await client.login(config.MATRIX_PASSWORD)) + + await client.room_send( + room_id=config.MC_CHANNEL, + message_type="m.room.message", + content = { + "msgtype": "m.text", + "body": "Starting Minecraft-Matrix bridge...", + "format": "org.matrix.custom.html", + "formatted_body": "Starting Minecraft-Matrix bridge..." + } + ) + await client.sync_forever(timeout=30000) # milliseconds + +async def start(): + await asyncio.gather( + activate_client(), + mc_wrapper.start(client, config.MC_CHANNEL) + ) + +asyncio.run(start()) \ No newline at end of file diff --git a/mc_wrapper.py b/mc_wrapper.py new file mode 100644 index 0000000..8fddbe1 --- /dev/null +++ b/mc_wrapper.py @@ -0,0 +1,123 @@ +from subprocess import Popen, PIPE +from typing import Union +import asyncio +import json +import sys +import re +from nbsr import NonBlockingStreamReader as NBSR + +# run the shell as a subprocess: +p = Popen(sys.argv[1:], + stdin = PIPE, stdout = PIPE, stderr = PIPE, shell = False) +# wrap p.stdout with a NonBlockingStreamReader object: +nbsr = NBSR(p.stdout) + +async def start(client, mc_channel): + await asyncio.sleep(3) + + while True: + output = nbsr.readline(0.1) + # 0.1 secs to let the shell output the result + if not output: + await asyncio.sleep(1) + else: + try: + sentence = output.decode("utf-8").strip() + except UnicodeDecodeError: + print("Could not decode sentence:") + print(output) + else: + print(sentence) + plain_text, sentence = process_message(sentence) + + if sentence is not None: + print("[Matrix] " + plain_text) + + # Send terminal message to Matrix + await client.room_send( + room_id=mc_channel, + message_type="m.room.message", + content = { + "msgtype": "m.text", + "body": plain_text, + "format": "org.matrix.custom.html", + "formatted_body": sentence + } + ) + +server_live = False + +def process_message(sentence : str) -> Union[str, None]: + global server_live + + if (match := re.fullmatch( + r"\[[\d:]+\] \[Server thread\/INFO\]: Preparing level \"(.+)\"" + , sentence)): + level, = match.groups() + return f"Preparing level {level}...", "Preparing level \"" + level + "\"..." + + if re.fullmatch( + r"\[[\d:]+\] \[Server thread\/INFO\]: Done \(\d+.?\d*s\)! For help, type \"help\"", + sentence): + server_live = True + return "The Minecraft server is live. The server is reacable at mc.noordstar.me.", "The minecraft server is live. The server is reacable at mc.noordstar.me." + + if re.fullmatch( + r"\[[\d:]+\] \[Server thread\/INFO\]: Stopping server", + sentence): + return "The server has stopped.", "The server has stopped." + + if not server_live: + return None, None + + if (match := re.fullmatch( + r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) joined the game", + sentence)): + username, = match.groups() + return username + " joined the Minecraft server", ( + "" + username + " joined the Minecraft server") + + if (match := re.fullmatch( + r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) left the game", + sentence)): + username, = match.groups() + return username + " left the Minecraft server", ( + "" + username + " left the Minecraft server") + + if (match := re.fullmatch( + r"\[[\d:]+\] \[Server thread\/INFO\]: <([A-Za-z0-9_]{3,16})> (.+)", + sentence)): + username, message = match.groups() + return username + ": " + message, ( + "" + username + ": " + message + ) + + if (match := re.fullmatch( + r"\[[\d:]+\] \[Server thread\/INFO\]: ([A-Za-z0-9_]{3,16}) ([\w\[\]\-\. !?,]+)", + sentence)): + message = " ".join(match.groups()) + return message, "" + message + "" + + return None, None + +def reply_to_mc(message : str, author : str, + admin : bool = False, platform : str = 'Matrix'): + """ + Send something back to the Minecraft terminal. + """ + if admin and message.startswith('!'): + p.stdin.write((message[1:] + "\r\n").encode()) + else: + msg = [ + "", + dict(text="M", color="red"), + dict(text="D", color="aqua") if platform == 'Discord' else None, + dict(text=f" <{author}> {message}") + ] + p.stdin.write( + ("execute as @a run tellraw @s " + json.dumps([m for m in msg if m is not None]) + "\r\n").encode() + ) + p.stdin.flush() + +if __name__ == '__main__': + asyncio.run(start()) \ No newline at end of file diff --git a/nbsr.py b/nbsr.py new file mode 100644 index 0000000..3b0787d --- /dev/null +++ b/nbsr.py @@ -0,0 +1,39 @@ +from threading import Thread +from queue import Queue, Empty + +class NonBlockingStreamReader: + + def __init__(self, stream): + ''' + stream: the stream to read from. + Usually a process' stdout or stderr. + ''' + + self._s = stream + self._q = Queue() + + def _populateQueue(stream, queue): + ''' + Collect lines from 'stream' and put them in 'quque'. + ''' + + while True: + line = stream.readline() + if line: + queue.put(line) + else: + raise UnexpectedEndOfStream + + self._t = Thread(target = _populateQueue, + args = (self._s, self._q)) + self._t.daemon = True + self._t.start() #start collecting lines from the stream + + def readline(self, timeout = None): + try: + return self._q.get(block = timeout is not None, + timeout = timeout) + except Empty: + return None + +class UnexpectedEndOfStream(Exception): pass \ No newline at end of file