From c81353548996b1712aa797b823049662e9b02d48 Mon Sep 17 00:00:00 2001 From: Etzelia Date: Fri, 30 Aug 2019 20:03:12 +0200 Subject: [PATCH] Internalize Discord Bot (#31) --- api/api.py | 31 +++----- api/views.py | 6 +- assets/bots/Discord-MCM.bot.py | 21 ----- assets/migrations/001-whitelist.sql | 55 ------------- bot/__init__.py | 38 +++++++++ bot/discord.py | 107 +++++++++++++++++++++++--- docs/source/django-settings.rst | 22 +++--- external/views.py | 4 +- templates/minecraft_manager/bots.html | 12 ++- utils.py | 6 +- views.py | 51 ++++-------- 11 files changed, 184 insertions(+), 169 deletions(-) delete mode 100644 assets/bots/Discord-MCM.bot.py delete mode 100644 assets/migrations/001-whitelist.sql diff --git a/api/api.py b/api/api.py index fc04bfb..98ea7f2 100644 --- a/api/api.py +++ b/api/api.py @@ -1,7 +1,8 @@ -import socket, requests, logging, os, datetime, pytz, mcstatus, random, string +import socket, logging, os, datetime, pytz, mcstatus, random, string from minecraft_manager.models import Alert from django.contrib.auth.models import User from django.conf import settings +from minecraft_manager.bot.discord import send as discord_send, DestType logger = logging.getLogger(__name__) @@ -37,36 +38,24 @@ def plugin(key, command): return False -def discord_mcm(message='', embeds=None, ping=False): - discord_mcm_webhook = getattr(settings, 'DISCORD_MCM_WEBHOOK', None) - if discord_mcm_webhook: +def discord_mcm(message='', embed=None, ping=False): + channel_id = getattr(settings, 'DISCORD_MCM_CHANNEL', None) + if channel_id: ping_list = getattr(settings, 'DISCORD_PING_LIST', []) if ping and ping_list: ping_list = ["<@&{0}>".format(ping) for ping in ping_list] message = "{0}\n{1}".format(" ".join(ping_list), message) - data = {} - if message: - data['content'] = message - if embeds: - data['embeds'] = embeds - return requests.post(discord_mcm_webhook, json=data) - return None + discord_send(DestType.CHANNEL, channel_id, message, embed) -def discord_notification(message='', embeds=None, ping=False): - discord_notification_webhook = getattr(settings, 'DISCORD_NOTIFICATION_WEBHOOK', None) - if discord_notification_webhook: +def discord_notification(message='', embed=None, ping=False): + channel_id = getattr(settings, 'DISCORD_NOTIFICATION_CHANNEL', None) + if channel_id: ping_list = getattr(settings, 'DISCORD_PING_LIST', []) if ping and ping_list: ping_list = ["<@&{0}>".format(ping) for ping in ping_list] message = "{0}\n{1}".format(" ".join(ping_list), message) - data = {} - if message: - data['content'] = message - if embeds: - data['embeds'] = embeds - return requests.post(discord_notification_webhook, json=data) - return None + discord_send(DestType.CHANNEL, channel_id, message, embed) diff --git a/api/views.py b/api/views.py index 675452e..47cd31f 100644 --- a/api/views.py +++ b/api/views.py @@ -192,7 +192,7 @@ class PluginAPI(View): json['message'] = "{0}'s application was submitted.".format(application.username) json['extra'] = application.id msg = mcm_utils.build_application(application) - mcm_api.discord_mcm(message='New Application!', embeds=msg) + mcm_api.discord_mcm(message='New Application!', embed=msg) elif "application_action" == keyword: if Application.objects.filter(id=post['application_id']).exists(): application = Application.objects.get(id=post['application_id']) @@ -298,7 +298,7 @@ class PluginAPI(View): link = "{}".format(mcm_utils.url_path(settings.MCM_BASE_LINK, 'dashboard/ticket', ticket.id)) msg = mcm_utils.build_ticket(ticket, link) json['extra'] = {'id': ticket.id, 'link': link} - mcm_api.discord_mcm(embeds=msg, ping=True) + mcm_api.discord_mcm(embed=msg, ping=True) except: json['status'] = False json['message'] = "Error while submitting ticket." @@ -311,7 +311,7 @@ class PluginAPI(View): json['message'] = "Warning issued." link = "{}".format(mcm_utils.url_path(settings.MCM_BASE_LINK, 'dashboard/note', warning.id)) msg = mcm_utils.build_warning(warning, link) - mcm_api.discord_mcm(embeds=msg) + mcm_api.discord_mcm(embed=msg) except Exception as ex: json['status'] = False json['message'] = "Error while issuing warning." diff --git a/assets/bots/Discord-MCM.bot.py b/assets/bots/Discord-MCM.bot.py deleted file mode 100644 index 092a99e..0000000 --- a/assets/bots/Discord-MCM.bot.py +++ /dev/null @@ -1,21 +0,0 @@ -import os -import sys -import django - -sep = os.sep -path = os.path.dirname(os.path.abspath(__file__)) -path = path.split(sep)[:-3] -project = path[-1] -path = sep.join(path) -sys.path.append(path) -print("Setting path for {0}: {1}".format(project, path)) -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "{}.settings".format(project)) -django.setup() - -from django.conf import settings -from minecraft_manager.bot.discord import Discord - -token = getattr(settings, 'DISCORD_BOT_TOKEN', None) -bot = Discord(token) - -bot.run_bot() diff --git a/assets/migrations/001-whitelist.sql b/assets/migrations/001-whitelist.sql deleted file mode 100644 index 268b167..0000000 --- a/assets/migrations/001-whitelist.sql +++ /dev/null @@ -1,55 +0,0 @@ --- Alerts -insert into minecraft_manager_alert (select * from whitelist_alert); - --- Applications -insert into minecraft_manager_application (select * from whitelist_application); - --- Players -insert into minecraft_manager_player (uuid, username, application_id, auth_user_id, last_seen, first_seen) -select wp.uuid, wp.username, ( -select mma.id from minecraft_manager_application mma where mma.username = ( -select wa.username from whitelist_application wa where wp.application_id = wa.id -) -), wp.auth_user_id, wp.last_seen, wp.first_seen from whitelist_player wp -; - --- Tickets -insert into minecraft_manager_ticket (message, priority, resolved, world, x, y, z, date, player_id, staff_id) -select wt.message, wt.priority, wt.resolved, wt.world, wt.x, wt.y, wt.z, wt.date, ( -select mmp.id from minecraft_manager_player mmp where mmp.uuid = ( -select wp.uuid from whitelist_player wp where wp.id = wt.player_id -) -), ( -select au.id from auth_user au where au.username = ( -select wp2.username from whitelist_player wp2 where wp2.id = wt.staff_id -) -) from whitelist_ticket wt -; - --- Warnings -insert into minecraft_manager_warning (message, severity, date, player_id, staff_id) -select ww.message, ww.severity, ww.date, ( -select mmp.id from minecraft_manager_player mmp where mmp.uuid = ( -select wp.uuid from whitelist_player wp where wp.id = ww.player_id -) -), ( -select au.id from auth_user au where au.username = ( -select wp2.username from whitelist_player wp2 where wp2.id = ww.staff_id -) -) from whitelist_warning ww -; - --- User Settings -insert into minecraft_manager_usersettings (default_results, default_theme, default_timezone, search_player_ip, show_timestamp_chat, last_ip, auth_user_id) -select default_results, default_theme, default_timezone, search_player_ip, show_timestamp_chat, last_ip, auth_user_id from whitelist_usersettings wu -; - --- Notes (This migration ONLY works if you are using standard whitelist app, aka only Tickets had notes) --- The ignore is because there were some incorrectly encoded characters giving MySQL a hard time -insert ignore into minecraft_manager_note (ref_table, ref_id, message, last_update, author_id) -select wn.ref_table, ( -select mmt.id from minecraft_manager_ticket mmt where mmt.message = ( -select wt.message from whitelist_ticket wt where wt.id = wn.ref_id -) -), wn.message, wn.last_update, wn.author_id from whitelist_note wn where (select count(*) from whitelist_ticket wt2 where wt2.id = wn.ref_id) > 0 -; \ No newline at end of file diff --git a/bot/__init__.py b/bot/__init__.py index e69de29..9e52327 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -0,0 +1,38 @@ +from django.conf import settings +import subprocess + + +class Bot: + plugin_port = getattr(settings, 'PLUGIN_PORT', None) + bot_dir = getattr(settings, 'BOT_DIR', "") + if not bot_dir.endswith("/"): + bot_dir += "/" + + def __init__(self, name, asset, executable=None, start=None, stop=None, restart=None, status=None, display=None): + self.name = name + self.asset = asset + self.executable = executable + self.start = start if start else self._start + self.stop = stop if stop else self._stop + self.restart = restart if restart else self._restart + self.status = status if status else self._status + self.display = display if display else self._display + + def _start(self): + screen = 'screen -S {0}_{1} -d -m {2} {3}{1}.bot.py' + subprocess.run(screen.format(self.plugin_port, self.name, self.executable, self.bot_dir), + shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + def _stop(self): + subprocess.run('screen -X -S "{0}_{1}" quit'.format(self.plugin_port, self.name), shell=True) + + def _restart(self): + self.stop() + self.start() + + def _status(self): + screens = subprocess.getoutput("screen -ls") + return True if "{0}_{1}".format(self.plugin_port, self.name) in screens else False + + def _display(self): + return "Started" if self.status() else "Stopped" diff --git a/bot/discord.py b/bot/discord.py index 89f7ae5..c2a8c0b 100644 --- a/bot/discord.py +++ b/bot/discord.py @@ -1,21 +1,38 @@ import asyncio import logging -import sys import traceback +import threading +from enum import Enum import discord from discord.ext import commands from django.conf import settings -from minecraft_manager.bot.commands import Commands - logger = logging.getLogger(__name__) +discord_loop = None +discord_bot = None description = ''' A Discord bot connected to an MCM instance. ''' +class DiscordStatus(Enum): + STOPPED = 0 + STARTING = 1 + STARTED = 2 + STOPPING = 3 + + def __str__(self): + return self.name.title() + + def is_running(self): + return self != DiscordStatus.STOPPED + + +discord_status = DiscordStatus.STOPPED + + class Discord(commands.Bot): discord_game = 'MCM' prefix = getattr(settings, 'DISCORD_BOT_PREFIX', '!') @@ -23,12 +40,14 @@ class Discord(commands.Bot): superuser_roles = getattr(settings, 'DISCORD_SUPERUSER_ROLES', []) error_users = getattr(settings, 'DISCORD_ERROR_USERS', []) - def __init__(self, token): - super().__init__(command_prefix=self.prefix, description=description, case_insensitive=True, help_command=None, activity=discord.Game(name=self.discord_game)) + def __init__(self, token, loop): + super().__init__(command_prefix=self.prefix, description=description, case_insensitive=True, help_command=None, activity=discord.Game(name=self.discord_game), loop=loop) self.token = token self.load_extension("minecraft_manager.bot.commands") async def on_ready(self): + global discord_status + discord_status = DiscordStatus.STARTED print('Logged in as') print(self.user.name) print(self.user.id) @@ -38,6 +57,10 @@ class Discord(commands.Bot): print('------') print('Logged in as {0} ({1}) with discord.py v{2}'.format(self.user.name, self.user.id, discord.__version__)) + async def on_disconnect(self): + global discord_status + discord_status = DiscordStatus.STOPPED + async def discord_message(self, dest, message): if isinstance(message, discord.Embed): for idx, field in enumerate(message.fields): @@ -61,15 +84,77 @@ class Discord(commands.Bot): await self.discord_message(user, '```python\n{}```'.format(error)) def run_bot(self): - loop = asyncio.get_event_loop() - + global discord_loop try: - loop.run_until_complete(self.start(self.token)) + discord_loop.run_until_complete(self.start(self.token)) except KeyboardInterrupt: logger.info("Bot received keyboard interrupt") except Exception as e: print(e) logger.info('Bot encountered the following unhandled exception %s', e) - finally: - loop.run_until_complete(self.logout()) - logger.info("Bot shutting down...") + + +def start(): + global discord_loop, discord_bot, discord_status + if discord_status != DiscordStatus.STOPPED: + return + token = getattr(settings, 'DISCORD_BOT_TOKEN', None) + discord_loop = asyncio.new_event_loop() + discord_bot = Discord(token, discord_loop) + thread = threading.Thread(target=discord_bot.run_bot) + thread.start() + discord_status = DiscordStatus.STARTING + + +def stop(): + global discord_loop, discord_bot, discord_status + if discord_status == DiscordStatus.STARTED: + discord_loop.create_task(discord_bot.close()) + discord_status = DiscordStatus.STOPPING + discord_loop = None + discord_bot = None + + +def restart(): + def _restart(): + stop() + while discord_status.is_running(): + pass + start() + if discord_status != DiscordStatus.STARTED: + return + thread = threading.Thread(target=_restart) + thread.start() + + +def status(): + return discord_status.is_running() + + +def display(): + return str(discord_status) + + +class DestType(Enum): + CHANNEL = 1 + USER = 2 + + +def send(dest_type: DestType, dest_id: int, message: str = "", embed: discord.Embed = None): + async def _send(): + if dest_type == DestType.CHANNEL: + dest = discord_bot.get_channel(dest_id) + elif dest_type == DestType.USER: + dest = discord_bot.get_user(dest_id) + else: + return + if message is not None: + await dest.send(message) + if embed is not None: + for idx, field in enumerate(embed.fields): + if not field.value: + embed.set_field_at(idx, name=field.name, value="N/A") + await dest.send(embed=embed) + global discord_loop, discord_bot + if discord_loop: + discord_loop.create_task(_send()) diff --git a/docs/source/django-settings.rst b/docs/source/django-settings.rst index a7ef2fb..5ef0bea 100644 --- a/docs/source/django-settings.rst +++ b/docs/source/django-settings.rst @@ -23,11 +23,21 @@ Optional ``BOT_DIR`` - The path to your bot directory. -``DISCORD_NOTIFICATION_WEBHOOK`` - The URL for the webhook used for notifications. +``DISCORD_BOT_TOKEN`` - The token to use to run the Discord bot. This must be generated by you in the Discord developer area. ``DISCORD_PING_LIST`` - A list of Discord Role IDs to ping whenever certain messages are sent. -``DISCORD_MCM_WEBHOOK`` - The URL for the webhook used for Applications, Tickets, and Warnings. +``DISCORD_BOT_PREFIX`` - The prefix to use for Discord bot commands. Set to ``!`` by default. + +``DISCORD_BOT_ROLES`` - A list of Discord Roles allowed to use the bot. If this list is empty, no one can use the bot! + +``DISCORD_SUPERUSER_ROLES`` - A list of Discord Roles allowed to use the superuser commands. + +``DISCORD_ERROR_USERS`` - A list of user IDs to send errors to. + +``DISCORD_MCM_CHANNEL`` - The ID for the channel used for Applications, Tickets, and Warnings. + +``DISCORD_NOTIFICATION_CHANNEL`` - The ID for the channel used for notifications. ``DISCORD_INVITE`` - The invite code to your Discord, for after a player applies on the web form. @@ -51,14 +61,6 @@ Optional ``COREPROTECT_ACTIVITY_URL`` - The URL to your CoreProtect Activity Web UI, if it exists. -``DISCORD_BOT_TOKEN`` - The token to use to run the Discord bot. This must be generated by you in the Discord developer area. - -``DISCORD_BOT_PREFIX`` - The prefix to use for Discord bot commands. Set to ``!`` by default. - -``DISCORD_BOT_ROLES`` - A list of Discord Roles allowed to use the bot. If this list is empty, no one can use the bot! - -``DISCORD_BOT_NEW_MEMBER_ROLES`` - A list of Discord Roles to give new players when they register. - ``CAPTCHA_SECRET`` - Your secret key used for reCAPTCHA ``STATS_FILTER`` - A python list of partial strings used to filter out stats. e.g. ``['broken', 'dropped', 'picked_up']`` to filter out broken, dropped and picked up stats \ No newline at end of file diff --git a/external/views.py b/external/views.py index e3d58ee..d6d41e1 100644 --- a/external/views.py +++ b/external/views.py @@ -70,7 +70,7 @@ class Apply(View): if valid and valid_username and captcha.success: app = form.save() msg = mcm_utils.build_application(app) - mcm_api.discord_mcm(message='New Application!', embeds=msg) + mcm_api.discord_mcm(message='New Application!', embed=msg) mcm_api.plugin("application", "{0} {1}".format(form.data['username'], app.id)) else: for error in captcha.errors: @@ -109,7 +109,7 @@ class Ticket(View): # Create the message to send to Discord link = "{}".format(mcm_utils.url_path(settings.MCM_BASE_LINK, 'dashboard/ticket', ticket.id)) msg = mcm_utils.build_ticket(ticket, link) - mcm_api.discord_mcm(message="New Ticket", embeds=msg, ping=True) + mcm_api.discord_mcm(message="New Ticket", embed=msg, ping=True) mcm_api.plugin("ticket", "{0} {1} {2}".format(username, ticket.id, link)) else: for error in captcha.errors: diff --git a/templates/minecraft_manager/bots.html b/templates/minecraft_manager/bots.html index 6460c81..d0ffd81 100644 --- a/templates/minecraft_manager/bots.html +++ b/templates/minecraft_manager/bots.html @@ -1,16 +1,14 @@ {% extends "minecraft_manager/dashboard.html" %} {% load csrf_html %} -{% load getattribute %} {% block title %}Bots{% endblock %} {% block section %}
- - {% for bot in form.bots %} -

{{ bot.name }}: {% if form|getattribute:bot.name is True %}Started{% else %}Stopped{% endif %}

+ {% for bot in bots %} +

{{ bot.name }}: {{ bot.display }}

{% autoescape off %}{% get_csrf_html request %}{% endautoescape %} - - - + + +

{% endfor %} diff --git a/utils.py b/utils.py index 71b357a..7c94daa 100644 --- a/utils.py +++ b/utils.py @@ -38,7 +38,7 @@ def build_application(application): embed.add_field(name="Read the Rules", value=application.read_rules) embed.add_field(name="Date", value=application.date_display) embed.add_field(name="Status", value=application.status) - return [embed.to_dict()] + return embed def build_ticket(ticket, link): @@ -53,7 +53,7 @@ def build_ticket(ticket, link): embed.add_field(name="Location", value=ticket.location) embed.add_field(name="Message", value=ticket.message) embed.add_field(name="Link", value=link) - return [embed.to_dict()] + return embed def build_warning(warning, link): @@ -66,7 +66,7 @@ def build_warning(warning, link): embed.add_field(name="Importance", value=warning.importance_display) embed.add_field(name="Message", value=warning.message) embed.add_field(name="Link", value=link) - return [embed.to_dict()] + return embed def validate_username(username): diff --git a/views.py b/views.py index 0be02c5..3e02034 100644 --- a/views.py +++ b/views.py @@ -17,8 +17,9 @@ from minecraft_manager.forms import TicketNoteForm, NoteForm from minecraft_manager.overview import overview_data from minecraft_manager.utils import resolve_player import minecraft_manager.api.api as API - -import subprocess +from minecraft_manager.bot import Bot +from minecraft_manager.bot.discord import start as discord_start, stop as discord_stop, restart as discord_restart, \ + status as discord_status, display as discord_display class Overview(View): @@ -452,11 +453,6 @@ class Chat(View): class Bots(View): - def assets(self): - path = os.path.abspath(os.path.dirname(__file__)) - bot_dir = os.path.join(path, 'assets/bots') - return bot_dir - def get_bots(self): bot_dir = getattr(settings, 'BOT_DIR', None) bots = [] @@ -466,41 +462,24 @@ class Bots(View): ve = file.replace('.bot.py', '') py = os.path.join(bot_dir, ve, 'bin/python') if os.path.isfile(py): - bots.append({'name': file.replace('.bot.py', ''), 'asset': False, 'executable': py}) + bots.append(Bot(file.replace('.bot.py', ''), False, py)) else: - bots.append({'name': file.replace('.bot.py', ''), 'asset': False, 'executable': sys.executable}) + bots.append(Bot(file.replace('.bot.py', ''), False, sys.executable)) # Also get packaged MCM bots - for file in os.listdir(self.assets()): - if file.endswith('.bot.py'): - bots.append({'name': file.replace('.bot.py', ''), 'asset': True, 'executable': sys.executable}) + bots.append(Bot("Discord-MCM", True, None, discord_start, discord_stop, discord_restart, discord_status, discord_display)) return bots - def get_form(self): - bots = self.get_bots() - plugin_port = getattr(settings, 'PLUGIN_PORT', None) - screens = subprocess.getoutput("screen -ls") - form = {'screens': screens, 'bots': bots} - for bot in bots: - form[bot['name']] = True if "{0}_{1}".format(plugin_port, bot['name']) in screens else False - return form - def get(self, request): - return render(request, 'minecraft_manager/bots.html', {'current_app': 'bots', 'form': self.get_form()}) + return render(request, 'minecraft_manager/bots.html', {'current_app': 'bots', 'bots': self.get_bots()}) def post(self, request): post = request.POST - plugin_port = getattr(settings, 'PLUGIN_PORT', None) for bot in self.get_bots(): - if bot['name'] in post: - if post[bot['name']] == "stop": - subprocess.run('screen -X -S "{0}_{1}" quit'.format(plugin_port, bot['name']), shell=True) - elif post[bot['name']] in ('start', 'restart'): - subprocess.run('screen -X -S "{0}_{1}" quit'.format(plugin_port, bot['name']), shell=True) - path = self.assets() if bot['asset'] else getattr(settings, 'BOT_DIR', "") - if not path.endswith("/"): - path += "/" - print('screen -S {0}_{1} -d -m {2} {3}{1}.bot.py'.format(plugin_port, bot['name'], bot['executable'], path)) - subprocess.run( - 'screen -S {0}_{1} -d -m {2} {3}{1}.bot.py'.format(plugin_port, bot['name'], bot['executable'], path), - shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return render(request, 'minecraft_manager/bots.html', {'current_app': 'bots', 'form': self.get_form()}) + if bot.name in post: + if post[bot.name] == "stop": + bot.stop() + elif post[bot.name] == "start": + bot.start() + elif post[bot.name] == "restart": + bot.restart() + return render(request, 'minecraft_manager/bots.html', {'current_app': 'bots', 'bots': self.get_bots()})