Merge branch 'feature/refactor-project-structure'

master
Tristan Gosselin-Hane 6 years ago
commit 200bee2c40
No known key found for this signature in database
GPG Key ID: 0B7E55B60DCA15D5
  1. 2
      Dockerfile
  2. 3
      minecraft-discord-bridge/__main__.py
  3. 82
      minecraft-discord-bridge/auth_server.py
  4. 18
      minecraft-discord-bridge/database_session.py
  5. 73
      minecraft-discord-bridge/elasticsearch_logger.py
  6. 714
      minecraft-discord-bridge/minecraft_discord_bridge.py
  7. 1
      minecraft_discord_bridge/__init__.py
  8. 3
      minecraft_discord_bridge/__main__.py
  9. 79
      minecraft_discord_bridge/auth_server.py
  10. 11
      minecraft_discord_bridge/config.py
  11. 0
      minecraft_discord_bridge/database.py
  12. 20
      minecraft_discord_bridge/database_session.py
  13. 61
      minecraft_discord_bridge/elasticsearch_logger.py
  14. 700
      minecraft_discord_bridge/minecraft_discord_bridge.py
  15. 38
      tox.ini

@ -12,4 +12,4 @@ RUN pip install --no-cache-dir pipenv \
VOLUME "/data" VOLUME "/data"
CMD [ "python", "-m", "pipenv", "run", "python", "-m", "minecraft-discord-bridge" ] CMD [ "python", "-m", "pipenv", "run", "python", "-m", "minecraft_discord_bridge" ]

@ -1,3 +0,0 @@
if __name__ == "__main__":
from . import minecraft_discord_bridge
minecraft_discord_bridge.main()

@ -1,82 +0,0 @@
from datetime import datetime
from quarry.net.server import ServerFactory, ServerProtocol
from .database import AccountLinkToken, MinecraftAccount, DiscordAccount
from . import database_session
class AuthProtocol(ServerProtocol):
def player_joined(self):
# This method gets called when a player successfully joins the server.
# If we're in online mode (the default), this means auth with the
# session server was successful and the user definitely owns the
# display name they claim to.
# Call super. This switches us to "play" mode, marks the player as
# in-game, and does some logging.
ServerProtocol.player_joined(self)
# Define your own logic here. It could be an HTTP request to an API,
# or perhaps an update to a database table.
display_name = self.display_name
uuid = self.uuid
# Monkey Patch for Forge deciding to append "\x00FML\x00" to the connection string
if self.connect_host.endswith("\x00FML\x00"):
ip_addr = self.connect_host[:-5]
else:
ip_addr = self.connect_host
connect_port = self.connect_port
self.logger.info("[AUTH SERVER] {} ({}) connected to address {}:{}".format(
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 {} account and replacing it with {}".format(
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)
session.close()
# Kick the player.
self.close("This shouldn't happen!")
class AuthFactory(ServerFactory):
protocol = AuthProtocol
motd = "Auth Server"

@ -1,18 +0,0 @@
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)
def get_session():
Session = sessionmaker(bind=_engine)()
return Session

@ -1,73 +0,0 @@
import time
from enum import Enum
import logging
import requests
_username = None
_password = None
_auth = None
_url = None
log = logging.getLogger("bridge.elasticsearch")
def initialize(config):
global _username, _password, _url, _auth
if config.es_auth:
_auth = True
_username = config.es_username
_password = config.es_password
_url = config.es_url
def log_connection(uuid, reason, count=0):
if ConnectionReason(reason).name != "SEEN":
es_payload = {
"uuid": uuid,
"time": (lambda: int(round(time.time() * 1000)))(),
"reason": ConnectionReason(reason).name,
"count": count,
}
else:
es_payload = {
"uuid": uuid,
"time": (lambda: int(round(time.time() * 1000)))(),
"reason": ConnectionReason(reason).name,
}
post_request("connections/_doc/", es_payload)
def log_chat_message(uuid, display_name, message, message_unformatted):
es_payload = {
"uuid": uuid,
"display_name": display_name,
"message": message,
"message_unformatted": message_unformatted,
"time": (lambda: int(round(time.time() * 1000)))(),
}
post_request("chat_messages/_doc/", es_payload)
def log_raw_message(type, message):
es_payload = {
"time": (lambda: int(round(time.time() * 1000)))(),
"type": type,
"message": message,
}
post_request("raw_messages/_doc/", es_payload)
def post_request(endpoint, payload):
the_url = "{}{}".format(_url, endpoint)
if _auth:
post = requests.post(the_url, auth=(_username, _password), json=payload)
else:
post = requests.post(the_url, json=payload)
log.debug(post.text)
class ConnectionReason(Enum):
CONNECTED = "CONNECTED"
DISCONNECTED = "DISCONNECTED"
SEEN = "SEEN"

