Completely refactor the codebase and make everything object-oriented and pythonic

Ensure 100% compliance with the pylint and flake8 linters
master
Tristan Gosselin-Hane 6 years ago
parent 3f9a938474
commit c801ced15c
No known key found for this signature in database
GPG Key ID: 0B7E55B60DCA15D5
  1. 11
      minecraft_discord_bridge/auth_server.py
  2. 11
      minecraft_discord_bridge/config.py
  3. 20
      minecraft_discord_bridge/database_session.py
  4. 10
      minecraft_discord_bridge/elasticsearch_logger.py
  5. 807
      minecraft_discord_bridge/minecraft_discord_bridge.py
  6. 38
      tox.ini

@ -3,7 +3,8 @@ from datetime import datetime
from quarry.net.server import ServerFactory, ServerProtocol from quarry.net.server import ServerFactory, ServerProtocol
from .database import AccountLinkToken, MinecraftAccount, DiscordAccount from .database import AccountLinkToken, MinecraftAccount, DiscordAccount
from . import database_session
DATABASE_SESSION = None
class AuthProtocol(ServerProtocol): class AuthProtocol(ServerProtocol):
@ -32,9 +33,9 @@ class AuthProtocol(ServerProtocol):
self.logger.info("[AUTH SERVER] %s (%s) connected to address %s:%s", self.logger.info("[AUTH SERVER] %s (%s) connected to address %s:%s",
display_name, uuid, ip_addr, connect_port) display_name, uuid, ip_addr, connect_port)
try:
connection_token = ip_addr.split(".")[0] connection_token = ip_addr.split(".")[0]
session = database_session.get_session() session = DATABASE_SESSION.get_session()
token = session.query(AccountLinkToken).filter_by(token=connection_token).first() token = session.query(AccountLinkToken).filter_by(token=connection_token).first()
if not token: if not token:
self.close("You have connected with an invalid token!") self.close("You have connected with an invalid token!")
@ -69,10 +70,6 @@ class AuthProtocol(ServerProtocol):
"Please run the mc!register command again to get a new token.") "Please run the mc!register command again to get a new token.")
return return
except Exception as e:
self.logger.error(e)
session.close()
# Kick the player. # Kick the player.
self.close("This shouldn't happen!") self.close("This shouldn't happen!")

@ -1,14 +1,13 @@
import json import json
import logging import logging
log = logging.getLogger("bridge.config")
class Configuration(object): class Configuration(object):
def __init__(self, path): def __init__(self, path):
self.logger = logging.getLogger("bridge.config")
try: try:
with open(path, 'r') as f: with open(path, 'r') as file:
self._config = json.load(f) self._config = json.load(file)
if self._config: if self._config:
self.mc_username = self._config["MAIN"]["MC_USERNAME"] self.mc_username = self._config["MAIN"]["MC_USERNAME"]
self.mc_password = self._config["MAIN"]["MC_PASSWORD"] self.mc_password = self._config["MAIN"]["MC_PASSWORD"]
@ -29,8 +28,8 @@ class Configuration(object):
self.es_username = self._config["ELASTICSEARCH"]["USERNAME"] self.es_username = self._config["ELASTICSEARCH"]["USERNAME"]
self.es_password = self._config["ELASTICSEARCH"]["PASSWORD"] self.es_password = self._config["ELASTICSEARCH"]["PASSWORD"]
else: else:
logging.error("error reading config") self.logger.error("error reading config")
exit(1) exit(1)
except IOError: except IOError:
logging.error("error reading config") self.logger.error("error reading config")
exit(1) exit(1)

@ -2,17 +2,19 @@ from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
_engine = None
Base = declarative_base() Base = declarative_base()
def initialize(config): class DatabaseSession():
global _engine def __init__(self):
_connection_string = config.database_connection_string self._engine = None
_engine = create_engine(_connection_string) self.connection_string = None
Base.metadata.create_all(_engine)
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(): def get_session(self):
Session = sessionmaker(bind=_engine)() session = sessionmaker(bind=self._engine)()
return Session return session

