commit
200bee2c40
@ -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" |
@ -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() |
Loading…
Reference in new issue