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

326 lines
14 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
UUID_CACHE = {}
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, AuthProtocol
# 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(join_game_packet):
logging.info('Disconnected.')
connection.disconnect(immediate=True)
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 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=handle_disconnect)
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=handle_disconnect)
#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, 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)
if username not in UUID_CACHE:
# Shouldn't happen anymore since the tab list packet sends us uuids
logging.debug("Got chat message from player {}, their UUID was not cached so it is being looked up via the Mojang API.".format(username))
try:
player_uuid = requests.get("https://api.mojang.com/users/profiles/minecraft/{}".format(username)).json()["id"]
UUID_CACHE[username] = player_uuid
except:
logging.error("Failed to lookup {}'s UUID using the Mojang API.")
return
else:
logging.debug("Got chat message from player {}, not looking up their UUID because it is already cached as {}.".format(username, UUID_CACHE[username]))
player_uuid = UUID_CACHE[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)
if username not in UUID_CACHE:
# Shouldn't happen anymore since the tab list packet sends us uuids
logging.debug("Got chat message from player {}, their UUID was not cached so it is being looked up via the Mojang API.".format(username))
try:
player_uuid = requests.get("https://api.mojang.com/users/profiles/minecraft/{}".format(username)).json()["id"]
UUID_CACHE[username] = player_uuid
except:
logging.error("Failed to lookup {}'s UUID using the Mojang API.")
return
else:
logging.debug("Got chat message from player {}, not looking up their UUID because it is already cached as {}.".format(username, UUID_CACHE[username]))
player_uuid = UUID_CACHE[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 message.channel.is_private:
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)
await discord_bot.send_message(message.channel, msg)
session.close()
return
else:
msg = "Unknown command, type `mc!help` for a list of commands."
await discord_bot.send_message(message.channel, msg)
return
if message.content.startswith("mc!chathere"):
session = database_session.get_session()
channels = session.query(DiscordChannel).filter_by(channel_id=this_channel).all()
logging.info(channels)
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 discord_bot.send_message(message.channel, msg)
else:
msg = "The bot is already chatting in this channel! To stop this, run `mc!stopchathere`."
await discord_bot.send_message(message.channel, 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()
logging.info(deleted)
if deleted < 1:
msg = "The bot was not chatting here!"
await discord_bot.send_message(message.channel, msg)
return
else:
msg = "The bot will no longer here!"
await discord_bot.send_message(message.channel, msg)
return
elif not message.author.bot:
await discord_bot.delete_message(message)
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()