@ -38,20 +38,20 @@ class ElasticsearchLogger():
} }
self.post_request("chat_messages/_doc/", es_payload) 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 = { es_payload = {
"time": (lambda: int(round(time.time() * 1000)))(), "time": (lambda: int(round(time.time() * 1000)))(),
"type": type, "type": msg_type,
"message": message, "message": message,
} }
self.post_request("raw_messages/_doc/", es_payload) self.post_request("raw_messages/_doc/", es_payload)
def post_request(self, endpoint, payload): def post_request(self, endpoint, payload):
theURL = "{}{}".format(self.url, endpoint) the_url = "{}{}".format(self.url, endpoint)
if self.username and self.password: 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: else:
post = requests.post(theURL, json=payload) post = requests.post(the_url, json=payload)
self.log.debug(post.text) self.log.debug(post.text)

@ -4,7 +4,6 @@ from __future__ import print_function
import sys import sys
import re import re
from enum import Enum
import json import json
import time import time
import logging import logging
@ -24,399 +23,71 @@ import discord
from mcstatus import MinecraftServer from mcstatus import MinecraftServer
from bidict import bidict from bidict import bidict
from . import database_session from .database_session import DatabaseSession
from . import elasticsearch_logger as el from .elasticsearch_logger import ElasticsearchLogger, ConnectionReason
from .config import Configuration from .config import Configuration
from .database import DiscordChannel, AccountLinkToken, DiscordAccount 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 = ""
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.
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)
time.sleep(15)
while not is_server_online():
log.info('Not reconnecting to server because it appears to be offline.')
time.sleep(15)
log.info('Reconnecting.')
connection.connect()
def handle_disconnect_packet(join_game_packet):
handle_disconnect()
def minecraft_handle_exception(exception, exc_info):
log.error("A minecraft exception occured! %s:", exception, exc_info=exc_info)
handle_disconnect()
def is_server_online():
server = MinecraftServer.lookup("{}:{}".format(config.mc_server, config.mc_port))
try:
status = server.status()
del status
return True
except ConnectionRefusedError:
return False
# AttributeError: 'TCPSocketConnection' object has no attribute 'socket'
# This might not be required as it happens upstream
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)
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 # Initialize the discord part
discord_bot = discord.Client() self.discord_bot = discord.Client()
self.config = Configuration("config.json")
def register_handlers(connection): self.auth_token = None
connection.register_packet_listener( self.connection = None
handle_join_game, clientbound.play.JoinGamePacket) self.setup_logging(self.config.logging_level)
self.database_session = DatabaseSession()
connection.register_packet_listener( self.logger = logging.getLogger("bridge")
handle_chat, clientbound.play.ChatMessagePacket) self.database_session.initialize(self.config)
# We need to import twisted after setting up the logger because twisted hijacks our logging
connection.register_packet_listener( from . import auth_server
handle_health_update, clientbound.play.UpdateHealthPacket) auth_server.DATABASE_SESSION = self.database_session
if self.config.es_enabled:
connection.register_packet_listener( if self.config.es_auth:
handle_disconnect_packet, clientbound.play.DisconnectPacket) self.es_logger = ElasticsearchLogger(
self.config.es_url, self.config.es_username, self.config.es_password)
connection.register_packet_listener(
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")
for action in tab_list_packet.actions:
if isinstance(action, clientbound.play.PlayerListItemPacket.AddPlayerAction):
log.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
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
# Initial tablist backfill
if 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:
requests.post(webhook, json=webhook_payload)
if config.es_enabled:
el.log_connection(
uuid=action.uuid, reason=el.ConnectionReason.CONNECTED, count=len(PLAYER_LIST))
return
else: else:
# The bot's name is sent last after the initial back-fill self.es_logger = ElasticsearchLogger(self.config.es_url)
if action.name == BOT_USERNAME:
ACCEPT_JOIN_EVENTS = True
if config.es_enabled:
diff = set(PREVIOUS_PLAYER_LIST.keys()) - set(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))
# 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 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: %s", action.uuid)
username = mc_uuid_to_username(action.uuid)
player_uuid = action.uuid
webhook_payload = {
'username': username,
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid),
'content': '',
'embeds': [{'color': 16711680, 'title': '**Left the game**'}]
}
for webhook in 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))
def handle_join_game(join_game_packet):
global PLAYER_LIST
log.info('Connected.')
PLAYER_LIST = bidict()
def handle_chat(chat_packet):
json_data = json.loads(chat_packet.json_data)
if "extra" not in json_data:
return
chat_string = ""
for chat_component in json_data["extra"]:
chat_string += chat_component["text"]
# Handle chat message
regexp_match = re.match("<(.*?)> (.*)", chat_string, re.M | re.I)
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():
# Don't relay our own messages
if config.es_enabled:
bot_message_match = re.match("<{}> (.*?): (.*)".format(
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)),
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)
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}")))
webhook_payload = {
'username': username,
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid),
'content': '{}'.format(message)
}
for webhook in WEBHOOKS:
requests.post(webhook, json=webhook_payload)
if config.es_enabled:
el.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)
def handle_health_update(health_update_packet): @self.discord_bot.event
if health_update_packet.health <= 0: async def on_ready(): # pylint: disable=W0612
log.debug("Respawned the player because it died") self.logger.info("Discord bot logged in as %s (%s)", self.discord_bot.user.name, self.discord_bot.user.id)
packet = serverbound.play.ClientStatusPacket() self.webhooks = []
packet.action_id = serverbound.play.ClientStatusPacket.RESPAWN session = self.database_session.get_session()
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() channels = session.query(DiscordChannel).all()
session.close() session.close()
for channel in channels: for channel in channels:
channel_id = channel.channel_id channel_id = channel.channel_id
discord_channel = discord_bot.get_channel(channel_id) discord_channel = self.discord_bot.get_channel(channel_id)
channel_webhooks = await discord_channel.webhooks() channel_webhooks = await discord_channel.webhooks()
found = False found = False
for webhook in channel_webhooks: for webhook in channel_webhooks:
if webhook.name == "_minecraft" and webhook.user == discord_bot.user: if webhook.name == "_minecraft" and webhook.user == self.discord_bot.user:
WEBHOOKS.append(webhook.url) self.webhooks.append(webhook.url)
found = True found = True
log.debug("Found webhook %s in channel %s", webhook.name, discord_channel.name) self.logger.debug("Found webhook %s in channel %s", webhook.name, discord_channel.name)
if not found: if not found:
# Create the hook # Create the hook
await discord_channel.create_webhook(name="_minecraft") await discord_channel.create_webhook(name="_minecraft")
@discord_bot.event @self.discord_bot.event
async def on_message(message): async def on_message(message): # pylint: disable=W0612
# We do not want the bot to reply to itself # We do not want the bot to reply to itself
if message.author == discord_bot.user: if message.author == self.discord_bot.user:
return return
this_channel = message.channel.id this_channel = message.channel.id
global WEBHOOKS
# PM Commands # PM Commands
if message.content.startswith("mc!help"): if message.content.startswith("mc!help"):
@ -428,7 +99,7 @@ def main():
if not dm_channel: if not dm_channel:
await message.author.create_dm() await message.author.create_dm()
send_channel = message.author.dm_channel send_channel = message.author.dm_channel
msg = get_discord_help_string() msg = self.get_discord_help_string()
await send_channel.send(msg) await send_channel.send(msg)
except discord.errors.Forbidden: except discord.errors.Forbidden:
if isinstance(message.author, discord.abc.User): if isinstance(message.author, discord.abc.User):
@ -440,7 +111,6 @@ def main():
elif message.content.startswith("mc!register"): elif message.content.startswith("mc!register"):
try: try:
# TODO: Catch the Forbidden error in a smart way before running application logic
send_channel = message.channel send_channel = message.channel
if isinstance(message.channel, discord.abc.GuildChannel): if isinstance(message.channel, discord.abc.GuildChannel):
await message.delete() await message.delete()
@ -448,7 +118,7 @@ def main():
if not dm_channel: if not dm_channel:
await message.author.create_dm() await message.author.create_dm()
send_channel = message.author.dm_channel send_channel = message.author.dm_channel
session = database_session.get_session() session = self.database_session.get_session()
discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first()
if not discord_account: if not discord_account:
new_discord_account = DiscordAccount(message.author.id) new_discord_account = DiscordAccount(message.author.id)
@ -456,13 +126,13 @@ def main():
session.commit() session.commit()
discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first()
new_token = generate_random_auth_token(16) new_token = self.generate_random_auth_token(16)
account_link_token = AccountLinkToken(message.author.id, new_token) account_link_token = AccountLinkToken(message.author.id, new_token)
discord_account.link_token = account_link_token discord_account.link_token = account_link_token
session.add(account_link_token) session.add(account_link_token)
session.commit() session.commit()
msg = "Please connect your minecraft account to `{}.{}:{}` in order to link it to this bridge!"\ msg = "Please connect your minecraft account to `{}.{}:{}` in order to link it to this bridge!"\
.format(new_token, config.auth_dns, config.auth_port) .format(new_token, self.config.auth_dns, self.config.auth_port)
session.close() session.close()
del session del session
await send_channel.send(msg) await send_channel.send(msg)
@ -480,7 +150,7 @@ def main():
msg = "Sorry, this command is only available in public channels." msg = "Sorry, this command is only available in public channels."
await message.channel.send(msg) await message.channel.send(msg)
return return
if message.author.id not in config.admin_users: if message.author.id not in self.config.admin_users:
await message.delete() await message.delete()
try: try:
dm_channel = message.author.dm_channel dm_channel = message.author.dm_channel
@ -496,7 +166,7 @@ def main():
await asyncio.sleep(3) await asyncio.sleep(3)
await error_msg.delete() await error_msg.delete()
return return
session = database_session.get_session() session = self.database_session.get_session()
channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all() channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all()
if not channels: if not channels:
new_channel = DiscordChannel(this_channel) new_channel = DiscordChannel(this_channel)
@ -505,7 +175,7 @@ def main():
session.close() session.close()
del session del session
webhook = await message.channel.create_webhook(name="_minecraft") webhook = await message.channel.create_webhook(name="_minecraft")
WEBHOOKS.append(webhook.url) self.webhooks.append(webhook.url)
msg = "The bot will now start chatting here! To stop this, run `mc!stopchathere`." msg = "The bot will now start chatting here! To stop this, run `mc!stopchathere`."
await message.channel.send(msg) await message.channel.send(msg)
else: else:
@ -518,7 +188,7 @@ def main():
msg = "Sorry, this command is only available in public channels." msg = "Sorry, this command is only available in public channels."
await message.channel.send(msg) await message.channel.send(msg)
return return
if message.author.id not in config.admin_users: if message.author.id not in self.config.admin_users:
await message.delete() await message.delete()
try: try:
dm_channel = message.author.dm_channel dm_channel = message.author.dm_channel
@ -533,19 +203,18 @@ def main():
error_msg = await message.channel.send(msg) error_msg = await message.channel.send(msg)
await asyncio.sleep(3) await asyncio.sleep(3)
await error_msg.delete() await error_msg.delete()
finally:
return return
session = database_session.get_session() session = self.database_session.get_session()
deleted = session.query(DiscordChannel).filter_by(channel_id=this_channel).delete() deleted = session.query(DiscordChannel).filter_by(channel_id=this_channel).delete()
session.commit() session.commit()
session.close() session.close()
for webhook in await message.channel.webhooks(): for webhook in await message.channel.webhooks():
if webhook.name == "_minecraft" and webhook.user == discord_bot.user: if webhook.name == "_minecraft" and webhook.user == self.discord_bot.user:
# Copy the list to avoid some problems since # Copy the list to avoid some problems since
# we're deleting indicies form it as we loop # we're deleting indicies form it as we loop
# through it # through it
if webhook.url in WEBHOOKS[:]: if webhook.url in self.webhooks[:]:
WEBHOOKS.remove(webhook.url) self.webhooks.remove(webhook.url)
await webhook.delete() await webhook.delete()
if deleted < 1: if deleted < 1:
msg = "The bot was not chatting here!" msg = "The bot was not chatting here!"
@ -565,13 +234,13 @@ def main():
if not dm_channel: if not dm_channel:
await message.author.create_dm() await message.author.create_dm()
send_channel = message.author.dm_channel send_channel = message.author.dm_channel
player_list = ", ".join(list(map(lambda x: x[1], PLAYER_LIST.items()))) player_list = ", ".join(list(map(lambda x: x[1], self.player_list.items())))
msg = "{}\n" \ msg = "{}\n" \
"Players online: {}\n" \ "Players online: {}\n" \
"{}".format(escape_markdown( "{}".format(self.escape_markdown(
strip_colour(TAB_HEADER)), escape_markdown( self.strip_colour(self.tab_header)), self.escape_markdown(
strip_colour(player_list)), escape_markdown( self.strip_colour(player_list)), self.escape_markdown(
strip_colour(TAB_FOOTER))) self.strip_colour(self.tab_footer)))
await send_channel.send(msg) await send_channel.send(msg)
except discord.errors.Forbidden: except discord.errors.Forbidden:
if isinstance(message.author, discord.abc.User): if isinstance(message.author, discord.abc.User):
@ -602,7 +271,7 @@ def main():
return return
elif not message.author.bot: elif not message.author.bot:
session = database_session.get_session() session = self.database_session.get_session()
channel_should_chat = session.query(DiscordChannel).filter_by(channel_id=this_channel).first() channel_should_chat = session.query(DiscordChannel).filter_by(channel_id=this_channel).first()
if channel_should_chat: if channel_should_chat:
await message.delete() await message.delete()
@ -612,16 +281,16 @@ def main():
minecraft_uuid = discord_user.minecraft_account.minecraft_uuid minecraft_uuid = discord_user.minecraft_account.minecraft_uuid
session.close() session.close()
del session del session
minecraft_username = mc_uuid_to_username(minecraft_uuid) minecraft_username = self.mc_uuid_to_username(minecraft_uuid)
# Max chat message length: 256, bot username does not count towards this # Max chat message length: 256, bot username does not count towards this
# Does not count|Counts # Does not count|Counts
# <BOT_USERNAME> minecraft_username: message # <BOT_USERNAME> minecraft_username: message
padding = 2 + len(minecraft_username) padding = 2 + len(minecraft_username)
message_to_send = remove_emoji( message_to_send = self.remove_emoji(
message.clean_content.encode('utf-8').decode('ascii', 'replace')).strip() message.clean_content.encode('utf-8').decode('ascii', 'replace')).strip()
message_to_discord = escape_markdown(message.clean_content) message_to_discord = self.escape_markdown(message.clean_content)
total_len = padding + len(message_to_send) total_len = padding + len(message_to_send)
if total_len > 256: if total_len > 256:
@ -630,13 +299,12 @@ def main():
elif not message_to_send: elif not message_to_send:
return return
session = database_session.get_session() session = self.database_session.get_session()
channels = session.query(DiscordChannel).all() channels = session.query(DiscordChannel).all()
session.close() session.close()
del session del session
global PREVIOUS_MESSAGE, NEXT_MESSAGE_TIME if message_to_send == self.previous_message or \
if message_to_send == PREVIOUS_MESSAGE or \ datetime.now(timezone.utc) < self.next_message_time:
datetime.now(timezone.utc) < NEXT_MESSAGE_TIME:
send_channel = message.channel send_channel = message.channel
try: try:
if isinstance(message.channel, discord.abc.GuildChannel): if isinstance(message.channel, discord.abc.GuildChannel):
@ -655,24 +323,26 @@ def main():
await error_msg.delete() await error_msg.delete()
return return
PREVIOUS_MESSAGE = message_to_send self.previous_message = message_to_send
NEXT_MESSAGE_TIME = datetime.now(timezone.utc) + timedelta(seconds=config.message_delay) self.next_message_time = datetime.now(timezone.utc) + timedelta(
seconds=self.config.message_delay)
log.info("Outgoing message from discord: Username: %s Message: %s", self.logger.info("Outgoing message from discord: Username: %s Message: %s",
minecraft_username, message_to_send) minecraft_username, message_to_send)
for channel in channels: for channel in channels:
webhooks = await discord_bot.get_channel(channel.channel_id).webhooks() webhooks = await self.discord_bot.get_channel(channel.channel_id).webhooks()
for webhook in webhooks: for webhook in webhooks:
if webhook.name == "_minecraft": if webhook.name == "_minecraft":
await webhook.send( await webhook.send(
username=minecraft_username, username=minecraft_username,
avatar_url="https://visage.surgeplay.com/face/160/{}".format(minecraft_uuid), avatar_url="https://visage.surgeplay.com/face/160/{}".format(
minecraft_uuid),
content=message_to_discord) content=message_to_discord)
packet = serverbound.play.ChatPacket() packet = serverbound.play.ChatPacket()
packet.message = "{}: {}".format(minecraft_username, message_to_send) packet.message = "{}: {}".format(minecraft_username, message_to_send)
connection.write_packet(packet) self.connection.write_packet(packet)
else: else:
send_channel = message.channel send_channel = message.channel
try: try:
@ -698,7 +368,332 @@ def main():
session.close() session.close()
del session del session
discord_bot.run(config.discord_token) 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 self.is_server_online():
self.logger.info('Not reconnecting to server because it appears to be offline.')
time.sleep(15)
self.logger.info('Reconnecting.')
self.connection.connect()
def handle_disconnect_packet(self, disconnect_packet):
self.handle_disconnect(disconnect_packet.json_data)
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(self):
server = MinecraftServer.lookup("{}:{}".format(self.config.mc_server, self.config.mc_port))
try:
status = server.status()
del status
return True
except ConnectionRefusedError:
return False
# AttributeError: 'TCPSocketConnection' object has no attribute 'socket'
# This might not be required as it happens upstream
except AttributeError:
return False
def register_handlers(self, connection):
connection.register_packet_listener(
self.handle_join_game, clientbound.play.JoinGamePacket)
connection.register_packet_listener(
self.handle_chat, clientbound.play.ChatMessagePacket)
connection.register_packet_listener(
self.handle_health_update, clientbound.play.UpdateHealthPacket)
connection.register_packet_listener(
self.handle_disconnect_packet, clientbound.play.DisconnectPacket)
connection.register_packet_listener(
self.handle_tab_list, clientbound.play.PlayerListItemPacket)
connection.register_packet_listener(
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):
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 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 self.uuid_cache.inv:
self.uuid_cache.inv[action.name] = action.uuid
# Initial tablist backfill
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 self.webhooks:
requests.post(webhook, json=webhook_payload)
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 == 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):
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 self.config.es_enabled:
self.es_logger.log_connection(
uuid=action.uuid, reason=ConnectionReason.CONNECTED, count=len(self.player_list))
if self.config.es_enabled:
self.es_logger.log_connection(uuid=action.uuid, reason=ConnectionReason.SEEN)
if isinstance(action, clientbound.play.PlayerListItemPacket.RemovePlayerAction):
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,
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid),
'content': '',
'embeds': [{'color': 16711680, 'title': '**Left the game**'}]
}
for webhook in self.webhooks:
requests.post(webhook, json=webhook_payload)
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(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(self, chat_packet):
json_data = json.loads(chat_packet.json_data)
if "extra" not in json_data:
return
chat_string = ""
for chat_component in json_data["extra"]:
chat_string += chat_component["text"]
# Handle chat message
regexp_match = re.match("<(.*?)> (.*)", chat_string, re.M | re.I)
if regexp_match:
username = regexp_match.group(1)
original_message = regexp_match.group(2)
player_uuid = self.mc_username_to_uuid(username)
if username.lower() == self.bot_username.lower():
# Don't relay our own messages
if self.config.es_enabled:
bot_message_match = re.match("<{}> (.*?): (.*)".format(
self.bot_username.lower()), chat_string, re.M | re.I)
if bot_message_match:
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)
self.es_logger.log_raw_message(
msg_type=chat_packet.Position.name_from_value(chat_packet.position),
message=chat_packet.json_data)
return
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 self.webhooks:
requests.post(webhook, json=webhook_payload)
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 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(self, health_update_packet):
if health_update_packet.health <= 0:
self.logger.debug("Respawned the player because it died")
packet = serverbound.play.ClientStatusPacket()
packet.action_id = serverbound.play.ClientStatusPacket.RESPAWN
self.connection.write_packet(packet)
def main():
bridge = MinecraftDiscordBridge()
bridge.run()
if __name__ == "__main__": if __name__ == "__main__":

@ -1,8 +1,9 @@
[tox] [tox]
envlist = flake8 envlist = flake8, pylint
[flake8] [flake8]
max-line-length = 120 max-line-length = 120
exclude = .tox
[testenv:flake8] [testenv:flake8]
deps = deps =
@ -10,3 +11,38 @@ deps =
commands = commands =
pipenv install --dev pipenv install --dev
flake8 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
Loading…
Cancel
Save