You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
minecraft-discord-bridge/webhook-bridge.py

348 lines
13 KiB

#!/usr/bin/env python
from __future__ import print_function
import getpass
import sys
import re
import requests
import json
import time
import logging
import random
import string
from threading import Thread
from optparse import OptionParser
from config import Configuration
from database import DiscordChannel, AccountLinkToken, MinecraftAccount, DiscordAccount
import database_session
from minecraft import authentication
from minecraft.exceptions import YggdrasilError
from minecraft.networking.connection import Connection
from minecraft.networking.packets import Packet, clientbound, serverbound
from minecraft.compat import input
import discord
import asyncio
from mcstatus import MinecraftServer
from bidict import bidict
UUID_CACHE = bidict()
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:
logging.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:
try:
player_uuid = requests.get("https://api.mojang.com/users/profiles/minecraft/{}".format(username)).json()["id"]
UUID_CACHE[username] = player_uuid
return player_uuid
except:
logging.error("Failed to lookup {}'s UUID using the Mojang API.".format(username))
else:
return UUID_CACHE[username]
def setup_logging(level):
if level.lower() == "debug":
log_level = logging.DEBUG
else:
log_level = logging.INFO
log_format = "%(asctime)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
logging.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))
def main():
config = Configuration("config.json")
setup_logging(config.logging_level)
WEBHOOK_URL = config.webhook_url
database_session.initialize(config)
reactor_thread = Thread(target=run_auth_server, args=(config.auth_port,))
reactor_thread.start()
def handle_disconnect():
logging.info('Disconnected.')
connection.disconnect(immediate=True)
time.sleep(5)
while not is_server_online():
logging.info('Not reconnecting to server because it appears to be offline.')
time.sleep(5)
logging.info('Reconnecting.')
connection.connect()
def handle_disconnect_packet(join_game_packet):
handle_disconnect()
def minecraft_handle_exception(exception, exc_info):
logging.info("{}: {}".format(exception, 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:
# The server is offline
return False
logging.debug("Checking if the server {} is online before connecting.")
if not config.mc_online:
logging.info("Connecting in offline mode...")
if not is_server_online():
logging.info('Not connecting to server because it appears to be offline.')
sys.exit(1)
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:
logging.info(e)
sys.exit()
global BOT_USERNAME
BOT_USERNAME = auth_token.username
logging.info("Logged in as %s..." % auth_token.username)
if not is_server_online():
logging.info('Not connecting to server because it appears to be offline.')
sys.exit(1)
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)
def handle_tab_list(tab_list_packet):
logging.debug("Processing tab list packet")
for action in tab_list_packet.actions:
if isinstance(action, clientbound.play.PlayerListItemPacket.AddPlayerAction):
logging.debug("Processing AddPlayerAction tab list packet, name: {}, uuid: {}".format(action.name, action.uuid))
if action.name not in UUID_CACHE:
UUID_CACHE[action.name] = action.uuid
if isinstance(action, clientbound.play.PlayerListItemPacket.RemovePlayerAction):
logging.debug("Processing RemovePlayerAction tab list packet, uuid: {}".format(action.uuid))
for username in UUID_CACHE:
if UUID_CACHE[username] == action.uuid:
del UUID_CACHE[username]
break
def handle_join_game(join_game_packet):
logging.info('Connected.')
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 join/leave
regexp_match = re.match("^(.*) (joined|left) the game", chat_string, re.M|re.I)
if regexp_match:
logging.info("Username: {} Status: {} the game".format(regexp_match.group(1), regexp_match.group(2)))
username = regexp_match.group(1)
status = regexp_match.group(2)
player_uuid = mc_username_to_uuid(username)
if status == "joined":
webhook_payload = {'username': username, 'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid),
'content': '', 'embeds': [{'color': 65280, 'title': '**Joined the game**'}]}
elif status == "left":
webhook_payload = {'username': username, 'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid),
'content': '', 'embeds': [{'color': 16711680, 'title': '**Left the game**'}]}
else:
return
post = requests.post(WEBHOOK_URL,json=webhook_payload)
# Handle chat message
regexp_match = re.match("<(.*?)> (.*)", chat_string, re.M|re.I)
if regexp_match:
username = regexp_match.group(1)
message = regexp_match.group(2)
player_uuid = mc_username_to_uuid(username)
logging.info("Username: {} Message: {}".format(username, message))
webhook_payload = {'username': username, 'avatar_url': "https://visage.surgeplay.com/face/160/{}".format(player_uuid),
'embeds': [{'title': '{}'.format(message)}]}
post = requests.post(WEBHOOK_URL,json=webhook_payload)
def handle_health_update(health_update_packet):
if health_update_packet.health <= 0:
#We need to respawn!!!!
logging.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():
logging.info("Discord bot logged in as {} ({})".format(discord_bot.user.name, discord_bot.user.id))
@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
if isinstance(message.channel, discord.abc.PrivateChannel):
if message.content.startswith("mc!help"):
return
if message.content.startswith("mc!somethingelse"):
return
if message.content.startswith("mc!register"):
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()
await message.channel.send(msg)
return
else:
msg = "Unknown command, type `mc!help` for a list of commands."
await message.channel.send(msg)
return
if message.content.startswith("mc!chathere"):
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
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"):
session = database_session.get_session()
channels = session.query(DiscordChannel).all()
deleted = session.query(DiscordChannel).filter_by(channel_id=this_channel).delete()
session.commit()
session.close()
if deleted < 1:
msg = "The bot was not chatting here!"
await message.channel.send(msg)
return
else:
msg = "The bot will no longer here!"
await message.channel.send(msg)
return
elif not message.author.bot:
await message.delete()
packet = serverbound.play.ChatPacket()
packet.message = "{}: {}".format(message.author.name, message.content)
connection.write_packet(packet)
discord_bot.run(config.discord_token)
while True:
try:
text = input()
if text == "/respawn":
logging.info("respawning...")
packet = serverbound.play.ClientStatusPacket()
packet.action_id = serverbound.play.ClientStatusPacket.RESPAWN
connection.write_packet(packet)
else:
packet = serverbound.play.ChatPacket()
packet.message = text
connection.write_packet(packet)
except KeyboardInterrupt:
logging.info("Bye!")
sys.exit()
if __name__ == "__main__":
main()