@ -1,714 +0,0 @@
#!/usr/bin/env python
from __future__ import print_function
import sys
import re
from enum import Enum
import requests
import json
import time
import logging
import random
import string
import uuid
from threading import Thread
from .config import Configuration
from .database import DiscordChannel, AccountLinkToken, DiscordAccount
from . import database_session
from datetime import datetime, timedelta, timezone
from . import elasticsearch_logger as el
from minecraft import authentication
from minecraft.exceptions import YggdrasilError
from minecraft.networking.connection import Connection
from minecraft.networking.packets import clientbound, serverbound
import discord
import asyncio
from mcstatus import MinecraftServer
from bidict import bidict
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(uuid):
if uuid not in UUID_CACHE:
try:
short_uuid = 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[uuid] = player_username
return player_username
except Exception as e:
log.error(e, exc_info=True)
log.error("Failed to lookup {}'s username using the Mojang API.".format(uuid))
else:
return UUID_CACHE[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 UUID using the Mojang API.".format(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(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'', string)
def escape_markdown(string):
# Absolutely needs to go first or it will replace our escaping slashes!
string = string.replace("\\", "\\\\")
string = string.replace("_", "\\_")
string = string.replace("*", "\\*")
return string
def strip_colour(string):
colour_pattern = re.compile(
u"\U000000A7" # selection symbol
".", flags=re.UNICODE)
return colour_pattern.sub(r'', 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 {}".format(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! {}:".format(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)
# Initialize the discord part
discord_bot = discord.Client()
def register_handlers(connection):
connection.register_packet_listener(
handle_join_game, clientbound.play.JoinGamePacket)
connection.register_packet_listener(
handle_chat, clientbound.play.ChatMessagePacket)
connection.register_packet_listener(
handle_health_update, clientbound.play.UpdateHealthPacket)
connection.register_packet_listener(
handle_disconnect_packet, clientbound.play.DisconnectPacket)
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={}".format(header_footer_packet.header))
log.debug("Got Tablist H/F Update: footer={}".format(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: {}, uuid: {}".format(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:
# 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())
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: {}".format(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: {} Message: {}".format(username, original_message))
log.debug("msg: {}".format(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):
if health_update_packet.health <= 0:
log.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 {} ({})".format(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 {} in channel {}".format(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()
finally:
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()
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()
finally:
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()
finally:
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()
finally:
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()
finally:
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
# <BOT_USERNAME> 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 len(message_to_send) <= 0:
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()
finally:
return
PREVIOUS_MESSAGE = message_to_send
NEXT_MESSAGE_TIME = datetime.now(timezone.utc) + timedelta(seconds=config.message_delay)
log.info("Outgoing message from discord: Username: {} Message: {}".format(
minecraft_username, message_to_send))
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()
finally:
session.close()
del session
return
else:
session.close()
del session
discord_bot.run(config.discord_token)
if __name__ == "__main__":
main()

@ -0,0 +1 @@
__version__ = "0.0.1"

@ -0,0 +1,3 @@
if __name__ == "__main__":
from minecraft_discord_bridge import minecraft_discord_bridge
minecraft_discord_bridge.main()

@ -0,0 +1,79 @@
from datetime import datetime
from quarry.net.server import ServerFactory, ServerProtocol
from .database import AccountLinkToken, MinecraftAccount, DiscordAccount
DATABASE_SESSION = None
class AuthProtocol(ServerProtocol):
def player_joined(self):
# This method gets called when a player successfully joins the server.
# If we're in online mode (the default), this means auth with the
# session server was successful and the user definitely owns the
# display name they claim to.
# Call super. This switches us to "play" mode, marks the player as
# in-game, and does some logging.
ServerProtocol.player_joined(self)
# Define your own logic here. It could be an HTTP request to an API,
# or perhaps an update to a database table.
display_name = self.display_name
uuid = self.uuid
# Monkey Patch for Forge deciding to append "\x00FML\x00" to the connection string
if self.connect_host.endswith("\x00FML\x00"):
ip_addr = self.connect_host[:-5]
else:
ip_addr = self.connect_host
connect_port = self.connect_port
self.logger.info("[AUTH SERVER] %s (%s) connected to address %s:%s",
display_name, uuid, ip_addr, connect_port)
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!")
class AuthFactory(ServerFactory):
protocol = AuthProtocol
motd = "Auth Server"

@ -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)

@ -0,0 +1,20 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
Base = declarative_base()
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(self):
session = sessionmaker(bind=self._engine)()
return session

@ -0,0 +1,61 @@
import time
from enum import Enum
import logging
import requests
class ElasticsearchLogger():
def __init__(self, url: str, username: str = "", password: str = ""):
self.url = url
self.username = username
self.password = password
self.log = logging.getLogger("bridge.elasticsearch")
def log_connection(self, uuid, reason, count=0):
if ConnectionReason(reason).name != "SEEN":
es_payload = {
"uuid": uuid,
"time": (lambda: int(round(time.time() * 1000)))(),
"reason": ConnectionReason(reason).name,
"count": count,
}
else:
es_payload = {
"uuid": uuid,
"time": (lambda: int(round(time.time() * 1000)))(),
"reason": ConnectionReason(reason).name,
}
self.post_request("connections/_doc/", es_payload)
def log_chat_message(self, uuid, display_name, message, message_unformatted):
es_payload = {
"uuid": uuid,
"display_name": display_name,
"message": message,
"message_unformatted": message_unformatted,
"time": (lambda: int(round(time.time() * 1000)))(),
}
self.post_request("chat_messages/_doc/", es_payload)
def log_raw_message(self, msg_type, message):
es_payload = {
"time": (lambda: int(round(time.time() * 1000)))(),
"type": msg_type,
"message": message,
}
self.post_request("raw_messages/_doc/", es_payload)
def post_request(self, endpoint, payload):
the_url = "{}{}".format(self.url, endpoint)
if self.username and self.password:
post = requests.post(the_url, auth=(self.username, self.password), json=payload)
else:
post = requests.post(the_url, json=payload)
self.log.debug(post.text)
class ConnectionReason(Enum):
CONNECTED = "CONNECTED"
DISCONNECTED = "DISCONNECTED"
SEEN = "SEEN"

@ -0,0 +1,700 @@
#!/usr/bin/env python
from __future__ import print_function
import sys
import re
import json
import time
import logging
import random
import string
import uuid
import asyncio
from threading import Thread
from datetime import datetime, timedelta, timezone
import requests
from minecraft import authentication
from minecraft.exceptions import YggdrasilError
from minecraft.networking.connection import Connection
from minecraft.networking.packets import clientbound, serverbound
import discord
from mcstatus import MinecraftServer
from bidict import bidict
from .database_session import DatabaseSession
from .elasticsearch_logger import ElasticsearchLogger, ConnectionReason
from .config import Configuration
from .database import DiscordChannel, AccountLinkToken, DiscordAccount
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
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
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
# <BOT_USERNAME> 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 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__":
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