[V3 RPC] Swap back to initial RPC library and hook into core commands (#1780)

* Switch RPC libs for websockets support

* Implement RPC handling for core

* Black reformat

* Fix docs for build on travis

* Modify RPC to use a Cog base class

* Refactor rpc server reference as global

* Handle cogbase unload method

* Add an init call to handle mutable base attributes

* Move RPC server reference back to the bot object

* Remove unused import

* Add tests for rpc method add/removal

* Add tests for rpc method add/removal and cog base unloading

* Add one more test

* Black reformat

* Add RPC mixin...fix MRO

* Correct internal rpc method names

* Add rpc test html file for debugging/example purposes

* Add documentation

* Add get_method_info

* Update docs with an example RPC call specifying parameter formatting

* Make rpc methods UPPER

* Black reformat

* Fix doc example

* Modify this to match new method naming convention

* Add more tests
This commit is contained in:
Will 2018-06-08 20:31:38 -04:00 committed by GitHub
parent 8b15053dd4
commit b983d5904b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 379 additions and 157 deletions

34
Pipfile.lock generated
View File

@ -32,6 +32,13 @@
], ],
"version": "==2.2.5" "version": "==2.2.5"
}, },
"aiohttp-json-rpc": {
"hashes": [
"sha256:9ec69ea70ce49c4af445f0ac56ac728708ccfad8b214272d2cc7e75bc0b31327",
"sha256:e2b8b49779d5d9b811f3a94e98092b1fa14af6d9adbf71c3afa6b20c641fa5d5"
],
"version": "==0.8.7"
},
"appdirs": { "appdirs": {
"hashes": [ "hashes": [
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
@ -76,13 +83,6 @@
"editable": true, "editable": true,
"path": "." "path": "."
}, },
"funcsigs": {
"hashes": [
"sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca",
"sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50"
],
"version": "==1.0.2"
},
"fuzzywuzzy": { "fuzzywuzzy": {
"hashes": [ "hashes": [
"sha256:d40c22d2744dff84885b30bbfc07fab7875f641d070374331777a4d1808b8d4e", "sha256:d40c22d2744dff84885b30bbfc07fab7875f641d070374331777a4d1808b8d4e",
@ -97,19 +97,6 @@
], ],
"version": "==2.6" "version": "==2.6"
}, },
"jsonrpcserver": {
"hashes": [
"sha256:ab8013cdee3f65d59c5d3f84c75be76a3492caa0b33ecaa3f0f69906cf3d9e92"
],
"version": "==3.5.4"
},
"jsonschema": {
"hashes": [
"sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08",
"sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02"
],
"version": "==2.6.0"
},
"multidict": { "multidict": {
"hashes": [ "hashes": [
"sha256:1a1d76374a1e7fe93acef96b354a03c1d7f83e7512e225a527d283da0d7ba5e0", "sha256:1a1d76374a1e7fe93acef96b354a03c1d7f83e7512e225a527d283da0d7ba5e0",
@ -166,13 +153,6 @@
], ],
"version": "==1.1.1" "version": "==1.1.1"
}, },
"six": {
"hashes": [
"sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
"sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
],
"version": "==1.11.0"
},
"websockets": { "websockets": {
"hashes": [ "hashes": [
"sha256:09dfec40e9b73e8808c39ecdbc1733e33915a2b26b90c54566afc0af546a9ec3", "sha256:09dfec40e9b73e8808c39ecdbc1733e33915a2b26b90c54566afc0af546a9ec3",

View File

@ -13,6 +13,9 @@ RedBase
:members: :members:
:exclude-members: get_context :exclude-members: get_context
.. automethod:: register_rpc_handler
.. automethod:: unregister_rpc_handler
Red Red
^^^ ^^^

View File

@ -4,36 +4,60 @@
RPC RPC
=== ===
.. currentmodule:: redbot.core.rpc
V3 comes default with an internal RPC server that may be used to remotely control the bot in various ways. V3 comes default with an internal RPC server that may be used to remotely control the bot in various ways.
Cogs must register functions to be exposed to RPC clients. Cogs must register functions to be exposed to RPC clients.
Each of those functions must only take JSON serializable parameters and must return JSON serializable objects. Each of those functions must only take JSON serializable parameters and must return JSON serializable objects.
To begin, register all methods using individual calls to the :func:`Methods.add` method. To enable the internal RPC server you must start the bot with the ``--rpc`` flag.
******** ********
Examples Examples
******** ********
Coming soon to a docs page near you! .. code-block:: Python
def setup(bot):
c = Cog()
bot.add_cog(c)
bot.register_rpc_handler(c.rpc_method)
*******************************
Interacting with the RPC Server
*******************************
The RPC server opens a websocket bound to port ``6133`` on ``127.0.0.1``.
This is not configurable for security reasons as broad access to this server gives anyone complete control over your bot.
To access the server you must find a library that implements websocket based JSONRPC in the language of your choice.
There are a few built-in RPC methods to note:
* ``GET_METHODS`` - Returns a list of available RPC methods.
* ``GET_METHOD_INFO`` - Will return the docstring for an available RPC method. Useful for finding information about the method's parameters and return values.
* ``GET_TOPIC`` - Returns a list of available RPC message topics.
* ``GET_SUBSCRIPTIONS`` - Returns a list of RPC subscriptions.
* ``SUBSCRIBE`` - Subscribes to an available RPC message topic.
* ``UNSUBSCRIBE`` - Unsubscribes from an RPC message topic.
All RPC methods accept a list of parameters.
The built-in methods above expect their parameters to be in list format.
All cog-based methods expect their parameter list to take one argument, a JSON object, in the following format::
params = [
{
"args": [], # A list of positional arguments
"kwargs": {}, # A dictionary of keyword arguments
}
]
# As an example, here's a call to "get_method_info"
rpc_call("GET_METHOD_INFO", ["get_methods",])
# And here's a call to "core__load"
rpc_call("CORE__LOAD", {"args": [["general", "economy", "downloader"],], "kwargs": {}})
************* *************
API Reference API Reference
************* *************
.. py:attribute:: redbot.core.rpc.methods Please see the :class:`redbot.core.bot.RedBase` class for details on the RPC handler register and unregister methods.
An instance of the :class:`Methods` class.
All attempts to register new RPC methods **MUST** use this object.
You should never create a new instance of the :class:`Methods` class!
RPC
^^^
.. autoclass:: redbot.core.rpc.RPC
:members:
Methods
^^^^^^^
.. autoclass:: redbot.core.rpc.Methods
:members:

View File

@ -13,8 +13,7 @@ from redbot.core.events import init_events
from redbot.core.cli import interactive_config, confirm, parse_cli_flags, ask_sentry from redbot.core.cli import interactive_config, confirm, parse_cli_flags, ask_sentry
from redbot.core.core_commands import Core from redbot.core.core_commands import Core
from redbot.core.dev_commands import Dev from redbot.core.dev_commands import Dev
from redbot.core import rpc, __version__ from redbot.core import __version__
import redbot.meta
import asyncio import asyncio
import logging.handlers import logging.handlers
import logging import logging
@ -112,7 +111,7 @@ def main():
sys.exit(1) sys.exit(1)
load_basic_configuration(cli_flags.instance_name) load_basic_configuration(cli_flags.instance_name)
log, sentry_log = init_loggers(cli_flags) log, sentry_log = init_loggers(cli_flags)
red = Red(cli_flags, description=description, pm_help=None) red = Red(cli_flags=cli_flags, description=description, pm_help=None)
init_global_checks(red) init_global_checks(red)
init_events(red, cli_flags) init_events(red, cli_flags)
red.add_cog(Core(red)) red.add_cog(Core(red))
@ -166,6 +165,10 @@ def main():
pending = asyncio.Task.all_tasks(loop=red.loop) pending = asyncio.Task.all_tasks(loop=red.loop)
gathered = asyncio.gather(*pending, loop=red.loop, return_exceptions=True) gathered = asyncio.gather(*pending, loop=red.loop, return_exceptions=True)
gathered.cancel() gathered.cancel()
try:
red.rpc.server.close()
except AttributeError:
pass
sys.exit(red._shutdown_mode.value) sys.exit(red._shutdown_mode.value)

View File

@ -18,12 +18,13 @@ from discord.voice_client import VoiceClient
VoiceClient.warn_nacl = False VoiceClient.warn_nacl = False
from .cog_manager import CogManager from .cog_manager import CogManager
from . import Config, i18n, commands, rpc from . import Config, i18n, commands
from .rpc import RPCMixin
from .help_formatter import Help, help as help_ from .help_formatter import Help, help as help_
from .sentry import SentryManager from .sentry import SentryManager
class RedBase(BotBase): class RedBase(BotBase, RPCMixin):
"""Mixin for the main bot class. """Mixin for the main bot class.
This exists because `Red` inherits from `discord.AutoShardedClient`, which This exists because `Red` inherits from `discord.AutoShardedClient`, which
@ -33,7 +34,7 @@ class RedBase(BotBase):
Selfbots should inherit from this mixin along with `discord.Client`. Selfbots should inherit from this mixin along with `discord.Client`.
""" """
def __init__(self, cli_flags, bot_dir: Path = Path.cwd(), **kwargs): def __init__(self, *args, cli_flags=None, bot_dir: Path = Path.cwd(), **kwargs):
self._shutdown_mode = ExitCodes.CRITICAL self._shutdown_mode = ExitCodes.CRITICAL
self.db = Config.get_core_conf(force_registration=True) self.db = Config.get_core_conf(force_registration=True)
self._co_owners = cli_flags.co_owner self._co_owners = cli_flags.co_owner
@ -107,10 +108,7 @@ class RedBase(BotBase):
self.cog_mgr = CogManager(paths=(str(self.main_dir / "cogs"),)) self.cog_mgr = CogManager(paths=(str(self.main_dir / "cogs"),))
super().__init__(formatter=Help(), **kwargs) super().__init__(*args, formatter=Help(), **kwargs)
if self.rpc_enabled:
self.rpc = rpc.RPC(self)
self.remove_command("help") self.remove_command("help")
@ -235,12 +233,24 @@ class RedBase(BotBase):
lib_name = lib.__name__ # Thank you lib_name = lib.__name__ # Thank you
# find all references to the module # find all references to the module
cog_names = []
# remove the cogs registered from the module # remove the cogs registered from the module
for cogname, cog in self.cogs.copy().items(): for cogname, cog in self.cogs.copy().items():
if cog.__module__.startswith(lib_name): if cog.__module__.startswith(lib_name):
self.remove_cog(cogname) self.remove_cog(cogname)
cog_names.append(cogname)
# remove all rpc handlers
for cogname in cog_names:
if cogname.upper() in self.rpc_handlers:
methods = self.rpc_handlers[cogname]
for meth in methods:
self.unregister_rpc_handler(meth)
del self.rpc_handlers[cogname]
# first remove all the commands from the module # first remove all the commands from the module
for cmd in self.all_commands.copy().values(): for cmd in self.all_commands.copy().values():
if cmd.module.startswith(lib_name): if cmd.module.startswith(lib_name):

View File

@ -46,6 +46,13 @@ _ = i18n.Translator("Core", __file__)
class CoreLogic: class CoreLogic:
def __init__(self, bot: "Red"): def __init__(self, bot: "Red"):
self.bot = bot self.bot = bot
self.bot.register_rpc_handler(self._load)
self.bot.register_rpc_handler(self._unload)
self.bot.register_rpc_handler(self._reload)
self.bot.register_rpc_handler(self._name)
self.bot.register_rpc_handler(self._prefixes)
self.bot.register_rpc_handler(self._version_info)
self.bot.register_rpc_handler(self._invite_url)
async def _load(self, cog_names: list): async def _load(self, cog_names: list):
""" """

View File

@ -18,6 +18,7 @@ from .data_manager import storage_type
from .utils.chat_formatting import inline, bordered, pagify, box from .utils.chat_formatting import inline, bordered, pagify, box
from .utils import fuzzy_command_search from .utils import fuzzy_command_search
from colorama import Fore, Style, init from colorama import Fore, Style, init
from . import rpc
log = logging.getLogger("red") log = logging.getLogger("red")
sentry_log = logging.getLogger("red.sentry") sentry_log = logging.getLogger("red.sentry")
@ -84,6 +85,9 @@ def init_events(bot, cli_flags):
if packages: if packages:
print("Loaded packages: " + ", ".join(packages)) print("Loaded packages: " + ", ".join(packages))
if bot.rpc_enabled:
await bot.rpc.initialize()
guilds = len(bot.guilds) guilds = len(bot.guilds)
users = len(set([m for m in bot.get_all_members()])) users = len(set([m for m in bot.get_all_members()]))
@ -172,8 +176,6 @@ def init_events(bot, cli_flags):
print("\nInvite URL: {}\n".format(invite_url)) print("\nInvite URL: {}\n".format(invite_url))
bot.color = discord.Colour(await bot.db.color()) bot.color = discord.Colour(await bot.db.color())
if bot.rpc_enabled:
await bot.rpc.initialize()
@bot.event @bot.event
async def on_error(event_method, *args, **kwargs): async def on_error(event_method, *args, **kwargs):

View File

@ -1,116 +1,74 @@
import weakref import asyncio
from aiohttp import web from aiohttp import web
import jsonrpcserver.aio from aiohttp_json_rpc import JsonRpc
from aiohttp_json_rpc.rpc import unpack_request_args
import inspect
import logging import logging
__all__ = ["methods", "RPC", "Methods"]
log = logging.getLogger("red.rpc") log = logging.getLogger("red.rpc")
__all__ = ["RPC", "RPCMixin", "get_name"]
class Methods(jsonrpcserver.aio.AsyncMethods):
"""
Container class for all registered RPC methods, please use the existing `methods`
attribute rather than creating a new instance of this class.
.. warning::
**NEVER** create a new instance of this class!
"""
def __init__(self):
super().__init__()
self._items = weakref.WeakValueDictionary()
def add(self, method, name: str = None):
"""
Registers a method to the internal RPC server making it available for
RPC users to call.
.. important::
Any method added here must take ONLY JSON serializable parameters and
MUST return a JSON serializable object.
Parameters
----------
method : function
A reference to the function to register.
name : str
Name of the function as seen by the RPC clients.
"""
if not inspect.iscoroutinefunction(method):
raise TypeError("Method must be a coroutine.")
if name is None:
name = method.__qualname__
self._items[str(name)] = method
def remove(self, *, name: str = None, method=None):
"""
Unregisters an RPC method. Either a name or reference to the method must
be provided and name will take priority.
Parameters
----------
name : str
method : function
"""
if name and name in self._items:
del self._items[name]
elif method and method in self._items.values():
to_remove = []
for name, val in self._items.items():
if method == val:
to_remove.append(name)
for name in to_remove:
del self._items[name]
def all_methods(self):
"""
Lists all available method names.
Returns
-------
list of str
"""
return self._items.keys()
methods = Methods() def get_name(func, prefix=None):
class_name = prefix or func.__self__.__class__.__name__.lower()
func_name = func.__name__.strip("_")
if class_name == "redrpc":
return func_name.upper()
return f"{class_name}__{func_name}".upper()
class BaseRPCMethodMixin: class RedRpc(JsonRpc):
def __init__(self): def __init__(self, *args, **kwargs):
methods.add(self.all_methods, name="all_methods") super().__init__(*args, **kwargs)
self.add_methods(("", self.get_method_info))
async def all_methods(self): def _add_method(self, method, prefix=""):
return list(methods.all_methods()) if not asyncio.iscoroutinefunction(method):
return
name = get_name(method, prefix)
self.methods[name] = method
def remove_method(self, method):
meth_name = get_name(method)
new_methods = {}
for name, meth in self.methods.items():
if name != meth_name:
new_methods[name] = meth
self.methods = new_methods
def remove_methods(self, prefix: str):
new_methods = {}
for name, meth in self.methods.items():
splitted = name.split("__")
if len(splitted) < 2 or splitted[0] != prefix:
new_methods[name] = meth
self.methods = new_methods
async def get_method_info(self, request):
method_name = request.params[0]
if method_name in self.methods:
return self.methods[method_name].__doc__
return "No docstring available."
class RPC(BaseRPCMethodMixin): class RPC:
""" """
RPC server manager. RPC server manager.
""" """
def __init__(self, bot): def __init__(self):
self.app = web.Application(loop=bot.loop) self.app = web.Application()
self.app.router.add_post("/rpc", self.handle) self._rpc = RedRpc()
self.app.router.add_route("*", "/", self._rpc)
self.app_handler = self.app.make_handler() self.app_handler = self.app.make_handler()
self.server = None self.server = None
super().__init__()
async def initialize(self): async def initialize(self):
""" """
Finalizes the initialization of the RPC server and allows it to begin Finalizes the initialization of the RPC server and allows it to begin
@ -125,10 +83,79 @@ class RPC(BaseRPCMethodMixin):
""" """
self.server.close() self.server.close()
async def handle(self, request): def add_method(self, method, prefix: str = None):
request = await request.text() if prefix is None:
response = await methods.dispatch(request) prefix = method.__self__.__class__.__name__.lower()
if response.is_notification:
return web.Response() if not asyncio.iscoroutinefunction(method):
else: raise TypeError("RPC methods must be coroutines.")
return web.json_response(response, status=response.http_status)
self._rpc.add_methods((prefix, unpack_request_args(method)))
def add_multi_method(self, *methods, prefix: str = None):
if not all(asyncio.iscoroutinefunction(m) for m in methods):
raise TypeError("RPC methods must be coroutines.")
for method in methods:
self.add_method(method, prefix=prefix)
def remove_method(self, method):
self._rpc.remove_method(method)
def remove_methods(self, prefix: str):
self._rpc.remove_methods(prefix)
class RPCMixin:
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.rpc = RPC()
self.rpc_handlers = {} # Lowered cog name to method
def register_rpc_handler(self, method):
"""
Registers a method to act as an RPC handler if the internal RPC server is active.
When calling this method through the RPC server, use the naming scheme "cogname__methodname".
.. important::
All parameters to RPC handler methods must be JSON serializable objects.
The return value of handler methods must also be JSON serializable.
Parameters
----------
method : coroutine
The method to register with the internal RPC server.
"""
self.rpc.add_method(method)
cog_name = method.__self__.__class__.__name__.upper()
if cog_name not in self.rpc_handlers:
self.rpc_handlers[cog_name] = []
self.rpc_handlers[cog_name].append(method)
def unregister_rpc_handler(self, method):
"""
Unregisters an RPC method handler.
This will be called automatically for you on cog unload and will pass silently if the
method is not previously registered.
Parameters
----------
method : coroutine
The method to unregister from the internal RPC server.
"""
self.rpc.remove_method(method)
name = get_name(method)
cog_name = name.split("__")[0]
if cog_name in self.rpc_handlers:
try:
self.rpc_handlers[cog_name].remove(method)
except ValueError:
pass

View File

@ -3,7 +3,7 @@ aiohttp>=2.0.0,<2.3.0
appdirs==1.4.3 appdirs==1.4.3
raven==6.5.0 raven==6.5.0
colorama==0.3.9 colorama==0.3.9
jsonrpcserver aiohttp-json-rpc==0.8.7
pyyaml==3.12 pyyaml==3.12
fuzzywuzzy[speedup]<=0.16.0 fuzzywuzzy[speedup]<=0.16.0
Red-Trivia>=1.1.1 Red-Trivia>=1.1.1

View File

@ -154,7 +154,7 @@ def red(config_fr):
Config.get_core_conf = lambda *args, **kwargs: config_fr Config.get_core_conf = lambda *args, **kwargs: config_fr
red = Red(cli_flags, description=description, pm_help=None) red = Red(cli_flags=cli_flags, description=description, pm_help=None)
yield red yield red

145
tests/core/test_rpc.py Normal file
View File

@ -0,0 +1,145 @@
import pytest
from redbot.core.rpc import RPC, RPCMixin, get_name
from unittest.mock import MagicMock
@pytest.fixture()
def rpc():
return RPC()
@pytest.fixture()
def rpcmixin():
r = RPCMixin()
r.rpc = MagicMock(spec=RPC)
return r
@pytest.fixture()
def cog():
class Cog:
async def cofunc(*args, **kwargs):
pass
async def cofunc2(*args, **kwargs):
pass
async def cofunc3(*args, **kwargs):
pass
def func(*args, **kwargs):
pass
return Cog()
@pytest.fixture()
def existing_func(rpc, cog):
rpc.add_method(cog.cofunc)
return cog.cofunc
@pytest.fixture()
def existing_multi_func(rpc, cog):
funcs = [cog.cofunc, cog.cofunc2, cog.cofunc3]
rpc.add_multi_method(*funcs)
return funcs
def test_get_name(cog):
assert get_name(cog.cofunc) == "COG__COFUNC"
assert get_name(cog.cofunc2) == "COG__COFUNC2"
assert get_name(cog.func) == "COG__FUNC"
def test_internal_methods_exist(rpc):
assert "GET_METHODS" in rpc._rpc.methods
def test_add_method(rpc, cog):
rpc.add_method(cog.cofunc)
assert get_name(cog.cofunc) in rpc._rpc.methods
def test_double_add(rpc, cog):
rpc.add_method(cog.cofunc)
count = len(rpc._rpc.methods)
rpc.add_method(cog.cofunc)
assert count == len(rpc._rpc.methods)
def test_add_notcoro_method(rpc, cog):
with pytest.raises(TypeError):
rpc.add_method(cog.func)
def test_add_multi(rpc, cog):
funcs = [cog.cofunc, cog.cofunc2, cog.cofunc3]
rpc.add_multi_method(*funcs)
names = [get_name(f) for f in funcs]
assert all(n in rpc._rpc.methods for n in names)
def test_add_multi_bad(rpc, cog):
funcs = [cog.cofunc, cog.cofunc2, cog.cofunc3, cog.func]
with pytest.raises(TypeError):
rpc.add_multi_method(*funcs)
names = [get_name(f) for f in funcs]
assert not any(n in rpc._rpc.methods for n in names)
def test_remove_method(rpc, existing_func):
before_count = len(rpc._rpc.methods)
rpc.remove_method(existing_func)
assert get_name(existing_func) not in rpc._rpc.methods
assert before_count - 1 == len(rpc._rpc.methods)
def test_remove_multi_method(rpc, existing_multi_func):
before_count = len(rpc._rpc.methods)
name = get_name(existing_multi_func[0])
prefix = name.split("__")[0]
rpc.remove_methods(prefix)
assert before_count - len(existing_multi_func) == len(rpc._rpc.methods)
names = [get_name(f) for f in existing_multi_func]
assert not any(n in rpc._rpc.methods for n in names)
def test_rpcmixin_register(rpcmixin, cog):
rpcmixin.register_rpc_handler(cog.cofunc)
assert rpcmixin.rpc.add_method.called_once_with(cog.cofunc)
name = get_name(cog.cofunc)
cogname = name.split("__")[0]
assert cogname in rpcmixin.rpc_handlers
def test_rpcmixin_unregister(rpcmixin, cog):
rpcmixin.register_rpc_handler(cog.cofunc)
rpcmixin.unregister_rpc_handler(cog.cofunc)
assert rpcmixin.rpc.remove_method.called_once_with(cog.cofunc)
name = get_name(cog.cofunc)
cogname = name.split("__")[0]
if cogname in rpcmixin.rpc_handlers:
assert cog.cofunc not in rpcmixin.rpc_handlers[cogname]

21
tests/rpc_test.html Normal file
View File

@ -0,0 +1,21 @@
<script src="https://code.jquery.com/jquery-2.2.1.js"></script>
<script>
var ws = new WebSocket("ws://localhost:6133");
var message_id = 0;
ws.onmessage = function(event) {
console.log(JSON.parse(event.data));
}
function ws_call_method(method, params) {
var request = {
jsonrpc: "2.0",
id: message_id,
method: method,
params: params
}
ws.send(JSON.stringify(request));
message_id++;
}
</script>