Compare commits

...

6 Commits

12 changed files with 417 additions and 384 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
# Minecraft-specific
server.jar
world/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@ -7,13 +7,14 @@ WORKDIR /usr/src/app
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Prepare MC server # # Prepare MC server
COPY server.jar ./ # COPY server.jar ./
RUN java -jar server.jar --nogui # RUN java -jar server.jar --nogui
COPY . . COPY src/ ./src/
COPY main.py ./
# Buffer Python's stdout for debugging during runtime # Buffer Python's stdout for debugging during runtime
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
CMD ["python", "main.py", "java", "-Xmx1024M", "-Xms1024M", "-jar", "server.jar", "nogui"] CMD ["python", "main.py"]

View File

@ -6,25 +6,36 @@ This way, communication is enabled between a Minecraft server and a Matrix room.
## Setup ## Setup
To create a Docker image, go to this folder and run the following command: 1. Create a Docker image using the following command:
``` ```
docker build -t matrix-mc-server ./ docker build -t matrix-mc-server ./
``` ```
Then, once a Docker image has been created, you can run the following command to run a Minecraft server. In `config.py`, you can find additional environment variables you can insert to alter settings on the Minecraft server. 2. Download the [latest Minecraft server jar file](https://www.minecraft.net/en-us/download/server).
``` 3. Open `config.yaml` and adjust it to however you like.
docker run --name mc-server \
-p 25565:25565 \
-v "<folder in which your store your Minecraft world>":/usr/src/app/world \
-e EULA=true \
-e MATRIX_HOMESERVER='<your matrix homeserver>' \
-e MATRIX_USERNAME='<matrix bridge client username>' \
-e MATRIX_PASSWORD='<matrix bridge client password>' \
-e MC_CHANNEL='<channel in which you communicate>' \
-e SERVER_ADDRESS='<ip address where players can connect to the server>' \
mc-bridge
```
This should successfully launch your bridged Minecraft server. **NOTE:** Make sure to set EULA to `true` otherwise the server will refuse to start.
4. Start the Docker container. You will need a few volumes:
- `/usr/src/app/config.yaml` - Location of your custom `config.yaml`
- `/usr/src/app/whitelist.json` - Location of your whitelist, if any.
- `/usr/src/app/world/` - Folder of your custom Minecraft world to load
- `/usr/src/app/server.jar` - (Default) location of the server jar file. Can be changed in `config.yaml`.
For example, your Docker compose file could look like the following:
```docker-compose
version: '3'
services:
matrix-mc-server:
image: matrix-mc-server:latest
volumes:
- <your config>:/usr/src/app/config.yaml
- <your whitelist>:/usr/src/app/whitelist.json
- <your world folder>:/usr/src/app/world
- <your server jar file>:/usr/src/app/server.jar
```

View File

@ -1,48 +0,0 @@
import os
import config
def rewriter(file_name):
"""Create a decorator that rewrites a file based on given settings."""
def exec(func):
"""Rewrite a file"""
new_lines = []
info_to_remember = {}
line_no = 0
with open(file_name, 'r', encoding='utf-8') as open_file:
for line in open_file:
line_no += 1
new_line = func(line, line_no, data=info_to_remember)
new_lines.append(new_line)
with open(file_name, 'w', encoding='utf-8') as write_file:
for line in new_lines:
write_file.write(line)
return exec
@rewriter('eula.txt')
def confirm_eula(line : str, line_no : int, data):
"""Confirm the Minecraft EULA"""
if not config.EULA:
return line
else:
return line.replace('eula=false', 'eula=true')
@rewriter('server.properties')
def fill_in_server_settings(line : str, line_no : int, data):
"""Set up the server based on our chosen properties"""
if line.strip().startswith('#'):
return line
key = line.split('=')[0]
server_settings = config.at(['minecraft']) or {}
try:
value = server_settings[key]
except IndexError:
return line
else:
return key + '=' + str(value) + '\n'

View File

@ -1,84 +0,0 @@
import os
import yaml
from typing import Any, List, Optional
with open('config.yaml', 'r') as open_file:
SETTINGS = yaml.load(open_file)
def at(keys : List[str]) -> Optional[Any]:
"""
Potentially get a value. If it doesn't exist, return None.
"""
return at_value(keys, SETTINGS)
def at_value(keys : List[str], value : Any) -> Optional[Any]:
try:
head, tail = keys[0], keys[1:]
except IndexError:
return value
else:
try:
new_value = value[head]
except TypeError:
return None
except KeyError:
return None
else:
return at_value(tail, new_value)
# EULA
EULA = at(['minecraft', 'eula']) or False
# Minecraft bridge credentials
MATRIX_HOMESERVER = at(['matrix', 'homeserver']) or "https://matrix.example.org/"
MATRIX_USERNAME = at(['matrix', 'username']) or "@alice:example.org"
MATRIX_PASSWORD = at(['matrix', 'password']) or "bridge_password"
# Matrix bridge room
MATRIX_ROOM = at(['matrix', 'room_id']) or "!channel_id:example.org"
SERVER_IP = os.getenv('SERVER_ADDRESS') or 'unknown ip'
# Matrix users who are allowed to run OP commands in Minecraft through Matrix
MC_ADMINS = [
"@bramvdnheuvel:nltrix.net", # Bram on Matrix (example, feel free to remove)
"@_discord_625632515314548736:t2bot.io" # Bram on Discord (example, feel free to remove)
# Your username on Matrix
]
if os.getenv('MATRIX_ADMINS') is not None:
MC_ADMINS = os.getenv('MATRIX_ADMINS').split(',')
make_bool = lambda os_value, default_value : default_value if not os_value else (
False if os_value.lower() == 'false' else True
)
SERVER_SETTINGS = {
'level-name': os.getenv('WORLD') or 'world',
# Server settings
'port' : 25565 if os.getenv('PORT') == None else int(os.getenv('PORT')),
'query.port' : 25565 if os.getenv('PORT') == None else int(os.getenv('PORT')),
'max-players' : 7 if os.getenv('MAX_PLAYERS') == None else int(os.getenv('MAX_PLAYERS')),
# Server temperature >:3
'view-distance' : 10 if os.getenv('RENDER_DISTANCE') == None else int(os.getenv('RENDER_DISTANCE')),
'enable-command-block' : make_bool(os.getenv('COMMAND_BLOCKS'), True),
# Environment
'allow-nether' : make_bool(os.getenv('NETHER'), True),
'spawn-npcs' : make_bool(os.getenv('NPCS'), True),
'spawn-animals' : make_bool(os.getenv('ANIMALS'), True),
'spawn-monsters' : make_bool(os.getenv('MONSTERS'), True),
# Gamemode
'pvp' : make_bool(os.getenv('PVP'), True),
'gamemode' : os.getenv('GAMEMODE') or 'survival',
'difficulty': os.getenv('DIFFICULTY') or 'medium',
'hardcore' : make_bool(os.getenv('HARDCORE'), False),
# Grief protection
'online-mode' : make_bool(os.getenv('VERIFY_ACCOUNTS'), True),
'white-list' : make_bool(os.getenv('WHITELIST'), True),
'enforce-whitelist' : make_bool(os.getenv('WHITELIST'), True),
'spawn-protection' : 16 if os.getenv('SPAWN_PROTECTION') == None else os.getenv('SPAWN_PROTECTION'),
}

View File

@ -1,3 +1,16 @@
config:
# How much memory the Minecraft server is allowed to take up
# Number in megabytes. Defaults to 1024. (= 1 GB)
ram: 1024
# File location of the server jar.
# To be downloaded at: https://www.minecraft.net/en-us/download/server
server_jar: server.jar
# Confirm the Minecraft EULA. https://account.mojang.com/documents/minecraft_eula
# Defaults to false, but is required to run the server.
# eula: true
# Matrix bridge configurations # Matrix bridge configurations
matrix: matrix:
# Homeserver URL # Homeserver URL
@ -14,14 +27,16 @@ matrix:
server_address: unknown ip server_address: unknown ip
# List of Matrix users that can send commands to the bridge. # List of Matrix users that can send commands to the bridge.
# When a message starts with a slash, (/) the bridge will interpret it as a # When a message starts with an exclamation mark, (!) the bridge will
# Minecraft command and will put that as a command into the console. # interpret it as a Minecraft command and will put that as a command
# into the console.
mc-admins: mc-admins:
- "@bram:matrix.directory" - "@bram:matrix.directory"
# - "@alice:example.org" # - "@alice:example.org"
# - # -
# When users have bridged from other platforms, you can indicate accordingly. # When users have bridged from other platforms, you can indicate accordingly.
# When multiple RegEx strings apply, all are included.
alternative_platforms: alternative_platforms:
Discord: Discord:
match: "@_?discord_\d+:.+" match: "@_?discord_\d+:.+"
@ -34,8 +49,6 @@ matrix:
# Settings that directly affect running the Minecraft server. # Settings that directly affect running the Minecraft server.
minecraft: minecraft:
# Confirm the Minecraft EULA. Defaults to false.
# eula: true
# -------------------- # --------------------
# MINECRAFT SERVER # MINECRAFT SERVER

70
main.py
View File

@ -1,67 +1,7 @@
import src.mc_wrapper as mc_wrapper
import src.mxclient as matrix_client
import asyncio import asyncio
import time
import re
from nio import AsyncClient, MatrixRoom, RoomMessageText # Start the Minecraft process
import mc_wrapper asyncio.run(matrix_client.start())
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 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": "<strong>Starting Minecraft-Matrix bridge...</strong>"
}
)
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())

