diff --git a/docs/framework_events.rst b/docs/framework_events.rst new file mode 100644 index 000000000..6a1e3c1b2 --- /dev/null +++ b/docs/framework_events.rst @@ -0,0 +1,12 @@ +.. framework events list + +============= +Custom Events +============= + +RPC Server +^^^^^^^^^^ + +.. py:method:: Red.on_shutdown() + + Dispatched when the bot begins it's shutdown procedures. diff --git a/redbot/__main__.py b/redbot/__main__.py index 22d247d08..cf8ae7824 100644 --- a/redbot/__main__.py +++ b/redbot/__main__.py @@ -21,6 +21,7 @@ from redbot.core.sentry_setup import init_sentry_logging from redbot.core.cli import interactive_config, confirm, parse_cli_flags, ask_sentry from redbot.core.core_commands import Core from redbot.core.dev_commands import Dev +from redbot.core import rpc import asyncio import logging.handlers import logging @@ -96,7 +97,7 @@ def main(): red = Red(cli_flags, description=description, pm_help=None) init_global_checks(red) init_events(red, cli_flags) - red.add_cog(Core()) + red.add_cog(Core(red)) red.add_cog(CogManagerUI()) if cli_flags.dev: red.add_cog(Dev()) @@ -141,6 +142,7 @@ def main(): sentry_log.critical("Fatal Exception", exc_info=e) loop.run_until_complete(red.logout()) finally: + rpc.clean_up() if cleanup_tasks: pending = asyncio.Task.all_tasks(loop=red.loop) gathered = asyncio.gather(*pending, loop=red.loop) diff --git a/redbot/core/__init__.py b/redbot/core/__init__.py index 663ac1939..4a17d18e2 100644 --- a/redbot/core/__init__.py +++ b/redbot/core/__init__.py @@ -6,6 +6,6 @@ from .context import RedContext __all__ = ["Config", "RedContext", "__version__"] try: - __version__ = pkg_resources.require("Red-DiscordBot")[0].version + __version__ = pkg_resources.get_distribution("Red-DiscordBot").version except pkg_resources.DistributionNotFound: __version__ = "3.0.0" diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 9ce51d43d..b560ce821 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -10,10 +10,29 @@ from discord.ext.commands.bot import BotBase from discord.ext.commands import GroupMixin from .cog_manager import CogManager -from . import Config, i18n, RedContext +from . import ( + Config, + i18n, + RedContext, + rpc +) + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from aiohttp_json_rpc import JsonRpc -class RedBase(BotBase): +# noinspection PyUnresolvedReferences +class RpcMethodMixin: + async def rpc__cogs(self, request): + return list(self.cogs.keys()) + + async def rpc__extensions(self, request): + return list(self.extensions.keys()) + + +class RedBase(BotBase, RpcMethodMixin): """Mixin for the main bot class. This exists because `Red` inherits from `discord.AutoShardedClient`, which @@ -26,6 +45,7 @@ class RedBase(BotBase): self._shutdown_mode = ExitCodes.CRITICAL self.db = Config.get_core_conf(force_registration=True) self._co_owners = cli_flags.co_owner + self.rpc_enabled = cli_flags.rpc self.db.register_global( token=None, @@ -73,6 +93,8 @@ class RedBase(BotBase): self.cog_mgr = CogManager(paths=(str(self.main_dir / 'cogs'),)) + self.register_rpc_methods() + super().__init__(**kwargs) async def _dict_abuse(self, indict): @@ -194,6 +216,10 @@ class RedBase(BotBase): del self.extensions[name] # del sys.modules[name] + def register_rpc_methods(self): + rpc.add_method('bot', self.rpc__cogs) + rpc.add_method('bot', self.rpc__extensions) + class Red(RedBase, discord.AutoShardedClient): """ diff --git a/redbot/core/cli.py b/redbot/core/cli.py index 9a9081623..e2db950b3 100644 --- a/redbot/core/cli.py +++ b/redbot/core/cli.py @@ -102,6 +102,10 @@ def parse_cli_flags(args): parser.add_argument("--dev", action="store_true", help="Enables developer mode") + parser.add_argument("--rpc", + action="store_true", + help="Enables the built-in RPC server. Please read the docs" + "prior to enabling this!") parser.add_argument("instance_name", help="Name of the bot instance created during `redbot-setup`.") diff --git a/redbot/core/core_commands.py b/redbot/core/core_commands.py index 496c3b92b..ac1318179 100644 --- a/redbot/core/core_commands.py +++ b/redbot/core/core_commands.py @@ -13,6 +13,12 @@ from discord.ext import commands from redbot.core import checks from redbot.core import i18n +from redbot.core import rpc + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from redbot.core.bot import Red __all__ = ["Core"] @@ -29,6 +35,12 @@ _ = i18n.CogI18n("Core", __file__) class Core: """Commands related to core functions""" + def __init__(self, bot): + self.bot = bot # type: Red + + rpc.add_method('core', self.rpc_load) + rpc.add_method('core', self.rpc_unload) + rpc.add_method('core', self.rpc_reload) @commands.command() @checks.is_owner() @@ -410,3 +422,25 @@ class Core: "to %s") % destination) else: await ctx.send(_("Message delivered to %s") % destination) + + # RPC handlers + async def rpc_load(self, request): + cog_name = request.params[0] + + spec = await self.bot.cog_mgr.find_cog(cog_name) + if spec is None: + raise LookupError("No such cog found.") + + self.cleanup_and_refresh_modules(spec.name) + + self.bot.load_extension(spec) + + async def rpc_unload(self, request): + cog_name = request.params[0] + + self.bot.unload_extension(cog_name) + + async def rpc_reload(self, request): + await self.rpc_unload(request) + await self.rpc_load(request) + diff --git a/redbot/core/events.py b/redbot/core/events.py index 5448c9097..003ca4a7d 100644 --- a/redbot/core/events.py +++ b/redbot/core/events.py @@ -9,9 +9,11 @@ import discord from .sentry_setup import should_log from discord.ext import commands +from . import __version__ from .data_manager import storage_type from .utils.chat_formatting import inline, bordered from colorama import Fore, Style +from .rpc import initialize log = logging.getLogger("red") sentry_log = logging.getLogger("red.sentry") @@ -70,12 +72,13 @@ def init_events(bot, cli_flags): prefixes = await bot.db.prefix() lang = await bot.db.locale() - red_pkg = pkg_resources.get_distribution('Red_DiscordBot') - dpy_version = pkg_resources.get_distribution('discord.py').version + red_version = __version__ + red_pkg = pkg_resources.get_distribution("Red-DiscordBot") + dpy_version = discord.__version__ INFO = [str(bot.user), "Prefixes: {}".format(', '.join(prefixes)), 'Language: {}'.format(lang), - "Red Bot Version: {}".format(red_pkg.version), + "Red Bot Version: {}".format(red_version), "Discord.py Version: {}".format(dpy_version), "Shards: {}".format(bot.shard_count)] @@ -125,6 +128,9 @@ def init_events(bot, cli_flags): if invite_url: print("\nInvite URL: {}\n".format(invite_url)) + if bot.rpc_enabled: + await initialize(bot) + @bot.event async def on_command_error(ctx, error): if isinstance(error, commands.MissingRequiredArgument): diff --git a/redbot/core/rpc.py b/redbot/core/rpc.py new file mode 100644 index 000000000..0fc0fdb6e --- /dev/null +++ b/redbot/core/rpc.py @@ -0,0 +1,82 @@ +from typing import NewType, TYPE_CHECKING + +import asyncio + +from aiohttp.web import Application +from aiohttp_json_rpc import JsonRpc + +import logging + +if TYPE_CHECKING: + from .bot import Red + +log = logging.getLogger('red.rpc') +JsonSerializable = NewType('JsonSerializable', dict) + +_rpc = JsonRpc(logger=log) + +_rpc_server = None # type: asyncio.AbstractServer + + +async def initialize(bot: "Red"): + global _rpc_server + + app = Application(loop=bot.loop) + app.router.add_route('*', '/rpc', _rpc) + + handler = app.make_handler() + + _rpc_server = await bot.loop.create_server(handler, '127.0.0.1', 6133) + + log.debug('Created RPC _rpc_server listener.') + + +def add_topic(topic_name: str): + """ + Adds a topic for clients to listen to. + + :param topic_name: + """ + _rpc.add_topics(topic_name) + + +def notify(topic_name: str, data: JsonSerializable): + """ + Publishes a notification for the given topic name to all listening clients. + + data MUST be json serializable. + + note:: + + This method will fail silently. + + :param topic_name: + :param data: + """ + _rpc.notify(topic_name, data) + + +def add_method(prefix, method): + """ + Makes a method available to RPC clients. The name given to clients will be as + follows:: + + "{}__{}".format(prefix, method.__name__) + + note:: + + This method will fail silently. + + :param prefix: + :param method: + MUST BE A COROUTINE OR OBJECT. + :return: + """ + _rpc.add_methods( + ('', method), + prefix=prefix + ) + + +def clean_up(): + _rpc_server.close() diff --git a/requirements.txt b/requirements.txt index 9891ed898..89d0beede 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ appdirs youtube_dl raven -colorama \ No newline at end of file +colorama +aiohttp-json-rpc