|
|
@ -4,7 +4,6 @@ from __future__ import print_function |
|
|
|
|
|
|
|
|
|
|
|
import sys |
|
|
|
import sys |
|
|
|
import re |
|
|
|
import re |
|
|
|
from enum import Enum |
|
|
|
|
|
|
|
import json |
|
|
|
import json |
|
|
|
import time |
|
|
|
import time |
|
|
|
import logging |
|
|
|
import logging |
|
|
@ -24,399 +23,71 @@ import discord |
|
|
|
from mcstatus import MinecraftServer |
|
|
|
from mcstatus import MinecraftServer |
|
|
|
from bidict import bidict |
|
|
|
from bidict import bidict |
|
|
|
|
|
|
|
|
|
|
|
from . import database_session |
|
|
|
from .database_session import DatabaseSession |
|
|
|
from . import elasticsearch_logger as el |
|
|
|
from .elasticsearch_logger import ElasticsearchLogger, ConnectionReason |
|
|
|
from .config import Configuration |
|
|
|
from .config import Configuration |
|
|
|
from .database import DiscordChannel, AccountLinkToken, DiscordAccount |
|
|
|
from .database import DiscordChannel, AccountLinkToken, DiscordAccount |
|
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger("bridge") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SESSION_TOKEN = "" |
|
|
|
|
|
|
|
UUID_CACHE = bidict() |
|
|
|
|
|
|
|
WEBHOOKS = [] |
|
|
|
|
|
|
|
BOT_USERNAME = "" |
|
|
|
|
|
|
|
NEXT_MESSAGE_TIME = datetime.now(timezone.utc) |
|
|
|
|
|
|
|
PREVIOUS_MESSAGE = "" |
|
|
|
|
|
|
|
PLAYER_LIST = bidict() |
|
|
|
|
|
|
|
PREVIOUS_PLAYER_LIST = bidict() |
|
|
|
|
|
|
|
ACCEPT_JOIN_EVENTS = False |
|
|
|
|
|
|
|
TAB_HEADER = "" |
|
|
|
|
|
|
|
TAB_FOOTER = "" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mc_uuid_to_username(mc_uuid): |
|
|
|
|
|
|
|
if mc_uuid not in UUID_CACHE: |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
short_uuid = mc_uuid.replace("-", "") |
|
|
|
|
|
|
|
mojang_response = requests.get("https://api.mojang.com/user/profiles/{}/names".format(short_uuid)).json() |
|
|
|
|
|
|
|
if len(mojang_response) > 1: |
|
|
|
|
|
|
|
# Multiple name changes |
|
|
|
|
|
|
|
player_username = mojang_response[-1]["name"] |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
# Only one name |
|
|
|
|
|
|
|
player_username = mojang_response[0]["name"] |
|
|
|
|
|
|
|
UUID_CACHE[mc_uuid] = player_username |
|
|
|
|
|
|
|
return player_username |
|
|
|
|
|
|
|
except requests.RequestException as e: |
|
|
|
|
|
|
|
log.error(e, exc_info=True) |
|
|
|
|
|
|
|
log.error("Failed to lookup %s's username using the Mojang API.", mc_uuid) |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
return UUID_CACHE[mc_uuid] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mc_username_to_uuid(username): |
|
|
|
|
|
|
|
if username not in UUID_CACHE.inv: |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
player_uuid = requests.get( |
|
|
|
|
|
|
|
"https://api.mojang.com/users/profiles/minecraft/{}".format(username)).json()["id"] |
|
|
|
|
|
|
|
long_uuid = uuid.UUID(player_uuid) |
|
|
|
|
|
|
|
UUID_CACHE.inv[username] = str(long_uuid) |
|
|
|
|
|
|
|
return player_uuid |
|
|
|
|
|
|
|
except requests.RequestException: |
|
|
|
|
|
|
|
log.error("Failed to lookup %s's username using the Mojang API.", username) |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
return UUID_CACHE.inv[username] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_discord_help_string(): |
|
|
|
|
|
|
|
help_str = ("Admin commands:\n" |
|
|
|
|
|
|
|
"`mc!chathere`: Starts outputting server messages in this channel\n" |
|
|
|
|
|
|
|
"`mc!stopchathere`: Stops outputting server messages in this channel\n" |
|
|
|
|
|
|
|
"User commands:\n" |
|
|
|
|
|
|
|
"`mc!tab`: Sends you the content of the server's player/tab list\n" |
|
|
|
|
|
|
|
"`mc!register`: Starts the minecraft account registration process\n" |
|
|
|
|
|
|
|
"To start chatting on the minecraft server, please register your account using `mc!register`.") |
|
|
|
|
|
|
|
return help_str |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# https://stackoverflow.com/questions/33404752/removing-emojis-from-a-string-in-python |
|
|
|
|
|
|
|
def remove_emoji(dirty_string): |
|
|
|
|
|
|
|
emoji_pattern = re.compile( |
|
|
|
|
|
|
|
"[" |
|
|
|
|
|
|
|
u"\U0001F600-\U0001F64F" # emoticons |
|
|
|
|
|
|
|
u"\U0001F300-\U0001F5FF" # symbols & pictographs |
|
|
|
|
|
|
|
u"\U0001F680-\U0001F6FF" # transport & map symbols |
|
|
|
|
|
|
|
u"\U0001F1E0-\U0001F1FF" # flags (iOS) |
|
|
|
|
|
|
|
u"\U0001F900-\U0001FAFF" # CJK Compatibility Ideographs |
|
|
|
|
|
|
|
# u"\U00002702-\U000027B0" |
|
|
|
|
|
|
|
# u"\U000024C2-\U0001F251" |
|
|
|
|
|
|
|
"]+", flags=re.UNICODE) |
|
|
|
|
|
|
|
return emoji_pattern.sub(r'', dirty_string) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def escape_markdown(md_string): |
|
|
|
|
|
|
|
# Absolutely needs to go first or it will replace our escaping slashes! |
|
|
|
|
|
|
|
escaped_string = md_string.replace("\\", "\\\\") |
|
|
|
|
|
|
|
escaped_string = escaped_string.replace("_", "\\_") |
|
|
|
|
|
|
|
escaped_string = escaped_string.replace("*", "\\*") |
|
|
|
|
|
|
|
return escaped_string |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def strip_colour(dirty_string): |
|
|
|
|
|
|
|
colour_pattern = re.compile( |
|
|
|
|
|
|
|
u"\U000000A7" # selection symbol |
|
|
|
|
|
|
|
".", flags=re.UNICODE) |
|
|
|
|
|
|
|
return colour_pattern.sub(r'', dirty_string) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def setup_logging(level): |
|
|
|
|
|
|
|
if level.lower() == "debug": |
|
|
|
|
|
|
|
log_level = logging.DEBUG |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
log_level = logging.INFO |
|
|
|
|
|
|
|
log_format = "%(asctime)s:%(name)s:%(levelname)s:%(message)s" |
|
|
|
|
|
|
|
logging.basicConfig(filename="bridge_log.log", format=log_format, level=log_level) |
|
|
|
|
|
|
|
stdout_logger = logging.StreamHandler(sys.stdout) |
|
|
|
|
|
|
|
stdout_logger.setFormatter(logging.Formatter(log_format)) |
|
|
|
|
|
|
|
logging.getLogger().addHandler(stdout_logger) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_auth_server(port): |
|
|
|
|
|
|
|
# We need to import twisted after setting up the logger because twisted hijacks our logging |
|
|
|
|
|
|
|
# TODO: Fix this in a cleaner way |
|
|
|
|
|
|
|
from twisted.internet import reactor |
|
|
|
|
|
|
|
from .auth_server import AuthFactory |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Create factory |
|
|
|
|
|
|
|
factory = AuthFactory() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Listen |
|
|
|
|
|
|
|
log.info("Starting authentication server on port %d", port) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
factory.listen("", port) |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
reactor.run(installSignalHandlers=False) |
|
|
|
|
|
|
|
except KeyboardInterrupt: |
|
|
|
|
|
|
|
reactor.stop() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_random_auth_token(length): |
|
|
|
|
|
|
|
letters = string.ascii_lowercase + string.digits + string.ascii_uppercase |
|
|
|
|
|
|
|
return ''.join(random.choice(letters) for i in range(length)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# TODO: Get rid of this when pycraft's enum becomes usable |
|
|
|
|
|
|
|
class ChatType(Enum): |
|
|
|
|
|
|
|
CHAT = 0 # A player-initiated chat message. |
|
|
|
|
|
|
|
SYSTEM = 1 # The result of running a command. |
|
|
|
|
|
|
|
GAME_INFO = 2 # Displayed above the hotbar in vanilla clients. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(): |
|
|
|
|
|
|
|
global BOT_USERNAME |
|
|
|
|
|
|
|
config = Configuration("config.json") |
|
|
|
|
|
|
|
setup_logging(config.logging_level) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
database_session.initialize(config) |
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
el.initialize(config) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
reactor_thread = Thread(target=run_auth_server, args=(config.auth_port,)) |
|
|
|
|
|
|
|
reactor_thread.start() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_disconnect(): |
|
|
|
|
|
|
|
log.info('Disconnected.') |
|
|
|
|
|
|
|
global PLAYER_LIST, PREVIOUS_PLAYER_LIST, ACCEPT_JOIN_EVENTS |
|
|
|
|
|
|
|
PREVIOUS_PLAYER_LIST = PLAYER_LIST.copy() |
|
|
|
|
|
|
|
ACCEPT_JOIN_EVENTS = False |
|
|
|
|
|
|
|
PLAYER_LIST = bidict() |
|
|
|
|
|
|
|
if connection.connected: |
|
|
|
|
|
|
|
log.info("Forced a disconnection because the connection is still connected.") |
|
|
|
|
|
|
|
connection.disconnect(immediate=True) |
|
|
|
|
|
|
|
time.sleep(15) |
|
|
|
|
|
|
|
while not is_server_online(): |
|
|
|
|
|
|
|
log.info('Not reconnecting to server because it appears to be offline.') |
|
|
|
|
|
|
|
time.sleep(15) |
|
|
|
|
|
|
|
log.info('Reconnecting.') |
|
|
|
|
|
|
|
connection.connect() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_disconnect_packet(join_game_packet): |
|
|
|
|
|
|
|
handle_disconnect() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def minecraft_handle_exception(exception, exc_info): |
|
|
|
|
|
|
|
log.error("A minecraft exception occured! %s:", exception, exc_info=exc_info) |
|
|
|
|
|
|
|
handle_disconnect() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_server_online(): |
|
|
|
|
|
|
|
server = MinecraftServer.lookup("{}:{}".format(config.mc_server, config.mc_port)) |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
status = server.status() |
|
|
|
|
|
|
|
del status |
|
|
|
|
|
|
|
return True |
|
|
|
|
|
|
|
except ConnectionRefusedError: |
|
|
|
|
|
|
|
return False |
|
|
|
|
|
|
|
# AttributeError: 'TCPSocketConnection' object has no attribute 'socket' |
|
|
|
|
|
|
|
# This might not be required as it happens upstream |
|
|
|
|
|
|
|
except AttributeError: |
|
|
|
|
|
|
|
return False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
log.debug("Checking if the server {} is online before connecting.") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not config.mc_online: |
|
|
|
|
|
|
|
log.info("Connecting in offline mode...") |
|
|
|
|
|
|
|
while not is_server_online(): |
|
|
|
|
|
|
|
log.info('Not connecting to server because it appears to be offline.') |
|
|
|
|
|
|
|
time.sleep(15) |
|
|
|
|
|
|
|
BOT_USERNAME = config.mc_username |
|
|
|
|
|
|
|
connection = Connection( |
|
|
|
|
|
|
|
config.mc_server, config.mc_port, username=config.mc_username, |
|
|
|
|
|
|
|
handle_exception=minecraft_handle_exception) |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
auth_token = authentication.AuthenticationToken() |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
auth_token.authenticate(config.mc_username, config.mc_password) |
|
|
|
|
|
|
|
except YggdrasilError as e: |
|
|
|
|
|
|
|
log.info(e) |
|
|
|
|
|
|
|
sys.exit() |
|
|
|
|
|
|
|
BOT_USERNAME = auth_token.profile.name |
|
|
|
|
|
|
|
log.info("Logged in as %s...", auth_token.profile.name) |
|
|
|
|
|
|
|
while not is_server_online(): |
|
|
|
|
|
|
|
log.info('Not connecting to server because it appears to be offline.') |
|
|
|
|
|
|
|
time.sleep(15) |
|
|
|
|
|
|
|
connection = Connection( |
|
|
|
|
|
|
|
config.mc_server, config.mc_port, auth_token=auth_token, |
|
|
|
|
|
|
|
handle_exception=minecraft_handle_exception) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MinecraftDiscordBridge(): |
|
|
|
|
|
|
|
def __init__(self): |
|
|
|
|
|
|
|
self.session_token = "" |
|
|
|
|
|
|
|
self.uuid_cache = bidict() |
|
|
|
|
|
|
|
self.webhooks = [] |
|
|
|
|
|
|
|
self.bot_username = "" |
|
|
|
|
|
|
|
self.next_message_time = datetime.now(timezone.utc) |
|
|
|
|
|
|
|
self.previous_message = "" |
|
|
|
|
|
|
|
self.player_list = bidict() |
|
|
|
|
|
|
|
self.previous_player_list = bidict() |
|
|
|
|
|
|
|
self.accept_join_events = False |
|
|
|
|
|
|
|
self.tab_header = "" |
|
|
|
|
|
|
|
self.tab_footer = "" |
|
|
|
# Initialize the discord part |
|
|
|
# Initialize the discord part |
|
|
|
discord_bot = discord.Client() |
|
|
|
self.discord_bot = discord.Client() |
|
|
|
|
|
|
|
self.config = Configuration("config.json") |
|
|
|
def register_handlers(connection): |
|
|
|
self.auth_token = None |
|
|
|
connection.register_packet_listener( |
|
|
|
self.connection = None |
|
|
|
handle_join_game, clientbound.play.JoinGamePacket) |
|
|
|
self.setup_logging(self.config.logging_level) |
|
|
|
|
|
|
|
self.database_session = DatabaseSession() |
|
|
|
connection.register_packet_listener( |
|
|
|
self.logger = logging.getLogger("bridge") |
|
|
|
handle_chat, clientbound.play.ChatMessagePacket) |
|
|
|
self.database_session.initialize(self.config) |
|
|
|
|
|
|
|
# We need to import twisted after setting up the logger because twisted hijacks our logging |
|
|
|
connection.register_packet_listener( |
|
|
|
from . import auth_server |
|
|
|
handle_health_update, clientbound.play.UpdateHealthPacket) |
|
|
|
auth_server.DATABASE_SESSION = self.database_session |
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
connection.register_packet_listener( |
|
|
|
if self.config.es_auth: |
|
|
|
handle_disconnect_packet, clientbound.play.DisconnectPacket) |
|
|
|
self.es_logger = ElasticsearchLogger( |
|
|
|
|
|
|
|
self.config.es_url, self.config.es_username, self.config.es_password) |
|
|
|
connection.register_packet_listener( |
|
|
|
|
|
|
|
handle_tab_list, clientbound.play.PlayerListItemPacket) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
connection.register_packet_listener( |
|
|
|
|
|
|
|
handle_player_list_header_and_footer_update, clientbound.play.PlayerListHeaderAndFooterPacket) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_player_list_header_and_footer_update(header_footer_packet): |
|
|
|
|
|
|
|
global TAB_FOOTER, TAB_HEADER |
|
|
|
|
|
|
|
log.debug("Got Tablist H/F Update: header=%s", header_footer_packet.header) |
|
|
|
|
|
|
|
log.debug("Got Tablist H/F Update: footer=%s", header_footer_packet.footer) |
|
|
|
|
|
|
|
TAB_HEADER = json.loads(header_footer_packet.header)["text"] |
|
|
|
|
|
|
|
TAB_FOOTER = json.loads(header_footer_packet.footer)["text"] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_tab_list(tab_list_packet): |
|
|
|
|
|
|
|
global ACCEPT_JOIN_EVENTS |
|
|
|
|
|
|
|
log.debug("Processing tab list packet") |
|
|
|
|
|
|
|
for action in tab_list_packet.actions: |
|
|
|
|
|
|
|
if isinstance(action, clientbound.play.PlayerListItemPacket.AddPlayerAction): |
|
|
|
|
|
|
|
log.debug( |
|
|
|
|
|
|
|
"Processing AddPlayerAction tab list packet, name: %s, uuid: %s", action.name, action.uuid) |
|
|
|
|
|
|
|
username = action.name |
|
|
|
|
|
|
|
player_uuid = action.uuid |
|
|
|
|
|
|
|
if action.name not in PLAYER_LIST.inv: |
|
|
|
|
|
|
|
PLAYER_LIST.inv[action.name] = action.uuid |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
# Sometimes we get a duplicate add packet on join idk why |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
if action.name not in UUID_CACHE.inv: |
|
|
|
|
|
|
|
UUID_CACHE.inv[action.name] = action.uuid |
|
|
|
|
|
|
|
# Initial tablist backfill |
|
|
|
|
|
|
|
if ACCEPT_JOIN_EVENTS: |
|
|
|
|
|
|
|
webhook_payload = { |
|
|
|
|
|
|
|
'username': username, |
|
|
|
|
|
|
|
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid), |
|
|
|
|
|
|
|
'content': '', |
|
|
|
|
|
|
|
'embeds': [{'color': 65280, 'title': '**Joined the game**'}] |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for webhook in WEBHOOKS: |
|
|
|
|
|
|
|
requests.post(webhook, json=webhook_payload) |
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
el.log_connection( |
|
|
|
|
|
|
|
uuid=action.uuid, reason=el.ConnectionReason.CONNECTED, count=len(PLAYER_LIST)) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
else: |
|
|
|
else: |
|
|
|
# The bot's name is sent last after the initial back-fill |
|
|
|
self.es_logger = ElasticsearchLogger(self.config.es_url) |
|
|
|
if action.name == BOT_USERNAME: |
|
|
|
|
|
|
|
ACCEPT_JOIN_EVENTS = True |
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
diff = set(PREVIOUS_PLAYER_LIST.keys()) - set(PLAYER_LIST.keys()) |
|
|
|
|
|
|
|
for idx, player_uuid in enumerate(diff): |
|
|
|
|
|
|
|
el.log_connection(uuid=player_uuid, reason=el.ConnectionReason.DISCONNECTED, |
|
|
|
|
|
|
|
count=len(PREVIOUS_PLAYER_LIST) - (idx + 1)) |
|
|
|
|
|
|
|
# Don't bother announcing the bot's own join message (who cares) but log it for analytics still |
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
el.log_connection( |
|
|
|
|
|
|
|
uuid=action.uuid, reason=el.ConnectionReason.CONNECTED, count=len(PLAYER_LIST)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
el.log_connection(uuid=action.uuid, reason=el.ConnectionReason.SEEN) |
|
|
|
|
|
|
|
if isinstance(action, clientbound.play.PlayerListItemPacket.RemovePlayerAction): |
|
|
|
|
|
|
|
log.debug("Processing RemovePlayerAction tab list packet, uuid: %s", action.uuid) |
|
|
|
|
|
|
|
username = mc_uuid_to_username(action.uuid) |
|
|
|
|
|
|
|
player_uuid = action.uuid |
|
|
|
|
|
|
|
webhook_payload = { |
|
|
|
|
|
|
|
'username': username, |
|
|
|
|
|
|
|
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid), |
|
|
|
|
|
|
|
'content': '', |
|
|
|
|
|
|
|
'embeds': [{'color': 16711680, 'title': '**Left the game**'}] |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for webhook in WEBHOOKS: |
|
|
|
|
|
|
|
requests.post(webhook, json=webhook_payload) |
|
|
|
|
|
|
|
del UUID_CACHE[action.uuid] |
|
|
|
|
|
|
|
del PLAYER_LIST[action.uuid] |
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
el.log_connection(uuid=action.uuid, reason=el.ConnectionReason.DISCONNECTED, count=len(PLAYER_LIST)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_join_game(join_game_packet): |
|
|
|
|
|
|
|
global PLAYER_LIST |
|
|
|
|
|
|
|
log.info('Connected.') |
|
|
|
|
|
|
|
PLAYER_LIST = bidict() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_chat(chat_packet): |
|
|
|
|
|
|
|
json_data = json.loads(chat_packet.json_data) |
|
|
|
|
|
|
|
if "extra" not in json_data: |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
chat_string = "" |
|
|
|
|
|
|
|
for chat_component in json_data["extra"]: |
|
|
|
|
|
|
|
chat_string += chat_component["text"] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Handle chat message |
|
|
|
|
|
|
|
regexp_match = re.match("<(.*?)> (.*)", chat_string, re.M | re.I) |
|
|
|
|
|
|
|
if regexp_match: |
|
|
|
|
|
|
|
username = regexp_match.group(1) |
|
|
|
|
|
|
|
original_message = regexp_match.group(2) |
|
|
|
|
|
|
|
player_uuid = mc_username_to_uuid(username) |
|
|
|
|
|
|
|
if username.lower() == BOT_USERNAME.lower(): |
|
|
|
|
|
|
|
# Don't relay our own messages |
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
bot_message_match = re.match("<{}> (.*?): (.*)".format( |
|
|
|
|
|
|
|
BOT_USERNAME.lower()), chat_string, re.M | re.I) |
|
|
|
|
|
|
|
if bot_message_match: |
|
|
|
|
|
|
|
el.log_chat_message( |
|
|
|
|
|
|
|
uuid=mc_username_to_uuid(bot_message_match.group(1)), |
|
|
|
|
|
|
|
display_name=bot_message_match.group(1), |
|
|
|
|
|
|
|
message=bot_message_match.group(2), |
|
|
|
|
|
|
|
message_unformatted=chat_string) |
|
|
|
|
|
|
|
el.log_raw_message(type=ChatType(chat_packet.position).name, message=chat_packet.json_data) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
log.info("Incoming message from minecraft: Username: %s Message: %s", username, original_message) |
|
|
|
|
|
|
|
log.debug("msg: %s", repr(original_message)) |
|
|
|
|
|
|
|
message = escape_markdown(remove_emoji(original_message.strip().replace("@", "@\N{zero width space}"))) |
|
|
|
|
|
|
|
webhook_payload = { |
|
|
|
|
|
|
|
'username': username, |
|
|
|
|
|
|
|
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid), |
|
|
|
|
|
|
|
'content': '{}'.format(message) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for webhook in WEBHOOKS: |
|
|
|
|
|
|
|
requests.post(webhook, json=webhook_payload) |
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
el.log_chat_message( |
|
|
|
|
|
|
|
uuid=player_uuid, display_name=username, message=original_message, message_unformatted=chat_string) |
|
|
|
|
|
|
|
if config.es_enabled: |
|
|
|
|
|
|
|
el.log_raw_message(type=ChatType(chat_packet.position).name, message=chat_packet.json_data) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_health_update(health_update_packet): |
|
|
|
@self.discord_bot.event |
|
|
|
if health_update_packet.health <= 0: |
|
|
|
async def on_ready(): # pylint: disable=W0612 |
|
|
|
log.debug("Respawned the player because it died") |
|
|
|
self.logger.info("Discord bot logged in as %s (%s)", self.discord_bot.user.name, self.discord_bot.user.id) |
|
|
|
packet = serverbound.play.ClientStatusPacket() |
|
|
|
self.webhooks = [] |
|
|
|
packet.action_id = serverbound.play.ClientStatusPacket.RESPAWN |
|
|
|
session = self.database_session.get_session() |
|
|
|
connection.write_packet(packet) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
register_handlers(connection) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
connection.connect() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@discord_bot.event |
|
|
|
|
|
|
|
async def on_ready(): |
|
|
|
|
|
|
|
log.info("Discord bot logged in as %s (%s)", discord_bot.user.name, discord_bot.user.id) |
|
|
|
|
|
|
|
global WEBHOOKS |
|
|
|
|
|
|
|
WEBHOOKS = [] |
|
|
|
|
|
|
|
session = database_session.get_session() |
|
|
|
|
|
|
|
channels = session.query(DiscordChannel).all() |
|
|
|
channels = session.query(DiscordChannel).all() |
|
|
|
session.close() |
|
|
|
session.close() |
|
|
|
for channel in channels: |
|
|
|
for channel in channels: |
|
|
|
channel_id = channel.channel_id |
|
|
|
channel_id = channel.channel_id |
|
|
|
discord_channel = discord_bot.get_channel(channel_id) |
|
|
|
discord_channel = self.discord_bot.get_channel(channel_id) |
|
|
|
channel_webhooks = await discord_channel.webhooks() |
|
|
|
channel_webhooks = await discord_channel.webhooks() |
|
|
|
found = False |
|
|
|
found = False |
|
|
|
for webhook in channel_webhooks: |
|
|
|
for webhook in channel_webhooks: |
|
|
|
if webhook.name == "_minecraft" and webhook.user == discord_bot.user: |
|
|
|
if webhook.name == "_minecraft" and webhook.user == self.discord_bot.user: |
|
|
|
WEBHOOKS.append(webhook.url) |
|
|
|
self.webhooks.append(webhook.url) |
|
|
|
found = True |
|
|
|
found = True |
|
|
|
log.debug("Found webhook %s in channel %s", webhook.name, discord_channel.name) |
|
|
|
self.logger.debug("Found webhook %s in channel %s", webhook.name, discord_channel.name) |
|
|
|
if not found: |
|
|
|
if not found: |
|
|
|
# Create the hook |
|
|
|
# Create the hook |
|
|
|
await discord_channel.create_webhook(name="_minecraft") |
|
|
|
await discord_channel.create_webhook(name="_minecraft") |
|
|
|
|
|
|
|
|
|
|
|
@discord_bot.event |
|
|
|
@self.discord_bot.event |
|
|
|
async def on_message(message): |
|
|
|
async def on_message(message): # pylint: disable=W0612 |
|
|
|
# We do not want the bot to reply to itself |
|
|
|
# We do not want the bot to reply to itself |
|
|
|
if message.author == discord_bot.user: |
|
|
|
if message.author == self.discord_bot.user: |
|
|
|
return |
|
|
|
return |
|
|
|
this_channel = message.channel.id |
|
|
|
this_channel = message.channel.id |
|
|
|
global WEBHOOKS |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# PM Commands |
|
|
|
# PM Commands |
|
|
|
if message.content.startswith("mc!help"): |
|
|
|
if message.content.startswith("mc!help"): |
|
|
@ -428,7 +99,7 @@ def main(): |
|
|
|
if not dm_channel: |
|
|
|
if not dm_channel: |
|
|
|
await message.author.create_dm() |
|
|
|
await message.author.create_dm() |
|
|
|
send_channel = message.author.dm_channel |
|
|
|
send_channel = message.author.dm_channel |
|
|
|
msg = get_discord_help_string() |
|
|
|
msg = self.get_discord_help_string() |
|
|
|
await send_channel.send(msg) |
|
|
|
await send_channel.send(msg) |
|
|
|
except discord.errors.Forbidden: |
|
|
|
except discord.errors.Forbidden: |
|
|
|
if isinstance(message.author, discord.abc.User): |
|
|
|
if isinstance(message.author, discord.abc.User): |
|
|
@ -440,7 +111,6 @@ def main(): |
|
|
|
|
|
|
|
|
|
|
|
elif message.content.startswith("mc!register"): |
|
|
|
elif message.content.startswith("mc!register"): |
|
|
|
try: |
|
|
|
try: |
|
|
|
# TODO: Catch the Forbidden error in a smart way before running application logic |
|
|
|
|
|
|
|
send_channel = message.channel |
|
|
|
send_channel = message.channel |
|
|
|
if isinstance(message.channel, discord.abc.GuildChannel): |
|
|
|
if isinstance(message.channel, discord.abc.GuildChannel): |
|
|
|
await message.delete() |
|
|
|
await message.delete() |
|
|
@ -448,7 +118,7 @@ def main(): |
|
|
|
if not dm_channel: |
|
|
|
if not dm_channel: |
|
|
|
await message.author.create_dm() |
|
|
|
await message.author.create_dm() |
|
|
|
send_channel = message.author.dm_channel |
|
|
|
send_channel = message.author.dm_channel |
|
|
|
session = database_session.get_session() |
|
|
|
session = self.database_session.get_session() |
|
|
|
discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() |
|
|
|
discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() |
|
|
|
if not discord_account: |
|
|
|
if not discord_account: |
|
|
|
new_discord_account = DiscordAccount(message.author.id) |
|
|
|
new_discord_account = DiscordAccount(message.author.id) |
|
|
@ -456,13 +126,13 @@ def main(): |
|
|
|
session.commit() |
|
|
|
session.commit() |
|
|
|
discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() |
|
|
|
discord_account = session.query(DiscordAccount).filter_by(discord_id=message.author.id).first() |
|
|
|
|
|
|
|
|
|
|
|
new_token = generate_random_auth_token(16) |
|
|
|
new_token = self.generate_random_auth_token(16) |
|
|
|
account_link_token = AccountLinkToken(message.author.id, new_token) |
|
|
|
account_link_token = AccountLinkToken(message.author.id, new_token) |
|
|
|
discord_account.link_token = account_link_token |
|
|
|
discord_account.link_token = account_link_token |
|
|
|
session.add(account_link_token) |
|
|
|
session.add(account_link_token) |
|
|
|
session.commit() |
|
|
|
session.commit() |
|
|
|
msg = "Please connect your minecraft account to `{}.{}:{}` in order to link it to this bridge!"\ |
|
|
|
msg = "Please connect your minecraft account to `{}.{}:{}` in order to link it to this bridge!"\ |
|
|
|
.format(new_token, config.auth_dns, config.auth_port) |
|
|
|
.format(new_token, self.config.auth_dns, self.config.auth_port) |
|
|
|
session.close() |
|
|
|
session.close() |
|
|
|
del session |
|
|
|
del session |
|
|
|
await send_channel.send(msg) |
|
|
|
await send_channel.send(msg) |
|
|
@ -480,7 +150,7 @@ def main(): |
|
|
|
msg = "Sorry, this command is only available in public channels." |
|
|
|
msg = "Sorry, this command is only available in public channels." |
|
|
|
await message.channel.send(msg) |
|
|
|
await message.channel.send(msg) |
|
|
|
return |
|
|
|
return |
|
|
|
if message.author.id not in config.admin_users: |
|
|
|
if message.author.id not in self.config.admin_users: |
|
|
|
await message.delete() |
|
|
|
await message.delete() |
|
|
|
try: |
|
|
|
try: |
|
|
|
dm_channel = message.author.dm_channel |
|
|
|
dm_channel = message.author.dm_channel |
|
|
@ -496,7 +166,7 @@ def main(): |
|
|
|
await asyncio.sleep(3) |
|
|
|
await asyncio.sleep(3) |
|
|
|
await error_msg.delete() |
|
|
|
await error_msg.delete() |
|
|
|
return |
|
|
|
return |
|
|
|
session = database_session.get_session() |
|
|
|
session = self.database_session.get_session() |
|
|
|
channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all() |
|
|
|
channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all() |
|
|
|
if not channels: |
|
|
|
if not channels: |
|
|
|
new_channel = DiscordChannel(this_channel) |
|
|
|
new_channel = DiscordChannel(this_channel) |
|
|
@ -505,7 +175,7 @@ def main(): |
|
|
|
session.close() |
|
|
|
session.close() |
|
|
|
del session |
|
|
|
del session |
|
|
|
webhook = await message.channel.create_webhook(name="_minecraft") |
|
|
|
webhook = await message.channel.create_webhook(name="_minecraft") |
|
|
|
WEBHOOKS.append(webhook.url) |
|
|
|
self.webhooks.append(webhook.url) |
|
|
|
msg = "The bot will now start chatting here! To stop this, run `mc!stopchathere`." |
|
|
|
msg = "The bot will now start chatting here! To stop this, run `mc!stopchathere`." |
|
|
|
await message.channel.send(msg) |
|
|
|
await message.channel.send(msg) |
|
|
|
else: |
|
|
|
else: |
|
|
@ -518,7 +188,7 @@ def main(): |
|
|
|
msg = "Sorry, this command is only available in public channels." |
|
|
|
msg = "Sorry, this command is only available in public channels." |
|
|
|
await message.channel.send(msg) |
|
|
|
await message.channel.send(msg) |
|
|
|
return |
|
|
|
return |
|
|
|
if message.author.id not in config.admin_users: |
|
|
|
if message.author.id not in self.config.admin_users: |
|
|
|
await message.delete() |
|
|
|
await message.delete() |
|
|
|
try: |
|
|
|
try: |
|
|
|
dm_channel = message.author.dm_channel |
|
|
|
dm_channel = message.author.dm_channel |
|
|
@ -533,19 +203,18 @@ def main(): |
|
|
|
error_msg = await message.channel.send(msg) |
|
|
|
error_msg = await message.channel.send(msg) |
|
|
|
await asyncio.sleep(3) |
|
|
|
await asyncio.sleep(3) |
|
|
|
await error_msg.delete() |
|
|
|
await error_msg.delete() |
|
|
|
finally: |
|
|
|
|
|
|
|
return |
|
|
|
return |
|
|
|
session = database_session.get_session() |
|
|
|
session = self.database_session.get_session() |
|
|
|
deleted = session.query(DiscordChannel).filter_by(channel_id=this_channel).delete() |
|
|
|
deleted = session.query(DiscordChannel).filter_by(channel_id=this_channel).delete() |
|
|
|
session.commit() |
|
|
|
session.commit() |
|
|
|
session.close() |
|
|
|
session.close() |
|
|
|
for webhook in await message.channel.webhooks(): |
|
|
|
for webhook in await message.channel.webhooks(): |
|
|
|
if webhook.name == "_minecraft" and webhook.user == discord_bot.user: |
|
|
|
if webhook.name == "_minecraft" and webhook.user == self.discord_bot.user: |
|
|
|
# Copy the list to avoid some problems since |
|
|
|
# Copy the list to avoid some problems since |
|
|
|
# we're deleting indicies form it as we loop |
|
|
|
# we're deleting indicies form it as we loop |
|
|
|
# through it |
|
|
|
# through it |
|
|
|
if webhook.url in WEBHOOKS[:]: |
|
|
|
if webhook.url in self.webhooks[:]: |
|
|
|
WEBHOOKS.remove(webhook.url) |
|
|
|
self.webhooks.remove(webhook.url) |
|
|
|
await webhook.delete() |
|
|
|
await webhook.delete() |
|
|
|
if deleted < 1: |
|
|
|
if deleted < 1: |
|
|
|
msg = "The bot was not chatting here!" |
|
|
|
msg = "The bot was not chatting here!" |
|
|
@ -565,13 +234,13 @@ def main(): |
|
|
|
if not dm_channel: |
|
|
|
if not dm_channel: |
|
|
|
await message.author.create_dm() |
|
|
|
await message.author.create_dm() |
|
|
|
send_channel = message.author.dm_channel |
|
|
|
send_channel = message.author.dm_channel |
|
|
|
player_list = ", ".join(list(map(lambda x: x[1], PLAYER_LIST.items()))) |
|
|
|
player_list = ", ".join(list(map(lambda x: x[1], self.player_list.items()))) |
|
|
|
msg = "{}\n" \ |
|
|
|
msg = "{}\n" \ |
|
|
|
"Players online: {}\n" \ |
|
|
|
"Players online: {}\n" \ |
|
|
|
"{}".format(escape_markdown( |
|
|
|
"{}".format(self.escape_markdown( |
|
|
|
strip_colour(TAB_HEADER)), escape_markdown( |
|
|
|
self.strip_colour(self.tab_header)), self.escape_markdown( |
|
|
|
strip_colour(player_list)), escape_markdown( |
|
|
|
self.strip_colour(player_list)), self.escape_markdown( |
|
|
|
strip_colour(TAB_FOOTER))) |
|
|
|
self.strip_colour(self.tab_footer))) |
|
|
|
await send_channel.send(msg) |
|
|
|
await send_channel.send(msg) |
|
|
|
except discord.errors.Forbidden: |
|
|
|
except discord.errors.Forbidden: |
|
|
|
if isinstance(message.author, discord.abc.User): |
|
|
|
if isinstance(message.author, discord.abc.User): |
|
|
@ -602,7 +271,7 @@ def main(): |
|
|
|
return |
|
|
|
return |
|
|
|
|
|
|
|
|
|
|
|
elif not message.author.bot: |
|
|
|
elif not message.author.bot: |
|
|
|
session = database_session.get_session() |
|
|
|
session = self.database_session.get_session() |
|
|
|
channel_should_chat = session.query(DiscordChannel).filter_by(channel_id=this_channel).first() |
|
|
|
channel_should_chat = session.query(DiscordChannel).filter_by(channel_id=this_channel).first() |
|
|
|
if channel_should_chat: |
|
|
|
if channel_should_chat: |
|
|
|
await message.delete() |
|
|
|
await message.delete() |
|
|
@ -612,16 +281,16 @@ def main(): |
|
|
|
minecraft_uuid = discord_user.minecraft_account.minecraft_uuid |
|
|
|
minecraft_uuid = discord_user.minecraft_account.minecraft_uuid |
|
|
|
session.close() |
|
|
|
session.close() |
|
|
|
del session |
|
|
|
del session |
|
|
|
minecraft_username = mc_uuid_to_username(minecraft_uuid) |
|
|
|
minecraft_username = self.mc_uuid_to_username(minecraft_uuid) |
|
|
|
|
|
|
|
|
|
|
|
# Max chat message length: 256, bot username does not count towards this |
|
|
|
# Max chat message length: 256, bot username does not count towards this |
|
|
|
# Does not count|Counts |
|
|
|
# Does not count|Counts |
|
|
|
# <BOT_USERNAME> minecraft_username: message |
|
|
|
# <BOT_USERNAME> minecraft_username: message |
|
|
|
padding = 2 + len(minecraft_username) |
|
|
|
padding = 2 + len(minecraft_username) |
|
|
|
|
|
|
|
|
|
|
|
message_to_send = remove_emoji( |
|
|
|
message_to_send = self.remove_emoji( |
|
|
|
message.clean_content.encode('utf-8').decode('ascii', 'replace')).strip() |
|
|
|
message.clean_content.encode('utf-8').decode('ascii', 'replace')).strip() |
|
|
|
message_to_discord = escape_markdown(message.clean_content) |
|
|
|
message_to_discord = self.escape_markdown(message.clean_content) |
|
|
|
|
|
|
|
|
|
|
|
total_len = padding + len(message_to_send) |
|
|
|
total_len = padding + len(message_to_send) |
|
|
|
if total_len > 256: |
|
|
|
if total_len > 256: |
|
|
@ -630,13 +299,12 @@ def main(): |
|
|
|
elif not message_to_send: |
|
|
|
elif not message_to_send: |
|
|
|
return |
|
|
|
return |
|
|
|
|
|
|
|
|
|
|
|
session = database_session.get_session() |
|
|
|
session = self.database_session.get_session() |
|
|
|
channels = session.query(DiscordChannel).all() |
|
|
|
channels = session.query(DiscordChannel).all() |
|
|
|
session.close() |
|
|
|
session.close() |
|
|
|
del session |
|
|
|
del session |
|
|
|
global PREVIOUS_MESSAGE, NEXT_MESSAGE_TIME |
|
|
|
if message_to_send == self.previous_message or \ |
|
|
|
if message_to_send == PREVIOUS_MESSAGE or \ |
|
|
|
datetime.now(timezone.utc) < self.next_message_time: |
|
|
|
datetime.now(timezone.utc) < NEXT_MESSAGE_TIME: |
|
|
|
|
|
|
|
send_channel = message.channel |
|
|
|
send_channel = message.channel |
|
|
|
try: |
|
|
|
try: |
|
|
|
if isinstance(message.channel, discord.abc.GuildChannel): |
|
|
|
if isinstance(message.channel, discord.abc.GuildChannel): |
|
|
@ -655,24 +323,26 @@ def main(): |
|
|
|
await error_msg.delete() |
|
|
|
await error_msg.delete() |
|
|
|
return |
|
|
|
return |
|
|
|
|
|
|
|
|
|
|
|
PREVIOUS_MESSAGE = message_to_send |
|
|
|
self.previous_message = message_to_send |
|
|
|
NEXT_MESSAGE_TIME = datetime.now(timezone.utc) + timedelta(seconds=config.message_delay) |
|
|
|
self.next_message_time = datetime.now(timezone.utc) + timedelta( |
|
|
|
|
|
|
|
seconds=self.config.message_delay) |
|
|
|
|
|
|
|
|
|
|
|
log.info("Outgoing message from discord: Username: %s Message: %s", |
|
|
|
self.logger.info("Outgoing message from discord: Username: %s Message: %s", |
|
|
|
minecraft_username, message_to_send) |
|
|
|
minecraft_username, message_to_send) |
|
|
|
|
|
|
|
|
|
|
|
for channel in channels: |
|
|
|
for channel in channels: |
|
|
|
webhooks = await discord_bot.get_channel(channel.channel_id).webhooks() |
|
|
|
webhooks = await self.discord_bot.get_channel(channel.channel_id).webhooks() |
|
|
|
for webhook in webhooks: |
|
|
|
for webhook in webhooks: |
|
|
|
if webhook.name == "_minecraft": |
|
|
|
if webhook.name == "_minecraft": |
|
|
|
await webhook.send( |
|
|
|
await webhook.send( |
|
|
|
username=minecraft_username, |
|
|
|
username=minecraft_username, |
|
|
|
avatar_url="https://visage.surgeplay.com/face/160/{}".format(minecraft_uuid), |
|
|
|
avatar_url="https://visage.surgeplay.com/face/160/{}".format( |
|
|
|
|
|
|
|
minecraft_uuid), |
|
|
|
content=message_to_discord) |
|
|
|
content=message_to_discord) |
|
|
|
|
|
|
|
|
|
|
|
packet = serverbound.play.ChatPacket() |
|
|
|
packet = serverbound.play.ChatPacket() |
|
|
|
packet.message = "{}: {}".format(minecraft_username, message_to_send) |
|
|
|
packet.message = "{}: {}".format(minecraft_username, message_to_send) |
|
|
|
connection.write_packet(packet) |
|
|
|
self.connection.write_packet(packet) |
|
|
|
else: |
|
|
|
else: |
|
|
|
send_channel = message.channel |
|
|
|
send_channel = message.channel |
|
|
|
try: |
|
|
|
try: |
|
|
@ -698,7 +368,332 @@ def main(): |
|
|
|
session.close() |
|
|
|
session.close() |
|
|
|
del session |
|
|
|
del session |
|
|
|
|
|
|
|
|
|
|
|
discord_bot.run(config.discord_token) |
|
|
|
def run(self): |
|
|
|
|
|
|
|
reactor_thread = Thread(target=self.run_auth_server, args=(self.config.auth_port,)) |
|
|
|
|
|
|
|
reactor_thread.start() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
self.logger.debug("Checking if the server {} is online before connecting.") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if not self.config.mc_online: |
|
|
|
|
|
|
|
self.logger.info("Connecting in offline mode...") |
|
|
|
|
|
|
|
while not self.is_server_online(): |
|
|
|
|
|
|
|
self.logger.info('Not connecting to server because it appears to be offline.') |
|
|
|
|
|
|
|
time.sleep(15) |
|
|
|
|
|
|
|
self.bot_username = self.config.mc_username |
|
|
|
|
|
|
|
self.connection = Connection( |
|
|
|
|
|
|
|
self.config.mc_server, self.config.mc_port, username=self.config.mc_username, |
|
|
|
|
|
|
|
handle_exception=self.minecraft_handle_exception) |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
self.auth_token = authentication.AuthenticationToken() |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
self.auth_token.authenticate(self.config.mc_username, self.config.mc_password) |
|
|
|
|
|
|
|
except YggdrasilError as ex: |
|
|
|
|
|
|
|
self.logger.info(ex) |
|
|
|
|
|
|
|
sys.exit() |
|
|
|
|
|
|
|
self.bot_username = self.auth_token.profile.name |
|
|
|
|
|
|
|
self.logger.info("Logged in as %s...", self.auth_token.profile.name) |
|
|
|
|
|
|
|
while not self.is_server_online(): |
|
|
|
|
|
|
|
self.logger.info('Not connecting to server because it appears to be offline.') |
|
|
|
|
|
|
|
time.sleep(15) |
|
|
|
|
|
|
|
self.connection = Connection( |
|
|
|
|
|
|
|
self.config.mc_server, self.config.mc_port, auth_token=self.auth_token, |
|
|
|
|
|
|
|
handle_exception=self.minecraft_handle_exception) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
self.register_handlers(self.connection) |
|
|
|
|
|
|
|
self.connection.connect() |
|
|
|
|
|
|
|
self.discord_bot.run(self.config.discord_token) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mc_uuid_to_username(self, mc_uuid: str): |
|
|
|
|
|
|
|
if mc_uuid not in self.uuid_cache: |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
short_uuid = mc_uuid.replace("-", "") |
|
|
|
|
|
|
|
mojang_response = requests.get("https://api.mojang.com/user/profiles/{}/names".format( |
|
|
|
|
|
|
|
short_uuid)).json() |
|
|
|
|
|
|
|
if len(mojang_response) > 1: |
|
|
|
|
|
|
|
# Multiple name changes |
|
|
|
|
|
|
|
player_username = mojang_response[-1]["name"] |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
# Only one name |
|
|
|
|
|
|
|
player_username = mojang_response[0]["name"] |
|
|
|
|
|
|
|
self.uuid_cache[mc_uuid] = player_username |
|
|
|
|
|
|
|
return player_username |
|
|
|
|
|
|
|
except requests.RequestException as ex: |
|
|
|
|
|
|
|
self.logger.error(ex, exc_info=True) |
|
|
|
|
|
|
|
self.logger.error("Failed to lookup %s's username using the Mojang API.", mc_uuid) |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
return self.uuid_cache[mc_uuid] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mc_username_to_uuid(self, username: str): |
|
|
|
|
|
|
|
if username not in self.uuid_cache.inv: |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
player_uuid = requests.get( |
|
|
|
|
|
|
|
"https://api.mojang.com/users/profiles/minecraft/{}".format(username)).json()["id"] |
|
|
|
|
|
|
|
long_uuid = uuid.UUID(player_uuid) |
|
|
|
|
|
|
|
self.uuid_cache.inv[username] = str(long_uuid) |
|
|
|
|
|
|
|
return player_uuid |
|
|
|
|
|
|
|
except requests.RequestException: |
|
|
|
|
|
|
|
self.logger.error("Failed to lookup %s's username using the Mojang API.", username) |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
return self.uuid_cache.inv[username] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_discord_help_string(self): |
|
|
|
|
|
|
|
help_str = ("Admin commands:\n" |
|
|
|
|
|
|
|
"`mc!chathere`: Starts outputting server messages in this channel\n" |
|
|
|
|
|
|
|
"`mc!stopchathere`: Stops outputting server messages in this channel\n" |
|
|
|
|
|
|
|
"User commands:\n" |
|
|
|
|
|
|
|
"`mc!tab`: Sends you the content of the server's player/tab list\n" |
|
|
|
|
|
|
|
"`mc!register`: Starts the minecraft account registration process\n" |
|
|
|
|
|
|
|
"To start chatting on the minecraft server, please register your account using `mc!register`.") |
|
|
|
|
|
|
|
return help_str |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# https://stackoverflow.com/questions/33404752/removing-emojis-from-a-string-in-python |
|
|
|
|
|
|
|
def remove_emoji(self, dirty_string): |
|
|
|
|
|
|
|
emoji_pattern = re.compile( |
|
|
|
|
|
|
|
"[" |
|
|
|
|
|
|
|
u"\U0001F600-\U0001F64F" # emoticons |
|
|
|
|
|
|
|
u"\U0001F300-\U0001F5FF" # symbols & pictographs |
|
|
|
|
|
|
|
u"\U0001F680-\U0001F6FF" # transport & map symbols |
|
|
|
|
|
|
|
u"\U0001F1E0-\U0001F1FF" # flags (iOS) |
|
|
|
|
|
|
|
u"\U0001F900-\U0001FAFF" # CJK Compatibility Ideographs |
|
|
|
|
|
|
|
# u"\U00002702-\U000027B0" |
|
|
|
|
|
|
|
# u"\U000024C2-\U0001F251" |
|
|
|
|
|
|
|
"]+", flags=re.UNICODE) |
|
|
|
|
|
|
|
return emoji_pattern.sub(r'', dirty_string) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def escape_markdown(self, md_string): |
|
|
|
|
|
|
|
# Absolutely needs to go first or it will replace our escaping slashes! |
|
|
|
|
|
|
|
escaped_string = md_string.replace("\\", "\\\\") |
|
|
|
|
|
|
|
escaped_string = escaped_string.replace("_", "\\_") |
|
|
|
|
|
|
|
escaped_string = escaped_string.replace("*", "\\*") |
|
|
|
|
|
|
|
return escaped_string |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def strip_colour(self, dirty_string): |
|
|
|
|
|
|
|
colour_pattern = re.compile( |
|
|
|
|
|
|
|
u"\U000000A7" # selection symbol |
|
|
|
|
|
|
|
".", flags=re.UNICODE) |
|
|
|
|
|
|
|
return colour_pattern.sub(r'', dirty_string) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def setup_logging(self, level): |
|
|
|
|
|
|
|
if level.lower() == "debug": |
|
|
|
|
|
|
|
log_level = logging.DEBUG |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
log_level = logging.INFO |
|
|
|
|
|
|
|
log_format = "%(asctime)s:%(name)s:%(levelname)s:%(message)s" |
|
|
|
|
|
|
|
logging.basicConfig(filename="bridge_log.log", format=log_format, level=log_level) |
|
|
|
|
|
|
|
stdout_logger = logging.StreamHandler(sys.stdout) |
|
|
|
|
|
|
|
stdout_logger.setFormatter(logging.Formatter(log_format)) |
|
|
|
|
|
|
|
logging.getLogger().addHandler(stdout_logger) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_auth_server(self, port): |
|
|
|
|
|
|
|
# We need to import twisted after setting up the logger because twisted hijacks our logging |
|
|
|
|
|
|
|
from twisted.internet import reactor |
|
|
|
|
|
|
|
from .auth_server import AuthFactory |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Create factory |
|
|
|
|
|
|
|
factory = AuthFactory() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Listen |
|
|
|
|
|
|
|
self.logger.info("Starting authentication server on port %d", port) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
factory.listen("", port) |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
reactor.run(installSignalHandlers=False) |
|
|
|
|
|
|
|
except KeyboardInterrupt: |
|
|
|
|
|
|
|
reactor.stop() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def generate_random_auth_token(self, length): |
|
|
|
|
|
|
|
letters = string.ascii_lowercase + string.digits + string.ascii_uppercase |
|
|
|
|
|
|
|
return ''.join(random.choice(letters) for i in range(length)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_disconnect(self, json_data=""): |
|
|
|
|
|
|
|
self.logger.info('Disconnected.') |
|
|
|
|
|
|
|
if json_data: |
|
|
|
|
|
|
|
self.logger.info("Disconnect json data: %s", json_data) |
|
|
|
|
|
|
|
self.previous_player_list = self.player_list.copy() |
|
|
|
|
|
|
|
self.accept_join_events = False |
|
|
|
|
|
|
|
self.player_list = bidict() |
|
|
|
|
|
|
|
if self.connection.connected: |
|
|
|
|
|
|
|
self.logger.info("Forced a disconnection because the connection is still connected.") |
|
|
|
|
|
|
|
self.connection.disconnect(immediate=True) |
|
|
|
|
|
|
|
time.sleep(15) |
|
|
|
|
|
|
|
while not self.is_server_online(): |
|
|
|
|
|
|
|
self.logger.info('Not reconnecting to server because it appears to be offline.') |
|
|
|
|
|
|
|
time.sleep(15) |
|
|
|
|
|
|
|
self.logger.info('Reconnecting.') |
|
|
|
|
|
|
|
self.connection.connect() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_disconnect_packet(self, disconnect_packet): |
|
|
|
|
|
|
|
self.handle_disconnect(disconnect_packet.json_data) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def minecraft_handle_exception(self, exception, exc_info): |
|
|
|
|
|
|
|
self.logger.error("A minecraft exception occured! %s:", exception, exc_info=exc_info) |
|
|
|
|
|
|
|
self.handle_disconnect() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_server_online(self): |
|
|
|
|
|
|
|
server = MinecraftServer.lookup("{}:{}".format(self.config.mc_server, self.config.mc_port)) |
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
status = server.status() |
|
|
|
|
|
|
|
del status |
|
|
|
|
|
|
|
return True |
|
|
|
|
|
|
|
except ConnectionRefusedError: |
|
|
|
|
|
|
|
return False |
|
|
|
|
|
|
|
# AttributeError: 'TCPSocketConnection' object has no attribute 'socket' |
|
|
|
|
|
|
|
# This might not be required as it happens upstream |
|
|
|
|
|
|
|
except AttributeError: |
|
|
|
|
|
|
|
return False |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_handlers(self, connection): |
|
|
|
|
|
|
|
connection.register_packet_listener( |
|
|
|
|
|
|
|
self.handle_join_game, clientbound.play.JoinGamePacket) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
connection.register_packet_listener( |
|
|
|
|
|
|
|
self.handle_chat, clientbound.play.ChatMessagePacket) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
connection.register_packet_listener( |
|
|
|
|
|
|
|
self.handle_health_update, clientbound.play.UpdateHealthPacket) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
connection.register_packet_listener( |
|
|
|
|
|
|
|
self.handle_disconnect_packet, clientbound.play.DisconnectPacket) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
connection.register_packet_listener( |
|
|
|
|
|
|
|
self.handle_tab_list, clientbound.play.PlayerListItemPacket) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
connection.register_packet_listener( |
|
|
|
|
|
|
|
self.handle_player_list_header_and_footer_update, clientbound.play.PlayerListHeaderAndFooterPacket) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_player_list_header_and_footer_update(self, header_footer_packet): |
|
|
|
|
|
|
|
self.logger.debug("Got Tablist H/F Update: header=%s", header_footer_packet.header) |
|
|
|
|
|
|
|
self.logger.debug("Got Tablist H/F Update: footer=%s", header_footer_packet.footer) |
|
|
|
|
|
|
|
self.tab_header = json.loads(header_footer_packet.header)["text"] |
|
|
|
|
|
|
|
self.tab_footer = json.loads(header_footer_packet.footer)["text"] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_tab_list(self, tab_list_packet): |
|
|
|
|
|
|
|
self.logger.debug("Processing tab list packet") |
|
|
|
|
|
|
|
for action in tab_list_packet.actions: |
|
|
|
|
|
|
|
if isinstance(action, clientbound.play.PlayerListItemPacket.AddPlayerAction): |
|
|
|
|
|
|
|
self.logger.debug( |
|
|
|
|
|
|
|
"Processing AddPlayerAction tab list packet, name: %s, uuid: %s", action.name, action.uuid) |
|
|
|
|
|
|
|
username = action.name |
|
|
|
|
|
|
|
player_uuid = action.uuid |
|
|
|
|
|
|
|
if action.name not in self.player_list.inv: |
|
|
|
|
|
|
|
self.player_list.inv[action.name] = action.uuid |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
# Sometimes we get a duplicate add packet on join idk why |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
if action.name not in self.uuid_cache.inv: |
|
|
|
|
|
|
|
self.uuid_cache.inv[action.name] = action.uuid |
|
|
|
|
|
|
|
# Initial tablist backfill |
|
|
|
|
|
|
|
if self.accept_join_events: |
|
|
|
|
|
|
|
webhook_payload = { |
|
|
|
|
|
|
|
'username': username, |
|
|
|
|
|
|
|
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid), |
|
|
|
|
|
|
|
'content': '', |
|
|
|
|
|
|
|
'embeds': [{'color': 65280, 'title': '**Joined the game**'}] |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for webhook in self.webhooks: |
|
|
|
|
|
|
|
requests.post(webhook, json=webhook_payload) |
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
|
|
|
|
self.es_logger.log_connection( |
|
|
|
|
|
|
|
uuid=action.uuid, reason=ConnectionReason.CONNECTED, count=len(self.player_list)) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
else: |
|
|
|
|
|
|
|
# The bot's name is sent last after the initial back-fill |
|
|
|
|
|
|
|
if action.name == self.bot_username: |
|
|
|
|
|
|
|
self.accept_join_events = True |
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
|
|
|
|
diff = set(self.previous_player_list.keys()) - set(self.player_list.keys()) |
|
|
|
|
|
|
|
for idx, player_uuid in enumerate(diff): |
|
|
|
|
|
|
|
self.es_logger.log_connection( |
|
|
|
|
|
|
|
uuid=player_uuid, reason=ConnectionReason.DISCONNECTED, |
|
|
|
|
|
|
|
count=len(self.previous_player_list) - (idx + 1)) |
|
|
|
|
|
|
|
# Don't bother announcing the bot's own join message (who cares) but log it for analytics still |
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
|
|
|
|
self.es_logger.log_connection( |
|
|
|
|
|
|
|
uuid=action.uuid, reason=ConnectionReason.CONNECTED, count=len(self.player_list)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
|
|
|
|
self.es_logger.log_connection(uuid=action.uuid, reason=ConnectionReason.SEEN) |
|
|
|
|
|
|
|
if isinstance(action, clientbound.play.PlayerListItemPacket.RemovePlayerAction): |
|
|
|
|
|
|
|
self.logger.debug("Processing RemovePlayerAction tab list packet, uuid: %s", action.uuid) |
|
|
|
|
|
|
|
username = self.mc_uuid_to_username(action.uuid) |
|
|
|
|
|
|
|
player_uuid = action.uuid |
|
|
|
|
|
|
|
webhook_payload = { |
|
|
|
|
|
|
|
'username': username, |
|
|
|
|
|
|
|
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid), |
|
|
|
|
|
|
|
'content': '', |
|
|
|
|
|
|
|
'embeds': [{'color': 16711680, 'title': '**Left the game**'}] |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for webhook in self.webhooks: |
|
|
|
|
|
|
|
requests.post(webhook, json=webhook_payload) |
|
|
|
|
|
|
|
del self.uuid_cache[action.uuid] |
|
|
|
|
|
|
|
del self.player_list[action.uuid] |
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
|
|
|
|
self.es_logger.log_connection( |
|
|
|
|
|
|
|
uuid=action.uuid, reason=ConnectionReason.DISCONNECTED, count=len(self.player_list)) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_join_game(self, join_game_packet): |
|
|
|
|
|
|
|
self.logger.info('Connected and joined game as entity id %d', join_game_packet.entity_id) |
|
|
|
|
|
|
|
self.player_list = bidict() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_chat(self, chat_packet): |
|
|
|
|
|
|
|
json_data = json.loads(chat_packet.json_data) |
|
|
|
|
|
|
|
if "extra" not in json_data: |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
chat_string = "" |
|
|
|
|
|
|
|
for chat_component in json_data["extra"]: |
|
|
|
|
|
|
|
chat_string += chat_component["text"] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Handle chat message |
|
|
|
|
|
|
|
regexp_match = re.match("<(.*?)> (.*)", chat_string, re.M | re.I) |
|
|
|
|
|
|
|
if regexp_match: |
|
|
|
|
|
|
|
username = regexp_match.group(1) |
|
|
|
|
|
|
|
original_message = regexp_match.group(2) |
|
|
|
|
|
|
|
player_uuid = self.mc_username_to_uuid(username) |
|
|
|
|
|
|
|
if username.lower() == self.bot_username.lower(): |
|
|
|
|
|
|
|
# Don't relay our own messages |
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
|
|
|
|
bot_message_match = re.match("<{}> (.*?): (.*)".format( |
|
|
|
|
|
|
|
self.bot_username.lower()), chat_string, re.M | re.I) |
|
|
|
|
|
|
|
if bot_message_match: |
|
|
|
|
|
|
|
self.es_logger.log_chat_message( |
|
|
|
|
|
|
|
uuid=self.mc_username_to_uuid(bot_message_match.group(1)), |
|
|
|
|
|
|
|
display_name=bot_message_match.group(1), |
|
|
|
|
|
|
|
message=bot_message_match.group(2), |
|
|
|
|
|
|
|
message_unformatted=chat_string) |
|
|
|
|
|
|
|
self.es_logger.log_raw_message( |
|
|
|
|
|
|
|
msg_type=chat_packet.Position.name_from_value(chat_packet.position), |
|
|
|
|
|
|
|
message=chat_packet.json_data) |
|
|
|
|
|
|
|
return |
|
|
|
|
|
|
|
self.logger.info("Incoming message from minecraft: Username: %s Message: %s", username, original_message) |
|
|
|
|
|
|
|
self.logger.debug("msg: %s", repr(original_message)) |
|
|
|
|
|
|
|
message = self.escape_markdown(self.remove_emoji(original_message.strip().replace( |
|
|
|
|
|
|
|
"@", "@\N{zero width space}"))) |
|
|
|
|
|
|
|
webhook_payload = { |
|
|
|
|
|
|
|
'username': username, |
|
|
|
|
|
|
|
'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid), |
|
|
|
|
|
|
|
'content': '{}'.format(message) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for webhook in self.webhooks: |
|
|
|
|
|
|
|
requests.post(webhook, json=webhook_payload) |
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
|
|
|
|
self.es_logger.log_chat_message( |
|
|
|
|
|
|
|
uuid=player_uuid, display_name=username, message=original_message, message_unformatted=chat_string) |
|
|
|
|
|
|
|
if self.config.es_enabled: |
|
|
|
|
|
|
|
self.es_logger.log_raw_message( |
|
|
|
|
|
|
|
msg_type=chat_packet.Position.name_from_value(chat_packet.position), |
|
|
|
|
|
|
|
message=chat_packet.json_data) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def handle_health_update(self, health_update_packet): |
|
|
|
|
|
|
|
if health_update_packet.health <= 0: |
|
|
|
|
|
|
|
self.logger.debug("Respawned the player because it died") |
|
|
|
|
|
|
|
packet = serverbound.play.ClientStatusPacket() |
|
|
|
|
|
|
|
packet.action_id = serverbound.play.ClientStatusPacket.RESPAWN |
|
|
|
|
|
|
|
self.connection.write_packet(packet) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(): |
|
|
|
|
|
|
|
bridge = MinecraftDiscordBridge() |
|
|
|
|
|
|
|
bridge.run() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
|
if __name__ == "__main__": |
|
|
|