25
src/build_server.py Normal file
View File

@ -0,0 +1,25 @@
"""
This module prepares the necessary files for running the server in the
correct configuration.
"""
import src.config as 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")

62
src/config.py Normal file
View File

@ -0,0 +1,62 @@
"""
This module loads and parses the config.yaml file.
"""
from typing import Any, List, Optional
import yaml
with open('config.yaml', 'r') as open_file:
SETTINGS = yaml.load(open_file)
def at(keys : List[str]) -> Optional[Any]:
"""
Potentially get a value. If it doesn't exist, return None.
"""
return at_value(keys, SETTINGS)
def at_value(keys : List[str], value : Any) -> Optional[Any]:
try:
head, tail = keys[0], keys[1:]
except IndexError:
return value
else:
try:
new_value = value[head]
except TypeError:
return None
except KeyError:
return None
else:
return at_value(tail, new_value)
# EULA
EULA = at(['config', 'eula']) or False
# Minecraft bridge credentials
MATRIX_HOMESERVER = at(['matrix', 'homeserver']) or "https://matrix.example.org/"
MATRIX_USERNAME = at(['matrix', 'username']) or "@alice:example.org"
MATRIX_PASSWORD = at(['matrix', 'password']) or "bridge_password"
# Matrix bridge room
MATRIX_ROOM = at(['matrix', 'room_id']) or "!channel_id:example.org"
SERVER_IP = at(['matrix', 'server_address']) or 'unknown ip'
MATRIX_ADMINS = at(['matrix', 'mc-admins']) or []
try:
RAM_SIZE = int(at(['config', 'ram']))
except TypeError:
RAM_SIZE = 1024
except ValueError:
RAM_SIZE = 1024
SERVER_JAR_LOCATION = at(['config', 'server_jar']) or 'server.jar'
RUN_COMMAND = [
'java',
f'-Xmx{RAM_SIZE}M',
f'-Xms{RAM_SIZE}M',
'-jar', SERVER_JAR_LOCATION,
'nogui'
]

