From 2c0ae52fe5d2dcfb1992a309cf29f00f05717908 Mon Sep 17 00:00:00 2001 From: Tristan Gosselin-Hane Date: Sun, 22 Sep 2019 15:27:02 -0400 Subject: [PATCH 1/5] Make the project a valid python package --- minecraft-discord-bridge/__main__.py | 3 --- minecraft_discord_bridge/__init__.py | 1 + minecraft_discord_bridge/__main__.py | 3 +++ .../auth_server.py | 0 .../config.py | 0 .../database.py | 0 .../database_session.py | 0 .../elasticsearch_logger.py | 0 .../minecraft_discord_bridge.py | 2 +- 9 files changed, 5 insertions(+), 4 deletions(-) delete mode 100644 minecraft-discord-bridge/__main__.py create mode 100644 minecraft_discord_bridge/__init__.py create mode 100644 minecraft_discord_bridge/__main__.py rename {minecraft-discord-bridge => minecraft_discord_bridge}/auth_server.py (100%) rename {minecraft-discord-bridge => minecraft_discord_bridge}/config.py (100%) rename {minecraft-discord-bridge => minecraft_discord_bridge}/database.py (100%) rename {minecraft-discord-bridge => minecraft_discord_bridge}/database_session.py (100%) rename {minecraft-discord-bridge => minecraft_discord_bridge}/elasticsearch_logger.py (100%) rename {minecraft-discord-bridge => minecraft_discord_bridge}/minecraft_discord_bridge.py (99%) diff --git a/minecraft-discord-bridge/__main__.py b/minecraft-discord-bridge/__main__.py deleted file mode 100644 index cdf0090..0000000 --- a/minecraft-discord-bridge/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -if __name__ == "__main__": - from . import minecraft_discord_bridge - minecraft_discord_bridge.main() diff --git a/minecraft_discord_bridge/__init__.py b/minecraft_discord_bridge/__init__.py new file mode 100644 index 0000000..f102a9c --- /dev/null +++ b/minecraft_discord_bridge/__init__.py @@ -0,0 +1 @@ +__version__ = "0.0.1" diff --git a/minecraft_discord_bridge/__main__.py b/minecraft_discord_bridge/__main__.py new file mode 100644 index 0000000..4653883 --- /dev/null +++ b/minecraft_discord_bridge/__main__.py @@ -0,0 +1,3 @@ +if __name__ == "__main__": + from minecraft_discord_bridge import minecraft_discord_bridge + minecraft_discord_bridge.main() diff --git a/minecraft-discord-bridge/auth_server.py b/minecraft_discord_bridge/auth_server.py similarity index 100% rename from minecraft-discord-bridge/auth_server.py rename to minecraft_discord_bridge/auth_server.py diff --git a/minecraft-discord-bridge/config.py b/minecraft_discord_bridge/config.py similarity index 100% rename from minecraft-discord-bridge/config.py rename to minecraft_discord_bridge/config.py diff --git a/minecraft-discord-bridge/database.py b/minecraft_discord_bridge/database.py similarity index 100% rename from minecraft-discord-bridge/database.py rename to minecraft_discord_bridge/database.py diff --git a/minecraft-discord-bridge/database_session.py b/minecraft_discord_bridge/database_session.py similarity index 100% rename from minecraft-discord-bridge/database_session.py rename to minecraft_discord_bridge/database_session.py diff --git a/minecraft-discord-bridge/elasticsearch_logger.py b/minecraft_discord_bridge/elasticsearch_logger.py similarity index 100% rename from minecraft-discord-bridge/elasticsearch_logger.py rename to minecraft_discord_bridge/elasticsearch_logger.py diff --git a/minecraft-discord-bridge/minecraft_discord_bridge.py b/minecraft_discord_bridge/minecraft_discord_bridge.py similarity index 99% rename from minecraft-discord-bridge/minecraft_discord_bridge.py rename to minecraft_discord_bridge/minecraft_discord_bridge.py index 66fca38..b1029af 100755 --- a/minecraft-discord-bridge/minecraft_discord_bridge.py +++ b/minecraft_discord_bridge/minecraft_discord_bridge.py @@ -15,7 +15,7 @@ import string import uuid from threading import Thread from .config import Configuration -from .database import DiscordChannel, AccountLinkToken, DiscordAccount +from . import DiscordChannel, AccountLinkToken, DiscordAccount from . import database_session from datetime import datetime, timedelta, timezone From 614e46a19d88bfed0ebd1944fb7d0b83871b1e5c Mon Sep 17 00:00:00 2001 From: Tristan Gosselin-Hane Date: Sun, 22 Sep 2019 16:33:23 -0400 Subject: [PATCH 2/5] Start crunching down on pylint warnings and errors --- minecraft_discord_bridge/auth_server.py | 8 +- .../minecraft_discord_bridge.py | 93 +++++++++---------- 2 files changed, 46 insertions(+), 55 deletions(-) diff --git a/minecraft_discord_bridge/auth_server.py b/minecraft_discord_bridge/auth_server.py index e6d65dc..c577e51 100644 --- a/minecraft_discord_bridge/auth_server.py +++ b/minecraft_discord_bridge/auth_server.py @@ -30,8 +30,8 @@ class AuthProtocol(ServerProtocol): connect_port = self.connect_port - self.logger.info("[AUTH SERVER] {} ({}) connected to address {}:{}".format( - display_name, uuid, ip_addr, connect_port)) + self.logger.info("[AUTH SERVER] %s (%s) connected to address %s:%s", + display_name, uuid, ip_addr, connect_port) try: connection_token = ip_addr.split(".")[0] session = database_session.get_session() @@ -50,8 +50,8 @@ class AuthProtocol(ServerProtocol): if discord_account.minecraft_account_id is not None: existing_account = session.query(MinecraftAccount).filter_by( id=discord_account.minecraft_account_id).first() - self.logger.info("unlinking existing {} account and replacing it with {}".format( - existing_account.minecraft_uuid, str(uuid))) + self.logger.info("unlinking existing %s account and replacing it with %s", + existing_account.minecraft_uuid, str(uuid)) session.delete(existing_account) mc_account = MinecraftAccount(str(uuid), discord_account.id) discord_account.minecraft_account = mc_account diff --git a/minecraft_discord_bridge/minecraft_discord_bridge.py b/minecraft_discord_bridge/minecraft_discord_bridge.py index b1029af..b6a3f5a 100755 --- a/minecraft_discord_bridge/minecraft_discord_bridge.py +++ b/minecraft_discord_bridge/minecraft_discord_bridge.py @@ -5,33 +5,30 @@ from __future__ import print_function import sys import re from enum import Enum - -import requests import json import time import logging import random import string import uuid +import asyncio from threading import Thread -from .config import Configuration -from . import DiscordChannel, AccountLinkToken, DiscordAccount -from . import database_session - from datetime import datetime, timedelta, timezone -from . import elasticsearch_logger as el + +import requests from minecraft import authentication from minecraft.exceptions import YggdrasilError from minecraft.networking.connection import Connection from minecraft.networking.packets import clientbound, serverbound - import discord -import asyncio - from mcstatus import MinecraftServer - from bidict import bidict +from . import database_session +from . import elasticsearch_logger as el +from .config import Configuration +from .database import DiscordChannel, AccountLinkToken, DiscordAccount + log = logging.getLogger("bridge") SESSION_TOKEN = "" @@ -47,10 +44,10 @@ TAB_HEADER = "" TAB_FOOTER = "" -def mc_uuid_to_username(uuid): - if uuid not in UUID_CACHE: +def mc_uuid_to_username(mc_uuid): + if mc_uuid not in UUID_CACHE: try: - short_uuid = uuid.replace("-", "") + short_uuid = mc_uuid.replace("-", "") mojang_response = requests.get("https://api.mojang.com/user/profiles/{}/names".format(short_uuid)).json() if len(mojang_response) > 1: # Multiple name changes @@ -58,13 +55,13 @@ def mc_uuid_to_username(uuid): else: # Only one name player_username = mojang_response[0]["name"] - UUID_CACHE[uuid] = player_username + UUID_CACHE[mc_uuid] = player_username return player_username - except Exception as e: + except requests.RequestException as e: log.error(e, exc_info=True) - log.error("Failed to lookup {}'s username using the Mojang API.".format(uuid)) + log.error("Failed to lookup %s's username using the Mojang API.", mc_uuid) else: - return UUID_CACHE[uuid] + return UUID_CACHE[mc_uuid] def mc_username_to_uuid(username): @@ -76,7 +73,7 @@ def mc_username_to_uuid(username): UUID_CACHE.inv[username] = str(long_uuid) return player_uuid except requests.RequestException: - log.error("Failed to lookup {}'s UUID using the Mojang API.".format(username)) + log.error("Failed to lookup %s's username using the Mojang API.", username) else: return UUID_CACHE.inv[username] @@ -93,7 +90,7 @@ def get_discord_help_string(): # https://stackoverflow.com/questions/33404752/removing-emojis-from-a-string-in-python -def remove_emoji(string): +def remove_emoji(dirty_string): emoji_pattern = re.compile( "[" u"\U0001F600-\U0001F64F" # emoticons @@ -104,22 +101,22 @@ def remove_emoji(string): # u"\U00002702-\U000027B0" # u"\U000024C2-\U0001F251" "]+", flags=re.UNICODE) - return emoji_pattern.sub(r'', string) + return emoji_pattern.sub(r'', dirty_string) -def escape_markdown(string): +def escape_markdown(md_string): # Absolutely needs to go first or it will replace our escaping slashes! - string = string.replace("\\", "\\\\") - string = string.replace("_", "\\_") - string = string.replace("*", "\\*") - return string + escaped_string = md_string.replace("\\", "\\\\") + escaped_string = escaped_string.replace("_", "\\_") + escaped_string = escaped_string.replace("*", "\\*") + return escaped_string -def strip_colour(string): +def strip_colour(dirty_string): colour_pattern = re.compile( u"\U000000A7" # selection symbol ".", flags=re.UNICODE) - return colour_pattern.sub(r'', string) + return colour_pattern.sub(r'', dirty_string) def setup_logging(level): @@ -144,7 +141,7 @@ def run_auth_server(port): factory = AuthFactory() # Listen - log.info("Starting authentication server on port {}".format(port)) + log.info("Starting authentication server on port %d", port) factory.listen("", port) try: @@ -197,7 +194,7 @@ def main(): handle_disconnect() def minecraft_handle_exception(exception, exc_info): - log.error("A minecraft exception occured! {}:".format(exception), exc_info=exc_info) + log.error("A minecraft exception occured! %s:", exception, exc_info=exc_info) handle_disconnect() def is_server_online(): @@ -232,7 +229,7 @@ def main(): log.info(e) sys.exit() BOT_USERNAME = auth_token.profile.name - log.info("Logged in as %s..." % auth_token.profile.name) + log.info("Logged in as %s...", auth_token.profile.name) while not is_server_online(): log.info('Not connecting to server because it appears to be offline.') time.sleep(15) @@ -264,8 +261,8 @@ def main(): def handle_player_list_header_and_footer_update(header_footer_packet): global TAB_FOOTER, TAB_HEADER - log.debug("Got Tablist H/F Update: header={}".format(header_footer_packet.header)) - log.debug("Got Tablist H/F Update: footer={}".format(header_footer_packet.footer)) + log.debug("Got Tablist H/F Update: header=%s", header_footer_packet.header) + log.debug("Got Tablist H/F Update: footer=%s", header_footer_packet.footer) TAB_HEADER = json.loads(header_footer_packet.header)["text"] TAB_FOOTER = json.loads(header_footer_packet.footer)["text"] @@ -275,7 +272,7 @@ def main(): for action in tab_list_packet.actions: if isinstance(action, clientbound.play.PlayerListItemPacket.AddPlayerAction): log.debug( - "Processing AddPlayerAction tab list packet, name: {}, uuid: {}".format(action.name, action.uuid)) + "Processing AddPlayerAction tab list packet, name: %s, uuid: %s", action.name, action.uuid) username = action.name player_uuid = action.uuid if action.name not in PLAYER_LIST.inv: @@ -316,7 +313,7 @@ def main(): if config.es_enabled: el.log_connection(uuid=action.uuid, reason=el.ConnectionReason.SEEN) if isinstance(action, clientbound.play.PlayerListItemPacket.RemovePlayerAction): - log.debug("Processing RemovePlayerAction tab list packet, uuid: {}".format(action.uuid)) + log.debug("Processing RemovePlayerAction tab list packet, uuid: %s", action.uuid) username = mc_uuid_to_username(action.uuid) player_uuid = action.uuid webhook_payload = { @@ -364,8 +361,8 @@ def main(): message_unformatted=chat_string) el.log_raw_message(type=ChatType(chat_packet.position).name, message=chat_packet.json_data) return - log.info("Incoming message from minecraft: Username: {} Message: {}".format(username, original_message)) - log.debug("msg: {}".format(repr(original_message))) + log.info("Incoming message from minecraft: Username: %s Message: %s", username, original_message) + log.debug("msg: %s", repr(original_message)) message = escape_markdown(remove_emoji(original_message.strip().replace("@", "@\N{zero width space}"))) webhook_payload = { 'username': username, @@ -393,7 +390,7 @@ def main(): @discord_bot.event async def on_ready(): - log.info("Discord bot logged in as {} ({})".format(discord_bot.user.name, discord_bot.user.id)) + log.info("Discord bot logged in as %s (%s)", discord_bot.user.name, discord_bot.user.id) global WEBHOOKS WEBHOOKS = [] session = database_session.get_session() @@ -408,7 +405,7 @@ def main(): if webhook.name == "_minecraft" and webhook.user == discord_bot.user: WEBHOOKS.append(webhook.url) found = True - log.debug("Found webhook {} in channel {}".format(webhook.name, discord_channel.name)) + log.debug("Found webhook %s in channel %s", webhook.name, discord_channel.name) if not found: # Create the hook await discord_channel.create_webhook(name="_minecraft") @@ -439,7 +436,6 @@ def main(): error_msg = await message.channel.send(msg) await asyncio.sleep(3) await error_msg.delete() - finally: return elif message.content.startswith("mc!register"): @@ -476,7 +472,6 @@ def main(): error_msg = await message.channel.send(msg) await asyncio.sleep(3) await error_msg.delete() - finally: return # Global Commands @@ -500,7 +495,6 @@ def main(): error_msg = await message.channel.send(msg) await asyncio.sleep(3) await error_msg.delete() - finally: return session = database_session.get_session() channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all() @@ -576,8 +570,8 @@ def main(): "Players online: {}\n" \ "{}".format(escape_markdown( strip_colour(TAB_HEADER)), escape_markdown( - strip_colour(player_list)), escape_markdown( - strip_colour(TAB_FOOTER))) + strip_colour(player_list)), escape_markdown( + strip_colour(TAB_FOOTER))) await send_channel.send(msg) except discord.errors.Forbidden: if isinstance(message.author, discord.abc.User): @@ -585,7 +579,6 @@ def main(): error_msg = await message.channel.send(msg) await asyncio.sleep(3) await error_msg.delete() - finally: return elif message.content.startswith("mc!"): @@ -606,7 +599,6 @@ def main(): error_msg = await message.channel.send(msg) await asyncio.sleep(3) await error_msg.delete() - finally: return elif not message.author.bot: @@ -635,7 +627,7 @@ def main(): if total_len > 256: message_to_send = message_to_send[:(256 - padding)] message_to_discord = message_to_discord[:(256 - padding)] - elif len(message_to_send) <= 0: + elif not message_to_send: return session = database_session.get_session() @@ -661,14 +653,13 @@ def main(): error_msg = await message.channel.send(msg) await asyncio.sleep(3) await error_msg.delete() - finally: return PREVIOUS_MESSAGE = message_to_send NEXT_MESSAGE_TIME = datetime.now(timezone.utc) + timedelta(seconds=config.message_delay) - log.info("Outgoing message from discord: Username: {} Message: {}".format( - minecraft_username, message_to_send)) + log.info("Outgoing message from discord: Username: %s Message: %s", + minecraft_username, message_to_send) for channel in channels: webhooks = await discord_bot.get_channel(channel.channel_id).webhooks() @@ -699,10 +690,10 @@ def main(): error_msg = await message.channel.send(msg) await asyncio.sleep(3) await error_msg.delete() + return finally: session.close() del session - return else: session.close() del session From 27273edd5748eca279c080ad173b467dbc330f81 Mon Sep 17 00:00:00 2001 From: Tristan Gosselin-Hane Date: Sun, 22 Sep 2019 16:33:55 -0400 Subject: [PATCH 3/5] Launch the bridge using the new module name --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1760478..4211a73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,4 @@ RUN pip install --no-cache-dir pipenv \ VOLUME "/data" -CMD [ "python", "-m", "pipenv", "run", "python", "-m", "minecraft-discord-bridge" ] +CMD [ "python", "-m", "pipenv", "run", "python", "-m", "minecraft_discord_bridge" ] From 3f9a9384740eb10778750e95577c85c8ae27f5eb Mon Sep 17 00:00:00 2001 From: Tristan Gosselin-Hane Date: Sun, 22 Sep 2019 17:25:10 -0400 Subject: [PATCH 4/5] Refactor elasticsearch logger into class --- .../elasticsearch_logger.py | 94 ++++++++----------- 1 file changed, 41 insertions(+), 53 deletions(-) diff --git a/minecraft_discord_bridge/elasticsearch_logger.py b/minecraft_discord_bridge/elasticsearch_logger.py index fd8ea72..47cfa76 100644 --- a/minecraft_discord_bridge/elasticsearch_logger.py +++ b/minecraft_discord_bridge/elasticsearch_logger.py @@ -4,67 +4,55 @@ import logging import requests -_username = None -_password = None -_auth = None -_url = None -log = logging.getLogger("bridge.elasticsearch") - - -def initialize(config): - global _username, _password, _url, _auth - if config.es_auth: - _auth = True - _username = config.es_username - _password = config.es_password - _url = config.es_url - - -def log_connection(uuid, reason, count=0): - if ConnectionReason(reason).name != "SEEN": +class ElasticsearchLogger(): + def __init__(self, url: str, username: str = "", password: str = ""): + self.url = url + self.username = username + self.password = password + self.log = logging.getLogger("bridge.elasticsearch") + + def log_connection(self, uuid, reason, count=0): + if ConnectionReason(reason).name != "SEEN": + es_payload = { + "uuid": uuid, + "time": (lambda: int(round(time.time() * 1000)))(), + "reason": ConnectionReason(reason).name, + "count": count, + } + else: + es_payload = { + "uuid": uuid, + "time": (lambda: int(round(time.time() * 1000)))(), + "reason": ConnectionReason(reason).name, + } + self.post_request("connections/_doc/", es_payload) + + def log_chat_message(self, uuid, display_name, message, message_unformatted): es_payload = { "uuid": uuid, + "display_name": display_name, + "message": message, + "message_unformatted": message_unformatted, "time": (lambda: int(round(time.time() * 1000)))(), - "reason": ConnectionReason(reason).name, - "count": count, } - else: + self.post_request("chat_messages/_doc/", es_payload) + + def log_raw_message(self, type, message): es_payload = { - "uuid": uuid, "time": (lambda: int(round(time.time() * 1000)))(), - "reason": ConnectionReason(reason).name, + "type": type, + "message": message, } - post_request("connections/_doc/", es_payload) - - -def log_chat_message(uuid, display_name, message, message_unformatted): - es_payload = { - "uuid": uuid, - "display_name": display_name, - "message": message, - "message_unformatted": message_unformatted, - "time": (lambda: int(round(time.time() * 1000)))(), - } - post_request("chat_messages/_doc/", es_payload) - - -def log_raw_message(type, message): - es_payload = { - "time": (lambda: int(round(time.time() * 1000)))(), - "type": type, - "message": message, - } - post_request("raw_messages/_doc/", es_payload) - - -def post_request(endpoint, payload): - the_url = "{}{}".format(_url, endpoint) - if _auth: - post = requests.post(the_url, auth=(_username, _password), json=payload) - else: - post = requests.post(the_url, json=payload) - log.debug(post.text) + self.post_request("raw_messages/_doc/", es_payload) + + def post_request(self, endpoint, payload): + theURL = "{}{}".format(self.url, endpoint) + if self.username and self.password: + post = requests.post(theURL, auth=(self.username, self.password), json=payload) + else: + post = requests.post(theURL, json=payload) + self.log.debug(post.text) class ConnectionReason(Enum): From c801ced15cc38e770292cb8f7a0368b050ce238a Mon Sep 17 00:00:00 2001 From: Tristan Gosselin-Hane Date: Sun, 22 Sep 2019 20:49:14 -0400 Subject: [PATCH 5/5] Completely refactor the codebase and make everything object-oriented and pythonic Ensure 100% compliance with the pylint and flake8 linters --- minecraft_discord_bridge/auth_server.py | 75 +- minecraft_discord_bridge/config.py | 11 +- minecraft_discord_bridge/database_session.py | 20 +- .../elasticsearch_logger.py | 10 +- .../minecraft_discord_bridge.py | 1147 ++++++++--------- tox.ini | 38 +- 6 files changed, 665 insertions(+), 636 deletions(-) diff --git a/minecraft_discord_bridge/auth_server.py b/minecraft_discord_bridge/auth_server.py index c577e51..4297972 100644 --- a/minecraft_discord_bridge/auth_server.py +++ b/minecraft_discord_bridge/auth_server.py @@ -3,7 +3,8 @@ from datetime import datetime from quarry.net.server import ServerFactory, ServerProtocol from .database import AccountLinkToken, MinecraftAccount, DiscordAccount -from . import database_session + +DATABASE_SESSION = None class AuthProtocol(ServerProtocol): @@ -32,46 +33,42 @@ class AuthProtocol(ServerProtocol): self.logger.info("[AUTH SERVER] %s (%s) connected to address %s:%s", display_name, uuid, ip_addr, connect_port) - try: - connection_token = ip_addr.split(".")[0] - session = database_session.get_session() - token = session.query(AccountLinkToken).filter_by(token=connection_token).first() - if not token: - self.close("You have connected with an invalid token!") - session.close() - return - discord_account = session.query(DiscordAccount).filter_by(link_token_id=token.id).first() - if not discord_account: - self.close("You have connected with an invalid token!") - session.close() - return - if datetime.utcnow() < token.expiry: - # Check if they already have a linked account and are re-linking - if discord_account.minecraft_account_id is not None: - existing_account = session.query(MinecraftAccount).filter_by( - id=discord_account.minecraft_account_id).first() - self.logger.info("unlinking existing %s account and replacing it with %s", - existing_account.minecraft_uuid, str(uuid)) - session.delete(existing_account) - mc_account = MinecraftAccount(str(uuid), discord_account.id) - discord_account.minecraft_account = mc_account - session.add(mc_account) - session.delete(token) - session.commit() - session.close() - self.close("Your minecraft account has successfully been linked to your discord account!") - return - else: - session.delete(token) - session.commit() - session.close() - self.close("You have connected with an expired token! " - "Please run the mc!register command again to get a new token.") - return - except Exception as e: - self.logger.error(e) + connection_token = ip_addr.split(".")[0] + session = DATABASE_SESSION.get_session() + token = session.query(AccountLinkToken).filter_by(token=connection_token).first() + if not token: + self.close("You have connected with an invalid token!") + session.close() + return + discord_account = session.query(DiscordAccount).filter_by(link_token_id=token.id).first() + if not discord_account: + self.close("You have connected with an invalid token!") + session.close() + return + if datetime.utcnow() < token.expiry: + # Check if they already have a linked account and are re-linking + if discord_account.minecraft_account_id is not None: + existing_account = session.query(MinecraftAccount).filter_by( + id=discord_account.minecraft_account_id).first() + self.logger.info("unlinking existing %s account and replacing it with %s", + existing_account.minecraft_uuid, str(uuid)) + session.delete(existing_account) + mc_account = MinecraftAccount(str(uuid), discord_account.id) + discord_account.minecraft_account = mc_account + session.add(mc_account) + session.delete(token) + session.commit() + session.close() + self.close("Your minecraft account has successfully been linked to your discord account!") + return + else: + session.delete(token) + session.commit() session.close() + self.close("You have connected with an expired token! " + "Please run the mc!register command again to get a new token.") + return # Kick the player. self.close("This shouldn't happen!") diff --git a/minecraft_discord_bridge/config.py b/minecraft_discord_bridge/config.py index 39a6748..f05c77e 100644 --- a/minecraft_discord_bridge/config.py +++ b/minecraft_discord_bridge/config.py @@ -1,14 +1,13 @@ import json import logging -log = logging.getLogger("bridge.config") - class Configuration(object): def __init__(self, path): + self.logger = logging.getLogger("bridge.config") try: - with open(path, 'r') as f: - self._config = json.load(f) + with open(path, 'r') as file: + self._config = json.load(file) if self._config: self.mc_username = self._config["MAIN"]["MC_USERNAME"] self.mc_password = self._config["MAIN"]["MC_PASSWORD"] @@ -29,8 +28,8 @@ class Configuration(object): self.es_username = self._config["ELASTICSEARCH"]["USERNAME"] self.es_password = self._config["ELASTICSEARCH"]["PASSWORD"] else: - logging.error("error reading config") + self.logger.error("error reading config") exit(1) except IOError: - logging.error("error reading config") + self.logger.error("error reading config") exit(1) diff --git a/minecraft_discord_bridge/database_session.py b/minecraft_discord_bridge/database_session.py index d6b80c6..3338687 100644 --- a/minecraft_discord_bridge/database_session.py +++ b/minecraft_discord_bridge/database_session.py @@ -2,17 +2,19 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -_engine = None Base = declarative_base() -def initialize(config): - global _engine - _connection_string = config.database_connection_string - _engine = create_engine(_connection_string) - Base.metadata.create_all(_engine) +class DatabaseSession(): + def __init__(self): + self._engine = None + self.connection_string = None + def initialize(self, config): + self.connection_string = config.database_connection_string + self._engine = create_engine(self.connection_string) + Base.metadata.create_all(self._engine) -def get_session(): - Session = sessionmaker(bind=_engine)() - return Session + def get_session(self): + session = sessionmaker(bind=self._engine)() + return session diff --git a/minecraft_discord_bridge/elasticsearch_logger.py b/minecraft_discord_bridge/elasticsearch_logger.py index 47cfa76..37de930 100644 --- a/minecraft_discord_bridge/elasticsearch_logger.py +++ b/minecraft_discord_bridge/elasticsearch_logger.py @@ -38,20 +38,20 @@ class ElasticsearchLogger(): } self.post_request("chat_messages/_doc/", es_payload) - def log_raw_message(self, type, message): + def log_raw_message(self, msg_type, message): es_payload = { "time": (lambda: int(round(time.time() * 1000)))(), - "type": type, + "type": msg_type, "message": message, } self.post_request("raw_messages/_doc/", es_payload) def post_request(self, endpoint, payload): - theURL = "{}{}".format(self.url, endpoint) + the_url = "{}{}".format(self.url, endpoint) if self.username and self.password: - post = requests.post(theURL, auth=(self.username, self.password), json=payload) + post = requests.post(the_url, auth=(self.username, self.password), json=payload) else: - post = requests.post(theURL, json=payload) + post = requests.post(the_url, json=payload) self.log.debug(post.text) diff --git a/minecraft_discord_bridge/minecraft_discord_bridge.py b/minecraft_discord_bridge/minecraft_discord_bridge.py index b6a3f5a..5820456 100755 --- a/minecraft_discord_bridge/minecraft_discord_bridge.py +++ b/minecraft_discord_bridge/minecraft_discord_bridge.py @@ -4,7 +4,6 @@ from __future__ import print_function import sys import re -from enum import Enum import json import time import logging @@ -24,181 +23,514 @@ import discord from mcstatus import MinecraftServer from bidict import bidict -from . import database_session -from . import elasticsearch_logger as el +from .database_session import DatabaseSession +from .elasticsearch_logger import ElasticsearchLogger, ConnectionReason from .config import Configuration from .database import DiscordChannel, AccountLinkToken, DiscordAccount -log = logging.getLogger("bridge") -SESSION_TOKEN = "" -UUID_CACHE = bidict() -WEBHOOKS = [] -BOT_USERNAME = "" -NEXT_MESSAGE_TIME = datetime.now(timezone.utc) -PREVIOUS_MESSAGE = "" -PLAYER_LIST = bidict() -PREVIOUS_PLAYER_LIST = bidict() -ACCEPT_JOIN_EVENTS = False -TAB_HEADER = "" -TAB_FOOTER = "" +class MinecraftDiscordBridge(): + def __init__(self): + self.session_token = "" + self.uuid_cache = bidict() + self.webhooks = [] + self.bot_username = "" + self.next_message_time = datetime.now(timezone.utc) + self.previous_message = "" + self.player_list = bidict() + self.previous_player_list = bidict() + self.accept_join_events = False + self.tab_header = "" + self.tab_footer = "" + # Initialize the discord part + self.discord_bot = discord.Client() + self.config = Configuration("config.json") + self.auth_token = None + self.connection = None + self.setup_logging(self.config.logging_level) + self.database_session = DatabaseSession() + self.logger = logging.getLogger("bridge") + self.database_session.initialize(self.config) + # We need to import twisted after setting up the logger because twisted hijacks our logging + from . import auth_server + auth_server.DATABASE_SESSION = self.database_session + if self.config.es_enabled: + if self.config.es_auth: + self.es_logger = ElasticsearchLogger( + self.config.es_url, self.config.es_username, self.config.es_password) + else: + self.es_logger = ElasticsearchLogger(self.config.es_url) + + @self.discord_bot.event + async def on_ready(): # pylint: disable=W0612 + self.logger.info("Discord bot logged in as %s (%s)", self.discord_bot.user.name, self.discord_bot.user.id) + self.webhooks = [] + session = self.database_session.get_session() + channels = session.query(DiscordChannel).all() + session.close() + for channel in channels: + channel_id = channel.channel_id + discord_channel = self.discord_bot.get_channel(channel_id) + channel_webhooks = await discord_channel.webhooks() + found = False + for webhook in channel_webhooks: + if webhook.name == "_minecraft" and webhook.user == self.discord_bot.user: + self.webhooks.append(webhook.url) + found = True + self.logger.debug("Found webhook %s in channel %s", webhook.name, discord_channel.name) + if not found: + # Create the hook + await discord_channel.create_webhook(name="_minecraft") + + @self.discord_bot.event + async def on_message(message): # pylint: disable=W0612 + # We do not want the bot to reply to itself + if message.author == self.discord_bot.user: + return + this_channel = message.channel.id + # PM Commands + if message.content.startswith("mc!help"): + try: + send_channel = message.channel + if isinstance(message.channel, discord.abc.GuildChannel): + await message.delete() + dm_channel = message.author.dm_channel + if not dm_channel: + await message.author.create_dm() + send_channel = message.author.dm_channel + msg = self.get_discord_help_string() + await send_channel.send(msg) + except discord.errors.Forbidden: + if isinstance(message.author, discord.abc.User): + msg = "{}, please allow private messages from this bot.".format(message.author.mention) + error_msg = await message.channel.send(msg) + await asyncio.sleep(3) + await error_msg.delete() + return -def mc_uuid_to_username(mc_uuid): - if mc_uuid not in UUID_CACHE: - try: - short_uuid = mc_uuid.replace("-", "") - mojang_response = requests.get("https://api.mojang.com/user/profiles/{}/names".format(short_uuid)).json() - if len(mojang_response) > 1: - # Multiple name changes - player_username = mojang_response[-1]["name"] - else: - # Only one name - player_username = mojang_response[0]["name"] - UUID_CACHE[mc_uuid] = player_username - return player_username - except requests.RequestException as e: - log.error(e, exc_info=True) - log.error("Failed to lookup %s's username using the Mojang API.", mc_uuid) - else: - return UUID_CACHE[mc_uuid] - - -def mc_username_to_uuid(username): - if username not in UUID_CACHE.inv: - try: - player_uuid = requests.get( - "https://api.mojang.com/users/profiles/minecraft/{}".format(username)).json()["id"] - long_uuid = uuid.UUID(player_uuid) - UUID_CACHE.inv[username] = str(long_uuid) - return player_uuid - except requests.RequestException: - log.error("Failed to lookup %s's username using the Mojang API.", username) - else: - return UUID_CACHE.inv[username] - - -def get_discord_help_string(): - help_str = ("Admin commands:\n" - "`mc!chathere`: Starts outputting server messages in this channel\n" - "`mc!stopchathere`: Stops outputting server messages in this channel\n" - "User commands:\n" - "`mc!tab`: Sends you the content of the server's player/tab list\n" - "`mc!register`: Starts the minecraft account registration process\n" - "To start chatting on the minecraft server, please register your account using `mc!register`.") - return help_str - - -# https://stackoverflow.com/questions/33404752/removing-emojis-from-a-string-in-python -def remove_emoji(dirty_string): - emoji_pattern = re.compile( - "[" - u"\U0001F600-\U0001F64F" # emoticons - u"\U0001F300-\U0001F5FF" # symbols & pictographs - u"\U0001F680-\U0001F6FF" # transport & map symbols - u"\U0001F1E0-\U0001F1FF" # flags (iOS) - u"\U0001F900-\U0001FAFF" # CJK Compatibility Ideographs - # u"\U00002702-\U000027B0" - # u"\U000024C2-\U0001F251" - "]+", flags=re.UNICODE) - return emoji_pattern.sub(r'', dirty_string) - - -def escape_markdown(md_string): - # Absolutely needs to go first or it will replace our escaping slashes! - escaped_string = md_string.replace("\\", "\\\\") - escaped_string = escaped_string.replace("_", "\\_") - escaped_string = escaped_string.replace("*", "\\*") - return escaped_string - - -def strip_colour(dirty_string): - colour_pattern = re.compile( - u"\U000000A7" # selection symbol - ".", flags=re.UNICODE) - return colour_pattern.sub(r'', dirty_string) - - -def setup_logging(level): - if level.lower() == "debug": - log_level = logging.DEBUG - else: - log_level = logging.INFO - log_format = "%(asctime)s:%(name)s:%(levelname)s:%(message)s" - logging.basicConfig(filename="bridge_log.log", format=log_format, level=log_level) - stdout_logger = logging.StreamHandler(sys.stdout) - stdout_logger.setFormatter(logging.Formatter(log_format)) - logging.getLogger().addHandler(stdout_logger) - - -def run_auth_server(port): - # We need to import twisted after setting up the logger because twisted hijacks our logging - # TODO: Fix this in a cleaner way - from twisted.internet import reactor - from .auth_server import AuthFactory - - # Create factory - factory = AuthFactory() - - # Listen - log.info("Starting authentication server on port %d", port) - - factory.listen("", port) - try: - reactor.run(installSignalHandlers=False) - except KeyboardInterrupt: - reactor.stop() - - -def generate_random_auth_token(length): - letters = string.ascii_lowercase + string.digits + string.ascii_uppercase - return ''.join(random.choice(letters) for i in range(length)) - - -# TODO: Get rid of this when pycraft's enum becomes usable -class ChatType(Enum): - CHAT = 0 # A player-initiated chat message. - SYSTEM = 1 # The result of running a command. - GAME_INFO = 2 # Displayed above the hotbar in vanilla clients. + elif message.content.startswith("mc!register"): + try: + send_channel = message.channel + if isinstance(message.channel, discord.abc.GuildChannel): + await message.delete() + dm_channel = message.author.dm_channel + if not dm_channel: + await message.author.create_dm() + send_channel = message.author.dm_channel + session = self.database_session.get_session() + discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() + if not discord_account: + new_discord_account = DiscordAccount(message.author.id) + session.add(new_discord_account) + session.commit() + discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() + + new_token = self.generate_random_auth_token(16) + account_link_token = AccountLinkToken(message.author.id, new_token) + discord_account.link_token = account_link_token + session.add(account_link_token) + session.commit() + msg = "Please connect your minecraft account to `{}.{}:{}` in order to link it to this bridge!"\ + .format(new_token, self.config.auth_dns, self.config.auth_port) + session.close() + del session + await send_channel.send(msg) + except discord.errors.Forbidden: + if isinstance(message.author, discord.abc.User): + msg = "{}, please allow private messages from this bot.".format(message.author.mention) + error_msg = await message.channel.send(msg) + await asyncio.sleep(3) + await error_msg.delete() + return + # Global Commands + elif message.content.startswith("mc!chathere"): + if isinstance(message.channel, discord.abc.PrivateChannel): + msg = "Sorry, this command is only available in public channels." + await message.channel.send(msg) + return + if message.author.id not in self.config.admin_users: + await message.delete() + try: + dm_channel = message.author.dm_channel + if not dm_channel: + await message.author.create_dm() + dm_channel = message.author.dm_channel + msg = "Sorry, you do not have permission to execute that command!" + await dm_channel.send(msg) + except discord.errors.Forbidden: + if isinstance(message.author, discord.abc.User): + msg = "{}, please allow private messages from this bot.".format(message.author.mention) + error_msg = await message.channel.send(msg) + await asyncio.sleep(3) + await error_msg.delete() + return + session = self.database_session.get_session() + channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all() + if not channels: + new_channel = DiscordChannel(this_channel) + session.add(new_channel) + session.commit() + session.close() + del session + webhook = await message.channel.create_webhook(name="_minecraft") + self.webhooks.append(webhook.url) + msg = "The bot will now start chatting here! To stop this, run `mc!stopchathere`." + await message.channel.send(msg) + else: + msg = "The bot is already chatting in this channel! To stop this, run `mc!stopchathere`." + await message.channel.send(msg) + return -def main(): - global BOT_USERNAME - config = Configuration("config.json") - setup_logging(config.logging_level) - - database_session.initialize(config) - if config.es_enabled: - el.initialize(config) - - reactor_thread = Thread(target=run_auth_server, args=(config.auth_port,)) - reactor_thread.start() - - def handle_disconnect(): - log.info('Disconnected.') - global PLAYER_LIST, PREVIOUS_PLAYER_LIST, ACCEPT_JOIN_EVENTS - PREVIOUS_PLAYER_LIST = PLAYER_LIST.copy() - ACCEPT_JOIN_EVENTS = False - PLAYER_LIST = bidict() - if connection.connected: - log.info("Forced a disconnection because the connection is still connected.") - connection.disconnect(immediate=True) + elif message.content.startswith("mc!stopchathere"): + if isinstance(message.channel, discord.abc.PrivateChannel): + msg = "Sorry, this command is only available in public channels." + await message.channel.send(msg) + return + if message.author.id not in self.config.admin_users: + await message.delete() + try: + dm_channel = message.author.dm_channel + if not dm_channel: + await message.author.create_dm() + dm_channel = message.author.dm_channel + msg = "Sorry, you do not have permission to execute that command!" + await dm_channel.send(msg) + except discord.errors.Forbidden: + if isinstance(message.author, discord.abc.User): + msg = "{}, please allow private messages from this bot.".format(message.author.mention) + error_msg = await message.channel.send(msg) + await asyncio.sleep(3) + await error_msg.delete() + return + session = self.database_session.get_session() + deleted = session.query(DiscordChannel).filter_by(channel_id=this_channel).delete() + session.commit() + session.close() + for webhook in await message.channel.webhooks(): + if webhook.name == "_minecraft" and webhook.user == self.discord_bot.user: + # Copy the list to avoid some problems since + # we're deleting indicies form it as we loop + # through it + if webhook.url in self.webhooks[:]: + self.webhooks.remove(webhook.url) + await webhook.delete() + if deleted < 1: + msg = "The bot was not chatting here!" + await message.channel.send(msg) + return + else: + msg = "The bot will no longer chat here!" + await message.channel.send(msg) + return + + elif message.content.startswith("mc!tab"): + send_channel = message.channel + try: + if isinstance(message.channel, discord.abc.GuildChannel): + await message.delete() + dm_channel = message.author.dm_channel + if not dm_channel: + await message.author.create_dm() + send_channel = message.author.dm_channel + player_list = ", ".join(list(map(lambda x: x[1], self.player_list.items()))) + msg = "{}\n" \ + "Players online: {}\n" \ + "{}".format(self.escape_markdown( + self.strip_colour(self.tab_header)), self.escape_markdown( + self.strip_colour(player_list)), self.escape_markdown( + self.strip_colour(self.tab_footer))) + await send_channel.send(msg) + except discord.errors.Forbidden: + if isinstance(message.author, discord.abc.User): + msg = "{}, please allow private messages from this bot.".format(message.author.mention) + error_msg = await message.channel.send(msg) + await asyncio.sleep(3) + await error_msg.delete() + return + + elif message.content.startswith("mc!"): + # Catch-all + send_channel = message.channel + try: + if isinstance(message.channel, discord.abc.GuildChannel): + await message.delete() + dm_channel = message.author.dm_channel + if not dm_channel: + await message.author.create_dm() + send_channel = message.author.dm_channel + msg = "Unknown command, type `mc!help` for a list of commands." + await send_channel.send(msg) + except discord.errors.Forbidden: + if isinstance(message.author, discord.abc.User): + msg = "{}, please allow private messages from this bot.".format(message.author.mention) + error_msg = await message.channel.send(msg) + await asyncio.sleep(3) + await error_msg.delete() + return + + elif not message.author.bot: + session = self.database_session.get_session() + channel_should_chat = session.query(DiscordChannel).filter_by(channel_id=this_channel).first() + if channel_should_chat: + await message.delete() + discord_user = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() + if discord_user: + if discord_user.minecraft_account: + minecraft_uuid = discord_user.minecraft_account.minecraft_uuid + session.close() + del session + minecraft_username = self.mc_uuid_to_username(minecraft_uuid) + + # Max chat message length: 256, bot username does not count towards this + # Does not count|Counts + # minecraft_username: message + padding = 2 + len(minecraft_username) + + message_to_send = self.remove_emoji( + message.clean_content.encode('utf-8').decode('ascii', 'replace')).strip() + message_to_discord = self.escape_markdown(message.clean_content) + + total_len = padding + len(message_to_send) + if total_len > 256: + message_to_send = message_to_send[:(256 - padding)] + message_to_discord = message_to_discord[:(256 - padding)] + elif not message_to_send: + return + + session = self.database_session.get_session() + channels = session.query(DiscordChannel).all() + session.close() + del session + if message_to_send == self.previous_message or \ + datetime.now(timezone.utc) < self.next_message_time: + send_channel = message.channel + try: + if isinstance(message.channel, discord.abc.GuildChannel): + dm_channel = message.author.dm_channel + if not dm_channel: + await message.author.create_dm() + send_channel = message.author.dm_channel + msg = "Your message \"{}\" has been rate-limited.".format(message.clean_content) + await send_channel.send(msg) + except discord.errors.Forbidden: + if isinstance(message.author, discord.abc.User): + msg = "{}, please allow private messages from this bot.".format( + message.author.mention) + error_msg = await message.channel.send(msg) + await asyncio.sleep(3) + await error_msg.delete() + return + + self.previous_message = message_to_send + self.next_message_time = datetime.now(timezone.utc) + timedelta( + seconds=self.config.message_delay) + + self.logger.info("Outgoing message from discord: Username: %s Message: %s", + minecraft_username, message_to_send) + + for channel in channels: + webhooks = await self.discord_bot.get_channel(channel.channel_id).webhooks() + for webhook in webhooks: + if webhook.name == "_minecraft": + await webhook.send( + username=minecraft_username, + avatar_url="https://visage.surgeplay.com/face/160/{}".format( + minecraft_uuid), + content=message_to_discord) + + packet = serverbound.play.ChatPacket() + packet.message = "{}: {}".format(minecraft_username, message_to_send) + self.connection.write_packet(packet) + else: + send_channel = message.channel + try: + if isinstance(message.channel, discord.abc.GuildChannel): + dm_channel = message.author.dm_channel + if not dm_channel: + await message.author.create_dm() + send_channel = message.author.dm_channel + msg = "Unable to send chat message: there is no Minecraft account linked to this discord " \ + "account, please run `mc!register`." + await send_channel.send(msg) + except discord.errors.Forbidden: + if isinstance(message.author, discord.abc.User): + msg = "{}, please allow private messages from this bot.".format(message.author.mention) + error_msg = await message.channel.send(msg) + await asyncio.sleep(3) + await error_msg.delete() + return + finally: + session.close() + del session + else: + session.close() + del session + + def run(self): + reactor_thread = Thread(target=self.run_auth_server, args=(self.config.auth_port,)) + reactor_thread.start() + + self.logger.debug("Checking if the server {} is online before connecting.") + + if not self.config.mc_online: + self.logger.info("Connecting in offline mode...") + while not self.is_server_online(): + self.logger.info('Not connecting to server because it appears to be offline.') + time.sleep(15) + self.bot_username = self.config.mc_username + self.connection = Connection( + self.config.mc_server, self.config.mc_port, username=self.config.mc_username, + handle_exception=self.minecraft_handle_exception) + else: + self.auth_token = authentication.AuthenticationToken() + try: + self.auth_token.authenticate(self.config.mc_username, self.config.mc_password) + except YggdrasilError as ex: + self.logger.info(ex) + sys.exit() + self.bot_username = self.auth_token.profile.name + self.logger.info("Logged in as %s...", self.auth_token.profile.name) + while not self.is_server_online(): + self.logger.info('Not connecting to server because it appears to be offline.') + time.sleep(15) + self.connection = Connection( + self.config.mc_server, self.config.mc_port, auth_token=self.auth_token, + handle_exception=self.minecraft_handle_exception) + + self.register_handlers(self.connection) + self.connection.connect() + self.discord_bot.run(self.config.discord_token) + + def mc_uuid_to_username(self, mc_uuid: str): + if mc_uuid not in self.uuid_cache: + try: + short_uuid = mc_uuid.replace("-", "") + mojang_response = requests.get("https://api.mojang.com/user/profiles/{}/names".format( + short_uuid)).json() + if len(mojang_response) > 1: + # Multiple name changes + player_username = mojang_response[-1]["name"] + else: + # Only one name + player_username = mojang_response[0]["name"] + self.uuid_cache[mc_uuid] = player_username + return player_username + except requests.RequestException as ex: + self.logger.error(ex, exc_info=True) + self.logger.error("Failed to lookup %s's username using the Mojang API.", mc_uuid) + else: + return self.uuid_cache[mc_uuid] + + def mc_username_to_uuid(self, username: str): + if username not in self.uuid_cache.inv: + try: + player_uuid = requests.get( + "https://api.mojang.com/users/profiles/minecraft/{}".format(username)).json()["id"] + long_uuid = uuid.UUID(player_uuid) + self.uuid_cache.inv[username] = str(long_uuid) + return player_uuid + except requests.RequestException: + self.logger.error("Failed to lookup %s's username using the Mojang API.", username) + else: + return self.uuid_cache.inv[username] + + def get_discord_help_string(self): + help_str = ("Admin commands:\n" + "`mc!chathere`: Starts outputting server messages in this channel\n" + "`mc!stopchathere`: Stops outputting server messages in this channel\n" + "User commands:\n" + "`mc!tab`: Sends you the content of the server's player/tab list\n" + "`mc!register`: Starts the minecraft account registration process\n" + "To start chatting on the minecraft server, please register your account using `mc!register`.") + return help_str + + # https://stackoverflow.com/questions/33404752/removing-emojis-from-a-string-in-python + def remove_emoji(self, dirty_string): + emoji_pattern = re.compile( + "[" + u"\U0001F600-\U0001F64F" # emoticons + u"\U0001F300-\U0001F5FF" # symbols & pictographs + u"\U0001F680-\U0001F6FF" # transport & map symbols + u"\U0001F1E0-\U0001F1FF" # flags (iOS) + u"\U0001F900-\U0001FAFF" # CJK Compatibility Ideographs + # u"\U00002702-\U000027B0" + # u"\U000024C2-\U0001F251" + "]+", flags=re.UNICODE) + return emoji_pattern.sub(r'', dirty_string) + + def escape_markdown(self, md_string): + # Absolutely needs to go first or it will replace our escaping slashes! + escaped_string = md_string.replace("\\", "\\\\") + escaped_string = escaped_string.replace("_", "\\_") + escaped_string = escaped_string.replace("*", "\\*") + return escaped_string + + def strip_colour(self, dirty_string): + colour_pattern = re.compile( + u"\U000000A7" # selection symbol + ".", flags=re.UNICODE) + return colour_pattern.sub(r'', dirty_string) + + def setup_logging(self, level): + if level.lower() == "debug": + log_level = logging.DEBUG + else: + log_level = logging.INFO + log_format = "%(asctime)s:%(name)s:%(levelname)s:%(message)s" + logging.basicConfig(filename="bridge_log.log", format=log_format, level=log_level) + stdout_logger = logging.StreamHandler(sys.stdout) + stdout_logger.setFormatter(logging.Formatter(log_format)) + logging.getLogger().addHandler(stdout_logger) + + def run_auth_server(self, port): + # We need to import twisted after setting up the logger because twisted hijacks our logging + from twisted.internet import reactor + from .auth_server import AuthFactory + + # Create factory + factory = AuthFactory() + + # Listen + self.logger.info("Starting authentication server on port %d", port) + + factory.listen("", port) + try: + reactor.run(installSignalHandlers=False) + except KeyboardInterrupt: + reactor.stop() + + def generate_random_auth_token(self, length): + letters = string.ascii_lowercase + string.digits + string.ascii_uppercase + return ''.join(random.choice(letters) for i in range(length)) + + def handle_disconnect(self, json_data=""): + self.logger.info('Disconnected.') + if json_data: + self.logger.info("Disconnect json data: %s", json_data) + self.previous_player_list = self.player_list.copy() + self.accept_join_events = False + self.player_list = bidict() + if self.connection.connected: + self.logger.info("Forced a disconnection because the connection is still connected.") + self.connection.disconnect(immediate=True) time.sleep(15) - while not is_server_online(): - log.info('Not reconnecting to server because it appears to be offline.') + while not self.is_server_online(): + self.logger.info('Not reconnecting to server because it appears to be offline.') time.sleep(15) - log.info('Reconnecting.') - connection.connect() + self.logger.info('Reconnecting.') + self.connection.connect() - def handle_disconnect_packet(join_game_packet): - handle_disconnect() + def handle_disconnect_packet(self, disconnect_packet): + self.handle_disconnect(disconnect_packet.json_data) - def minecraft_handle_exception(exception, exc_info): - log.error("A minecraft exception occured! %s:", exception, exc_info=exc_info) - handle_disconnect() + def minecraft_handle_exception(self, exception, exc_info): + self.logger.error("A minecraft exception occured! %s:", exception, exc_info=exc_info) + self.handle_disconnect() - def is_server_online(): - server = MinecraftServer.lookup("{}:{}".format(config.mc_server, config.mc_port)) + def is_server_online(self): + server = MinecraftServer.lookup("{}:{}".format(self.config.mc_server, self.config.mc_port)) try: status = server.status() del status @@ -210,111 +542,80 @@ def main(): except AttributeError: return False - log.debug("Checking if the server {} is online before connecting.") - - if not config.mc_online: - log.info("Connecting in offline mode...") - while not is_server_online(): - log.info('Not connecting to server because it appears to be offline.') - time.sleep(15) - BOT_USERNAME = config.mc_username - connection = Connection( - config.mc_server, config.mc_port, username=config.mc_username, - handle_exception=minecraft_handle_exception) - else: - auth_token = authentication.AuthenticationToken() - try: - auth_token.authenticate(config.mc_username, config.mc_password) - except YggdrasilError as e: - log.info(e) - sys.exit() - BOT_USERNAME = auth_token.profile.name - log.info("Logged in as %s...", auth_token.profile.name) - while not is_server_online(): - log.info('Not connecting to server because it appears to be offline.') - time.sleep(15) - connection = Connection( - config.mc_server, config.mc_port, auth_token=auth_token, - handle_exception=minecraft_handle_exception) - - # Initialize the discord part - discord_bot = discord.Client() - - def register_handlers(connection): + def register_handlers(self, connection): connection.register_packet_listener( - handle_join_game, clientbound.play.JoinGamePacket) + self.handle_join_game, clientbound.play.JoinGamePacket) connection.register_packet_listener( - handle_chat, clientbound.play.ChatMessagePacket) + self.handle_chat, clientbound.play.ChatMessagePacket) connection.register_packet_listener( - handle_health_update, clientbound.play.UpdateHealthPacket) + self.handle_health_update, clientbound.play.UpdateHealthPacket) connection.register_packet_listener( - handle_disconnect_packet, clientbound.play.DisconnectPacket) + self.handle_disconnect_packet, clientbound.play.DisconnectPacket) connection.register_packet_listener( - handle_tab_list, clientbound.play.PlayerListItemPacket) + self.handle_tab_list, clientbound.play.PlayerListItemPacket) connection.register_packet_listener( - handle_player_list_header_and_footer_update, clientbound.play.PlayerListHeaderAndFooterPacket) - - def handle_player_list_header_and_footer_update(header_footer_packet): - global TAB_FOOTER, TAB_HEADER - log.debug("Got Tablist H/F Update: header=%s", header_footer_packet.header) - log.debug("Got Tablist H/F Update: footer=%s", header_footer_packet.footer) - TAB_HEADER = json.loads(header_footer_packet.header)["text"] - TAB_FOOTER = json.loads(header_footer_packet.footer)["text"] - - def handle_tab_list(tab_list_packet): - global ACCEPT_JOIN_EVENTS - log.debug("Processing tab list packet") + self.handle_player_list_header_and_footer_update, clientbound.play.PlayerListHeaderAndFooterPacket) + + def handle_player_list_header_and_footer_update(self, header_footer_packet): + self.logger.debug("Got Tablist H/F Update: header=%s", header_footer_packet.header) + self.logger.debug("Got Tablist H/F Update: footer=%s", header_footer_packet.footer) + self.tab_header = json.loads(header_footer_packet.header)["text"] + self.tab_footer = json.loads(header_footer_packet.footer)["text"] + + def handle_tab_list(self, tab_list_packet): + self.logger.debug("Processing tab list packet") for action in tab_list_packet.actions: if isinstance(action, clientbound.play.PlayerListItemPacket.AddPlayerAction): - log.debug( + self.logger.debug( "Processing AddPlayerAction tab list packet, name: %s, uuid: %s", action.name, action.uuid) username = action.name player_uuid = action.uuid - if action.name not in PLAYER_LIST.inv: - PLAYER_LIST.inv[action.name] = action.uuid + if action.name not in self.player_list.inv: + self.player_list.inv[action.name] = action.uuid else: # Sometimes we get a duplicate add packet on join idk why return - if action.name not in UUID_CACHE.inv: - UUID_CACHE.inv[action.name] = action.uuid + if action.name not in self.uuid_cache.inv: + self.uuid_cache.inv[action.name] = action.uuid # Initial tablist backfill - if ACCEPT_JOIN_EVENTS: + if self.accept_join_events: webhook_payload = { 'username': username, 'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid), 'content': '', 'embeds': [{'color': 65280, 'title': '**Joined the game**'}] } - for webhook in WEBHOOKS: + for webhook in self.webhooks: requests.post(webhook, json=webhook_payload) - if config.es_enabled: - el.log_connection( - uuid=action.uuid, reason=el.ConnectionReason.CONNECTED, count=len(PLAYER_LIST)) + if self.config.es_enabled: + self.es_logger.log_connection( + uuid=action.uuid, reason=ConnectionReason.CONNECTED, count=len(self.player_list)) return else: # The bot's name is sent last after the initial back-fill - if action.name == BOT_USERNAME: - ACCEPT_JOIN_EVENTS = True - if config.es_enabled: - diff = set(PREVIOUS_PLAYER_LIST.keys()) - set(PLAYER_LIST.keys()) + if action.name == self.bot_username: + self.accept_join_events = True + if self.config.es_enabled: + diff = set(self.previous_player_list.keys()) - set(self.player_list.keys()) for idx, player_uuid in enumerate(diff): - el.log_connection(uuid=player_uuid, reason=el.ConnectionReason.DISCONNECTED, - count=len(PREVIOUS_PLAYER_LIST) - (idx + 1)) + self.es_logger.log_connection( + uuid=player_uuid, reason=ConnectionReason.DISCONNECTED, + count=len(self.previous_player_list) - (idx + 1)) # Don't bother announcing the bot's own join message (who cares) but log it for analytics still - if config.es_enabled: - el.log_connection( - uuid=action.uuid, reason=el.ConnectionReason.CONNECTED, count=len(PLAYER_LIST)) + if self.config.es_enabled: + self.es_logger.log_connection( + uuid=action.uuid, reason=ConnectionReason.CONNECTED, count=len(self.player_list)) - if config.es_enabled: - el.log_connection(uuid=action.uuid, reason=el.ConnectionReason.SEEN) + if self.config.es_enabled: + self.es_logger.log_connection(uuid=action.uuid, reason=ConnectionReason.SEEN) if isinstance(action, clientbound.play.PlayerListItemPacket.RemovePlayerAction): - log.debug("Processing RemovePlayerAction tab list packet, uuid: %s", action.uuid) - username = mc_uuid_to_username(action.uuid) + self.logger.debug("Processing RemovePlayerAction tab list packet, uuid: %s", action.uuid) + username = self.mc_uuid_to_username(action.uuid) player_uuid = action.uuid webhook_payload = { 'username': username, @@ -322,19 +623,19 @@ def main(): 'content': '', 'embeds': [{'color': 16711680, 'title': '**Left the game**'}] } - for webhook in WEBHOOKS: + for webhook in self.webhooks: requests.post(webhook, json=webhook_payload) - del UUID_CACHE[action.uuid] - del PLAYER_LIST[action.uuid] - if config.es_enabled: - el.log_connection(uuid=action.uuid, reason=el.ConnectionReason.DISCONNECTED, count=len(PLAYER_LIST)) + del self.uuid_cache[action.uuid] + del self.player_list[action.uuid] + if self.config.es_enabled: + self.es_logger.log_connection( + uuid=action.uuid, reason=ConnectionReason.DISCONNECTED, count=len(self.player_list)) - def handle_join_game(join_game_packet): - global PLAYER_LIST - log.info('Connected.') - PLAYER_LIST = bidict() + def handle_join_game(self, join_game_packet): + self.logger.info('Connected and joined game as entity id %d', join_game_packet.entity_id) + self.player_list = bidict() - def handle_chat(chat_packet): + def handle_chat(self, chat_packet): json_data = json.loads(chat_packet.json_data) if "extra" not in json_data: return @@ -347,358 +648,52 @@ def main(): if regexp_match: username = regexp_match.group(1) original_message = regexp_match.group(2) - player_uuid = mc_username_to_uuid(username) - if username.lower() == BOT_USERNAME.lower(): + player_uuid = self.mc_username_to_uuid(username) + if username.lower() == self.bot_username.lower(): # Don't relay our own messages - if config.es_enabled: + if self.config.es_enabled: bot_message_match = re.match("<{}> (.*?): (.*)".format( - BOT_USERNAME.lower()), chat_string, re.M | re.I) + self.bot_username.lower()), chat_string, re.M | re.I) if bot_message_match: - el.log_chat_message( - uuid=mc_username_to_uuid(bot_message_match.group(1)), + self.es_logger.log_chat_message( + uuid=self.mc_username_to_uuid(bot_message_match.group(1)), display_name=bot_message_match.group(1), message=bot_message_match.group(2), message_unformatted=chat_string) - el.log_raw_message(type=ChatType(chat_packet.position).name, message=chat_packet.json_data) + self.es_logger.log_raw_message( + msg_type=chat_packet.Position.name_from_value(chat_packet.position), + message=chat_packet.json_data) return - log.info("Incoming message from minecraft: Username: %s Message: %s", username, original_message) - log.debug("msg: %s", repr(original_message)) - message = escape_markdown(remove_emoji(original_message.strip().replace("@", "@\N{zero width space}"))) + self.logger.info("Incoming message from minecraft: Username: %s Message: %s", username, original_message) + self.logger.debug("msg: %s", repr(original_message)) + message = self.escape_markdown(self.remove_emoji(original_message.strip().replace( + "@", "@\N{zero width space}"))) webhook_payload = { 'username': username, 'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid), 'content': '{}'.format(message) } - for webhook in WEBHOOKS: + for webhook in self.webhooks: requests.post(webhook, json=webhook_payload) - if config.es_enabled: - el.log_chat_message( + if self.config.es_enabled: + self.es_logger.log_chat_message( uuid=player_uuid, display_name=username, message=original_message, message_unformatted=chat_string) - if config.es_enabled: - el.log_raw_message(type=ChatType(chat_packet.position).name, message=chat_packet.json_data) + if self.config.es_enabled: + self.es_logger.log_raw_message( + msg_type=chat_packet.Position.name_from_value(chat_packet.position), + message=chat_packet.json_data) - def handle_health_update(health_update_packet): + def handle_health_update(self, health_update_packet): if health_update_packet.health <= 0: - log.debug("Respawned the player because it died") + self.logger.debug("Respawned the player because it died") packet = serverbound.play.ClientStatusPacket() packet.action_id = serverbound.play.ClientStatusPacket.RESPAWN - connection.write_packet(packet) - - register_handlers(connection) - - connection.connect() - - @discord_bot.event - async def on_ready(): - log.info("Discord bot logged in as %s (%s)", discord_bot.user.name, discord_bot.user.id) - global WEBHOOKS - WEBHOOKS = [] - session = database_session.get_session() - channels = session.query(DiscordChannel).all() - session.close() - for channel in channels: - channel_id = channel.channel_id - discord_channel = discord_bot.get_channel(channel_id) - channel_webhooks = await discord_channel.webhooks() - found = False - for webhook in channel_webhooks: - if webhook.name == "_minecraft" and webhook.user == discord_bot.user: - WEBHOOKS.append(webhook.url) - found = True - log.debug("Found webhook %s in channel %s", webhook.name, discord_channel.name) - if not found: - # Create the hook - await discord_channel.create_webhook(name="_minecraft") - - @discord_bot.event - async def on_message(message): - # We do not want the bot to reply to itself - if message.author == discord_bot.user: - return - this_channel = message.channel.id - global WEBHOOKS - - # PM Commands - if message.content.startswith("mc!help"): - try: - send_channel = message.channel - if isinstance(message.channel, discord.abc.GuildChannel): - await message.delete() - dm_channel = message.author.dm_channel - if not dm_channel: - await message.author.create_dm() - send_channel = message.author.dm_channel - msg = get_discord_help_string() - await send_channel.send(msg) - except discord.errors.Forbidden: - if isinstance(message.author, discord.abc.User): - msg = "{}, please allow private messages from this bot.".format(message.author.mention) - error_msg = await message.channel.send(msg) - await asyncio.sleep(3) - await error_msg.delete() - return - - elif message.content.startswith("mc!register"): - try: - # TODO: Catch the Forbidden error in a smart way before running application logic - send_channel = message.channel - if isinstance(message.channel, discord.abc.GuildChannel): - await message.delete() - dm_channel = message.author.dm_channel - if not dm_channel: - await message.author.create_dm() - send_channel = message.author.dm_channel - session = database_session.get_session() - discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() - if not discord_account: - new_discord_account = DiscordAccount(message.author.id) - session.add(new_discord_account) - session.commit() - discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() + self.connection.write_packet(packet) - new_token = generate_random_auth_token(16) - account_link_token = AccountLinkToken(message.author.id, new_token) - discord_account.link_token = account_link_token - session.add(account_link_token) - session.commit() - msg = "Please connect your minecraft account to `{}.{}:{}` in order to link it to this bridge!"\ - .format(new_token, config.auth_dns, config.auth_port) - session.close() - del session - await send_channel.send(msg) - except discord.errors.Forbidden: - if isinstance(message.author, discord.abc.User): - msg = "{}, please allow private messages from this bot.".format(message.author.mention) - error_msg = await message.channel.send(msg) - await asyncio.sleep(3) - await error_msg.delete() - return - # Global Commands - elif message.content.startswith("mc!chathere"): - if isinstance(message.channel, discord.abc.PrivateChannel): - msg = "Sorry, this command is only available in public channels." - await message.channel.send(msg) - return - if message.author.id not in config.admin_users: - await message.delete() - try: - dm_channel = message.author.dm_channel - if not dm_channel: - await message.author.create_dm() - dm_channel = message.author.dm_channel - msg = "Sorry, you do not have permission to execute that command!" - await dm_channel.send(msg) - except discord.errors.Forbidden: - if isinstance(message.author, discord.abc.User): - msg = "{}, please allow private messages from this bot.".format(message.author.mention) - error_msg = await message.channel.send(msg) - await asyncio.sleep(3) - await error_msg.delete() - return - session = database_session.get_session() - channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all() - if not channels: - new_channel = DiscordChannel(this_channel) - session.add(new_channel) - session.commit() - session.close() - del session - webhook = await message.channel.create_webhook(name="_minecraft") - WEBHOOKS.append(webhook.url) - msg = "The bot will now start chatting here! To stop this, run `mc!stopchathere`." - await message.channel.send(msg) - else: - msg = "The bot is already chatting in this channel! To stop this, run `mc!stopchathere`." - await message.channel.send(msg) - return - - elif message.content.startswith("mc!stopchathere"): - if isinstance(message.channel, discord.abc.PrivateChannel): - msg = "Sorry, this command is only available in public channels." - await message.channel.send(msg) - return - if message.author.id not in config.admin_users: - await message.delete() - try: - dm_channel = message.author.dm_channel - if not dm_channel: - await message.author.create_dm() - dm_channel = message.author.dm_channel - msg = "Sorry, you do not have permission to execute that command!" - await dm_channel.send(msg) - except discord.errors.Forbidden: - if isinstance(message.author, discord.abc.User): - msg = "{}, please allow private messages from this bot.".format(message.author.mention) - error_msg = await message.channel.send(msg) - await asyncio.sleep(3) - await error_msg.delete() - finally: - return - session = database_session.get_session() - deleted = session.query(DiscordChannel).filter_by(channel_id=this_channel).delete() - session.commit() - session.close() - for webhook in await message.channel.webhooks(): - if webhook.name == "_minecraft" and webhook.user == discord_bot.user: - # Copy the list to avoid some problems since - # we're deleting indicies form it as we loop - # through it - if webhook.url in WEBHOOKS[:]: - WEBHOOKS.remove(webhook.url) - await webhook.delete() - if deleted < 1: - msg = "The bot was not chatting here!" - await message.channel.send(msg) - return - else: - msg = "The bot will no longer chat here!" - await message.channel.send(msg) - return - - elif message.content.startswith("mc!tab"): - send_channel = message.channel - try: - if isinstance(message.channel, discord.abc.GuildChannel): - await message.delete() - dm_channel = message.author.dm_channel - if not dm_channel: - await message.author.create_dm() - send_channel = message.author.dm_channel - player_list = ", ".join(list(map(lambda x: x[1], PLAYER_LIST.items()))) - msg = "{}\n" \ - "Players online: {}\n" \ - "{}".format(escape_markdown( - strip_colour(TAB_HEADER)), escape_markdown( - strip_colour(player_list)), escape_markdown( - strip_colour(TAB_FOOTER))) - await send_channel.send(msg) - except discord.errors.Forbidden: - if isinstance(message.author, discord.abc.User): - msg = "{}, please allow private messages from this bot.".format(message.author.mention) - error_msg = await message.channel.send(msg) - await asyncio.sleep(3) - await error_msg.delete() - return - - elif message.content.startswith("mc!"): - # Catch-all - send_channel = message.channel - try: - if isinstance(message.channel, discord.abc.GuildChannel): - await message.delete() - dm_channel = message.author.dm_channel - if not dm_channel: - await message.author.create_dm() - send_channel = message.author.dm_channel - msg = "Unknown command, type `mc!help` for a list of commands." - await send_channel.send(msg) - except discord.errors.Forbidden: - if isinstance(message.author, discord.abc.User): - msg = "{}, please allow private messages from this bot.".format(message.author.mention) - error_msg = await message.channel.send(msg) - await asyncio.sleep(3) - await error_msg.delete() - return - - elif not message.author.bot: - session = database_session.get_session() - channel_should_chat = session.query(DiscordChannel).filter_by(channel_id=this_channel).first() - if channel_should_chat: - await message.delete() - discord_user = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() - if discord_user: - if discord_user.minecraft_account: - minecraft_uuid = discord_user.minecraft_account.minecraft_uuid - session.close() - del session - minecraft_username = mc_uuid_to_username(minecraft_uuid) - - # Max chat message length: 256, bot username does not count towards this - # Does not count|Counts - # minecraft_username: message - padding = 2 + len(minecraft_username) - - message_to_send = remove_emoji( - message.clean_content.encode('utf-8').decode('ascii', 'replace')).strip() - message_to_discord = escape_markdown(message.clean_content) - - total_len = padding + len(message_to_send) - if total_len > 256: - message_to_send = message_to_send[:(256 - padding)] - message_to_discord = message_to_discord[:(256 - padding)] - elif not message_to_send: - return - - session = database_session.get_session() - channels = session.query(DiscordChannel).all() - session.close() - del session - global PREVIOUS_MESSAGE, NEXT_MESSAGE_TIME - if message_to_send == PREVIOUS_MESSAGE or \ - datetime.now(timezone.utc) < NEXT_MESSAGE_TIME: - send_channel = message.channel - try: - if isinstance(message.channel, discord.abc.GuildChannel): - dm_channel = message.author.dm_channel - if not dm_channel: - await message.author.create_dm() - send_channel = message.author.dm_channel - msg = "Your message \"{}\" has been rate-limited.".format(message.clean_content) - await send_channel.send(msg) - except discord.errors.Forbidden: - if isinstance(message.author, discord.abc.User): - msg = "{}, please allow private messages from this bot.".format( - message.author.mention) - error_msg = await message.channel.send(msg) - await asyncio.sleep(3) - await error_msg.delete() - return - - PREVIOUS_MESSAGE = message_to_send - NEXT_MESSAGE_TIME = datetime.now(timezone.utc) + timedelta(seconds=config.message_delay) - - log.info("Outgoing message from discord: Username: %s Message: %s", - minecraft_username, message_to_send) - - for channel in channels: - webhooks = await discord_bot.get_channel(channel.channel_id).webhooks() - for webhook in webhooks: - if webhook.name == "_minecraft": - await webhook.send( - username=minecraft_username, - avatar_url="https://visage.surgeplay.com/face/160/{}".format(minecraft_uuid), - content=message_to_discord) - - packet = serverbound.play.ChatPacket() - packet.message = "{}: {}".format(minecraft_username, message_to_send) - connection.write_packet(packet) - else: - send_channel = message.channel - try: - if isinstance(message.channel, discord.abc.GuildChannel): - dm_channel = message.author.dm_channel - if not dm_channel: - await message.author.create_dm() - send_channel = message.author.dm_channel - msg = "Unable to send chat message: there is no Minecraft account linked to this discord " \ - "account, please run `mc!register`." - await send_channel.send(msg) - except discord.errors.Forbidden: - if isinstance(message.author, discord.abc.User): - msg = "{}, please allow private messages from this bot.".format(message.author.mention) - error_msg = await message.channel.send(msg) - await asyncio.sleep(3) - await error_msg.delete() - return - finally: - session.close() - del session - else: - session.close() - del session - - discord_bot.run(config.discord_token) +def main(): + bridge = MinecraftDiscordBridge() + bridge.run() if __name__ == "__main__": diff --git a/tox.ini b/tox.ini index 0325919..eac4fb7 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,9 @@ [tox] -envlist = flake8 +envlist = flake8, pylint [flake8] max-line-length = 120 +exclude = .tox [testenv:flake8] deps = @@ -10,3 +11,38 @@ deps = commands = pipenv install --dev flake8 + +[testenv:pylint] +deps = + pipenv +commands = + pipenv install --dev + pylint --rcfile=tox.ini minecraft_discord_bridge + +[FORMAT] +max-line-length=120 + +[MESSAGES CONTROL] +; C0111 Missing docstring +; I0011: Locally disabling %s +; I0012: Locally enabling %s +; W0704 Except doesn't do anything Used when an except clause does nothing but "pass" and there is no "else" clause +; W0142 Used * or * magic* Used when a function or method is called using *args or **kwargs to dispatch arguments. +; W0212 Access to a protected member %s of a client class +; W0232 Class has no __init__ method Used when a class has no __init__ method, neither its parent classes. +; W0613 Unused argument %r Used when a function or method argument is not used. +; W0702 No exception's type specified Used when an except clause doesn't specify exceptions type to catch. +; R0201 Method could be a function +; W0614 Unused import XYZ from wildcard import +; R0903 Too few public methods +; R0904 Too many public methods +; R0914 Too many local variables +; R0912 Too many branches +; R0915 Too many statements +; R0913 Too many arguments +; R0923: Interface not implemented +disable=I0011,I0012,C0111,W0142,R + +[TYPECHECK] +; https://stackoverflow.com/questions/17142236/how-do-i-make-pylint-recognize-twisted-and-ephem-members +ignored-classes=twisted.internet.reactor \ No newline at end of file