diff --git a/build_server.py b/src/build_server.py similarity index 96% rename from build_server.py rename to src/build_server.py index 68b7229..9f48491 100644 --- a/build_server.py +++ b/src/build_server.py @@ -1,25 +1,25 @@ -""" - This module prepares the necessary files for running the server in the - correct configuration. -""" - -import config - -def write_eula(): - """ - Write whether the user accepts to Minecraft's EULA. - The server refuses to run unless explicitly accepted. - """ - with open("eula.txt", 'w') as fp: - if config.EULA == True: - fp.write("eula=true") - else: - fp.write("eula=false") - -def write_server_properties(): - """ - Write the configuration for the Minecraft world. - """ - with open("server.properties", 'w') as fp: - for key, value in config.at(['minecraft']).items(): - fp.write(f"{key}={value}\n") +""" + This module prepares the necessary files for running the server in the + correct configuration. +""" + +import config + +def write_eula(): + """ + Write whether the user accepts to Minecraft's EULA. + The server refuses to run unless explicitly accepted. + """ + with open("eula.txt", 'w') as fp: + if config.EULA == True: + fp.write("eula=true") + else: + fp.write("eula=false") + +def write_server_properties(): + """ + Write the configuration for the Minecraft world. + """ + with open("server.properties", 'w') as fp: + for key, value in config.at(['minecraft']).items(): + fp.write(f"{key}={value}\n") diff --git a/mc_wrapper.py b/src/mc_wrapper.py similarity index 94% rename from mc_wrapper.py rename to src/mc_wrapper.py index 136a8af..e355c6d 100644 --- a/mc_wrapper.py +++ b/src/mc_wrapper.py @@ -1,153 +1,160 @@ -from subprocess import Popen, PIPE -from typing import Union -import asyncio -import json -import re -from nbsr import NonBlockingStreamReader as NBSR -import config -import build_server as build - -# Write the appropriate files -build.write_eula() -build.write_server_properties() - -# run the shell as a subprocess: -p = Popen(config.RUN_COMMAND, - 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): - """ - Start reading from the Minecraft subprocess. - """ - 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]: - """ - Process a message that is sent to stdout in the Minecraft terminal. - - If this function deems it relevant, it returns a string that can be - sent to Matrix. - """ - 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 f"The Minecraft server is live. The server is reacable at {config.SERVER_IP}.", f"The minecraft server is live. The server is reacable at {config.SERVER_IP}." - - 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, display_name : str, sender : str): - """ - Send something back to the Minecraft terminal. - """ - if sender in config.MATRIX_ADMINS and message.startswith('!'): - p.stdin.write((message[1:] + "\r\n").encode()) - else: - p.stdin.write( - ("execute as @a run tellraw @s " + format(sender, display_name, message) + "\r\n").encode() - ) - p.stdin.flush() - -def format(sender : str, display_name : str, message : str) -> str: - """ - Create a string used to format the user's message. - """ - start = [ "", dict(text="M", color="red" ) ] - end = [ dict(text=f" <{display_name}> {message}") ] - - options = config.at(['matrix', 'alternative_platforms']) or {} - - for platform, details in options.items(): - try: - regex = details['match'] - text = details['text'] - color = details['color'] - except KeyError: - print("WARNING: Platform `" + platform + "` is missing some configurations.") - else: - if re.fullmatch(regex, sender): - start.append(dict(text=text, color=color)) - - return json.dumps(start + end) - -if __name__ == '__main__': +""" + The mc_wrapper modules handles the process that runs the Minecraft server. + + The server is run in a subprocess, and live communication between the + game and the Python script is facilitated. +""" + +from subprocess import Popen, PIPE +from typing import Union +import asyncio +import json +import re +from src.nbsr import NonBlockingStreamReader as NBSR +import config +import src.build_server as build + +# Write the appropriate files +build.write_eula() +build.write_server_properties() + +# run the shell as a subprocess: +p = Popen(config.RUN_COMMAND, + 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): + """ + Start reading from the Minecraft subprocess. + """ + 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]: + """ + Process a message that is sent to stdout in the Minecraft terminal. + + If this function deems it relevant, it returns a string that can be + sent to Matrix. + """ + 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 f"The Minecraft server is live. The server is reacable at {config.SERVER_IP}.", f"The minecraft server is live. The server is reacable at {config.SERVER_IP}." + + 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, display_name : str, sender : str): + """ + Send something back to the Minecraft terminal. + """ + if sender in config.MATRIX_ADMINS and message.startswith('!'): + p.stdin.write((message[1:] + "\r\n").encode()) + else: + p.stdin.write( + ("execute as @a run tellraw @s " + format(sender, display_name, message) + "\r\n").encode() + ) + p.stdin.flush() + +def format(sender : str, display_name : str, message : str) -> str: + """ + Create a string used to format the user's message. + """ + start = [ "", dict(text="M", color="red" ) ] + end = [ dict(text=f" <{display_name}> {message}") ] + + options = config.at(['matrix', 'alternative_platforms']) or {} + + for platform, details in options.items(): + try: + regex = details['match'] + text = details['text'] + color = details['color'] + except KeyError: + print("WARNING: Platform `" + platform + "` is missing some configurations.") + else: + if re.fullmatch(regex, sender): + start.append(dict(text=text, color=color)) + + return json.dumps(start + end) + +if __name__ == '__main__': asyncio.run(start()) \ No newline at end of file diff --git a/main.py b/src/mxclient.py similarity index 93% rename from main.py rename to src/mxclient.py index 651aef6..2440316 100644 --- a/main.py +++ b/src/mxclient.py @@ -1,61 +1,60 @@ -import asyncio -import time -import re - -from nio import AsyncClient, MatrixRoom, RoomMessageText -import mc_wrapper - -import config -import build_server - -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.MATRIX_ROOM: - return - if event.sender == client.user_id: - return - if int(event.server_timestamp) < STARTUP_TIME: - return - - # 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, event.sender) -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.MATRIX_ROOM, - 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( - # Start the Matrix client - activate_client(), - - # Start the Minecraft subprocess - mc_wrapper.start(client, config.MATRIX_ROOM) - ) - +import asyncio +import time +import re + +from nio import AsyncClient, MatrixRoom, RoomMessageText +import src.mc_wrapper as 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.MATRIX_ROOM: + return + if event.sender == client.user_id: + return + if int(event.server_timestamp) < STARTUP_TIME: + return + + # 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, event.sender) +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.MATRIX_ROOM, + 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( + # Start the Matrix client + activate_client(), + + # Start the Minecraft subprocess + mc_wrapper.start(client, config.MATRIX_ROOM) + ) + asyncio.run(start()) \ No newline at end of file diff --git a/nbsr.py b/src/nbsr.py similarity index 64% rename from nbsr.py rename to src/nbsr.py index 3b0787d..74f2a03 100644 --- a/nbsr.py +++ b/src/nbsr.py @@ -1,39 +1,51 @@ -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 - +""" + The NBSR module defines a Non-blocking stream reader (NBSR class). + + In short, the Minecraft stdout is a stream of data that doesn't end until + the server shuts down. Traditionally, Python would not run any code until + the server has shut down and returns its entire output. + + The NBSR class allows us to read from the stream without blocking the entire + Python script. We will occasionally ask the NBSR for any updates, and it + will give us the latest output, if it exists. +""" + +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