View File

@ -1,19 +1,33 @@
"""
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 subprocess import Popen, PIPE
from typing import Union from typing import Union
import asyncio import asyncio
import json import json
import sys
import re import re
from nbsr import NonBlockingStreamReader as NBSR from src.nbsr import NonBlockingStreamReader as NBSR
import config import src.config as config
import src.build_server as build
# Write the appropriate files
build.write_eula()
build.write_server_properties()
# run the shell as a subprocess: # run the shell as a subprocess:
p = Popen(sys.argv[1:], p = Popen(config.RUN_COMMAND,
stdin = PIPE, stdout = PIPE, stderr = PIPE, shell = False) stdin = PIPE, stdout = PIPE, stderr = PIPE, shell = False)
# wrap p.stdout with a NonBlockingStreamReader object: # wrap p.stdout with a NonBlockingStreamReader object:
nbsr = NBSR(p.stdout) nbsr = NBSR(p.stdout)
async def start(client, mc_channel): async def start(client, mc_channel):
"""
Start reading from the Minecraft subprocess.
"""
await asyncio.sleep(3) await asyncio.sleep(3)
while True: while True:
@ -49,6 +63,12 @@ async def start(client, mc_channel):
server_live = False server_live = False
def process_message(sentence : str) -> Union[str, None]: 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 global server_live
if (match := re.fullmatch( if (match := re.fullmatch(
@ -83,7 +103,8 @@ def process_message(sentence : str) -> Union[str, None]:
sentence)): sentence)):
username, = match.groups() username, = match.groups()
return username + " left the Minecraft server", ( return username + " left the Minecraft server", (
"<strong>" + username + " left the Minecraft server</strong>") "<strong>" + username + " left the Minecraft server</strong>"
)
if (match := re.fullmatch( if (match := re.fullmatch(
r"\[[\d:]+\] \[Server thread\/INFO\]: <([A-Za-z0-9_]{3,16})> (.+)", r"\[[\d:]+\] \[Server thread\/INFO\]: <([A-Za-z0-9_]{3,16})> (.+)",
@ -101,24 +122,39 @@ def process_message(sentence : str) -> Union[str, None]:
return None, None return None, None
def reply_to_mc(message : str, author : str, def reply_to_mc(message : str, display_name : str, sender : str):
admin : bool = False, platform : str = 'Matrix'):
""" """
Send something back to the Minecraft terminal. Send something back to the Minecraft terminal.
""" """
if admin and message.startswith('!'): if sender in config.MATRIX_ADMINS and message.startswith('!'):
p.stdin.write((message[1:] + "\r\n").encode()) p.stdin.write((message[1:] + "\r\n").encode())
else: 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( p.stdin.write(
("execute as @a run tellraw @s " + json.dumps([m for m in msg if m is not None]) + "\r\n").encode() ("execute as @a run tellraw @s " + format(sender, display_name, message) + "\r\n").encode()
) )
p.stdin.flush() 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__': if __name__ == '__main__':
asyncio.run(start()) asyncio.run(start())

61
src/mxclient.py Normal file
View File

@ -0,0 +1,61 @@
import asyncio
import time
import re
from nio import AsyncClient, MatrixRoom, RoomMessageText
import src.mc_wrapper as mc_wrapper
import src.config as 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": "<strong>Starting Minecraft-Matrix bridge...</strong>"
}
)
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)
)
if __name__ == '__main__':
asyncio.run(start())

View File

@ -1,3 +1,15 @@
"""
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 threading import Thread
from queue import Queue, Empty from queue import Queue, Empty