From c801ced15cc38e770292cb8f7a0368b050ce238a Mon Sep 17 00:00:00 2001 From: Tristan Gosselin-Hane Date: Sun, 22 Sep 2019 20:49:14 -0400 Subject: [PATCH] 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