From 30fbb9cbe74246631a80c21949792c9c75430b7e Mon Sep 17 00:00:00 2001 From: Andrei Zbikowski Date: Sat, 17 Jun 2017 01:15:36 -0700 Subject: [PATCH 1/7] Increase the weight of group matches over command argument matches (#33) --- disco/bot/bot.py | 9 +++++---- tests/test_bot.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/disco/bot/bot.py b/disco/bot/bot.py index b88d6f1..3388b90 100644 --- a/disco/bot/bot.py +++ b/disco/bot/bot.py @@ -303,10 +303,12 @@ class Bot(LoggingClass): if not self.command_matches_re or not self.command_matches_re.match(content): return + options = [] for command in self.commands: match = command.compiled_regex.match(content) if match: - yield (command, match) + options.append((command, match)) + return sorted(options, key=lambda obj: obj[0].group is None) def get_level(self, actor): level = CommandLevels.DEFAULT @@ -359,14 +361,13 @@ class Bot(LoggingClass): if not len(commands): return False - result = False for command, match in commands: if not self.check_command_permissions(command, msg): continue if command.plugin.execute(CommandEvent(command, msg, match)): - result = True - return result + return True + return False def on_message_create(self, event): if event.message.author.id == self.client.state.me.id: diff --git a/tests/test_bot.py b/tests/test_bot.py index fafc875..61149d5 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -69,3 +69,26 @@ class TestBot(TestCase): self.assertNotEqual(self.bot.command_matches_re.match('t b'), None) self.assertEqual(self.bot.command_matches_re.match('testing b'), None) self.assertEqual(self.bot.command_matches_re.match('testlmao a'), None) + + def test_group_and_command(self): + plugin = Object() + plugin.bot = self.bot + + self.bot._commands = [ + Command(plugin, None, 'test'), + Command(plugin, None, 'a', group='test'), + Command(plugin, None, 'b', group='test'), + ] + + self.bot.recompute() + + msg = Object() + + msg.content = '!test a' + commands = list(self.bot.get_commands_for_message(False, None, '!', msg)) + self.assertEqual(commands[0][0], self.bot._commands[1]) + self.assertEqual(commands[1][0], self.bot._commands[0]) + + msg.content = '!test' + commands = list(self.bot.get_commands_for_message(False, None, '!', msg)) + self.assertEqual(commands[0][0], self.bot._commands[0]) From f48671d9bbf4361c46eda455ae705d9306beaa50 Mon Sep 17 00:00:00 2001 From: Andrei Zbikowski Date: Sat, 17 Jun 2017 01:15:49 -0700 Subject: [PATCH 2/7] Add support for built-in HTTP/Flask server (#34) * Add support for built-in HTTP/Flask server * Don't default http_enabled to true --- disco/bot/bot.py | 20 ++++++++++++++++++++ disco/bot/plugin.py | 14 ++++++++++++++ docs/installation.md | 1 + examples/basic_plugin.py | 5 +++++ setup.py | 1 + 5 files changed, 41 insertions(+) diff --git a/disco/bot/bot.py b/disco/bot/bot.py index 3388b90..00d1e6d 100644 --- a/disco/bot/bot.py +++ b/disco/bot/bot.py @@ -1,11 +1,13 @@ import re import os import six +import gevent import inspect import importlib from six.moves import reload_module from holster.threadlocal import ThreadLocal +from gevent.wsgi import WSGIServer from disco.types.guild import GuildMember from disco.bot.plugin import Plugin @@ -63,6 +65,13 @@ class BotConfig(Config): The serialization format plugin configuration files are in. plugin_config_dir : str The directory plugin configuration is located within. + http_enabled : bool + Whether to enable the built-in Flask server which allows plugins to handle + and route HTTP requests. + http_host : str + The host string for the HTTP Flask server (if enabled) + http_port : int + The port for the HTTP Flask server (if enabled) """ levels = {} plugins = [] @@ -90,6 +99,10 @@ class BotConfig(Config): storage_serializer = 'json' storage_path = 'storage.json' + http_enabled = False + http_host = '0.0.0.0' + http_port = 7575 + class Bot(LoggingClass): """ @@ -132,6 +145,13 @@ class Bot(LoggingClass): if self.client.config.manhole_enable: self.client.manhole_locals['bot'] = self + if self.config.http_enabled: + from flask import Flask + self.log.info('Starting HTTP server bound to %s:%s', self.config.http_host, self.config.http_port) + self.http = Flask('disco') + self.http_server = WSGIServer((self.config.http_host, self.config.http_port), self.http) + self.http_server_greenlet = gevent.spawn(self.http_server.serve_forever) + self.plugins = {} self.group_abbrev = {} diff --git a/disco/bot/plugin.py b/disco/bot/plugin.py index 155e581..da719a4 100644 --- a/disco/bot/plugin.py +++ b/disco/bot/plugin.py @@ -131,6 +131,17 @@ class BasePluginDeco(object): 'kwargs': kwargs, }) + @classmethod + def route(cls, *args, **kwargs): + """ + Adds an HTTP route. + """ + return cls.add_meta_deco({ + 'type': 'http.add_route', + 'args': args, + 'kwargs': kwargs, + }) + class PluginDeco(BasePluginDeco): """ @@ -227,6 +238,9 @@ class Plugin(LoggingClass, PluginDeco): getattr(command.parser, meta['type'].split('.', 1)[-1])( *meta['args'], **meta['kwargs']) + elif meta['type'] == 'http.add_route': + meta['kwargs']['view_func'] = member + self.bot.http.add_url_rule(*meta['args'], **meta['kwargs']) else: raise Exception('unhandled meta type {}'.format(meta)) diff --git a/docs/installation.md b/docs/installation.md index 70de528..778d8c8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -21,6 +21,7 @@ pip install disco[performance] | Name | Explanation | Versions | |------|-------------|----------| | voice | Adds functionality required to connect and use voice | Both | +| http | Adds a built-in HTTP server w/ Flask, allowing plugins to handle HTTP requests | Both | | music | Adds the ability to stream and play music from various third party sites | Both | | performance | Adds a faster JSON parser (ujson) and an ETF encoding parser | 2.x Only | | sharding | Adds a library which is required to enable auto-sharding | 2.x Only | diff --git a/examples/basic_plugin.py b/examples/basic_plugin.py index c57866c..a55f352 100644 --- a/examples/basic_plugin.py +++ b/examples/basic_plugin.py @@ -74,3 +74,8 @@ class BasicPlugin(Plugin): if args.help: return event.msg.reply(event.parser.format_help()) event.msg.reply(args.asdf) + + @Plugin.route('/test') + def on_test_route(self): + print 'WOW!' + return 'Hi!' diff --git a/setup.py b/setup.py index 0fb1232..7f4488b 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ with open('README.md') as f: extras_require = { 'voice': ['pynacl==1.1.2'], + 'http': ['flask==0.12.2'], 'music': ['youtube_dl==2017.4.26'], 'performance': ['erlpack==0.3.2', 'ujson==1.35'], 'sharding': ['gipc==0.6.0'], From ab4f975609ea2ac47ede7278a6bfcb51e6102772 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 17 Jun 2017 04:44:41 -0700 Subject: [PATCH 3/7] Cleanup examples and include comments --- disco/cli.py | 8 +++++ examples/basic_plugin.py | 76 +++++++++++++++------------------------- 2 files changed, 37 insertions(+), 47 deletions(-) diff --git a/disco/cli.py b/disco/cli.py index df04313..4d69cbb 100644 --- a/disco/cli.py +++ b/disco/cli.py @@ -25,6 +25,7 @@ parser.add_argument('--encoder', help='encoder for gateway data', default=None) parser.add_argument('--run-bot', help='run a disco bot on this client', action='store_true', default=False) parser.add_argument('--plugin', help='load plugins into the bot', nargs='*', default=[]) parser.add_argument('--log-level', help='log level', default='info') +parser.add_argument('--http-bind', help='bind information for http server', default=None) def disco_main(run=False): @@ -74,6 +75,13 @@ def disco_main(run=False): bot_config.plugins = args.plugin else: bot_config.plugins += args.plugin + + if args.http_bind: + bot_config.http_enabled = True + host, port = args.http_bind.split(':', 1) + bot_config.http_host = host + bot_config.http_port = int(port) + bot = Bot(client, bot_config) if run: diff --git a/examples/basic_plugin.py b/examples/basic_plugin.py index a55f352..b47dc6e 100644 --- a/examples/basic_plugin.py +++ b/examples/basic_plugin.py @@ -1,81 +1,63 @@ from disco.bot import Plugin +from disco.util.sanitize import S class BasicPlugin(Plugin): - @Plugin.command('reload') - def on_reload(self, event): - self.reload() - event.msg.reply('Reloaded!') + @Plugin.command('ping') + def on_ping_command(self, event): + # Generally all the functionality you need to interact with is contained + # within the event object passed to command and event handlers. + event.msg.reply('Pong!') @Plugin.listen('MessageCreate') - def on_message_create(self, msg): - self.log.info('Message created: {}: {}'.format(msg.author, msg.content)) + def on_message_create(self, event): + # All of Discord's events can be listened too and handled easily + self.log.info(u'{}: {}'.format(event.author, event.content)) @Plugin.command('echo', '') def on_echo_command(self, event, content): - event.msg.reply(content) + # Commands can take a set of arguments that are validated by Disco itself + # and content sent via messages can be automatically sanitized to avoid + # mentions/etc. + event.msg.reply(content, santize=True) - @Plugin.command('spam', ' ') - def on_spam_command(self, event, count, content): - for i in range(count): - event.msg.reply(content) + @Plugin.command('add', ' ', group='math') + def on_math_add_command(self, event, a, b): + # Commands can be grouped together for a cleaner user-facing interface. + event.msg.reply('{}'.format(a + b)) - @Plugin.command('count', group='messages') - def on_stats(self, event): - msg = event.msg.reply('Ok, one moment...') - msg_count = 0 - - for msgs in event.channel.messages_iter(bulk=True): - msg_count += len(msgs) - - msg.edit('{} messages'.format(msg_count)) + @Plugin.command('sub', ' ', group='math') + def on_math_sub_command(self, event, a, b): + event.msg.reply('{}'.format(a - b)) @Plugin.command('tag', ' [value:str...]') def on_tag(self, event, name, value=None): + # Plugins can easily store data locally using Disco's built in storage tags = self.storage.guild.ensure('tags') if value: tags[name] = value - event.msg.reply(':ok_hand:') + event.msg.reply(u':ok_hand: created tag `{}`'.format(S(name))) else: if name in tags: return event.msg.reply(tags[name]) else: - return event.msg.reply('Unknown tag: `{}`'.format(name)) - - @Plugin.command('info', '') - def on_info(self, event, query): - users = list(self.state.users.select({'username': query}, {'id': query})) - - if not users: - event.msg.reply("Couldn't find user for your query: `{}`".format(query)) - elif len(users) > 1: - event.msg.reply('I found too many users ({}) for your query: `{}`'.format(len(users), query)) - else: - user = users[0] - parts = [] - parts.append('ID: {}'.format(user.id)) - parts.append('Username: {}'.format(user.username)) - parts.append('Discriminator: {}'.format(user.discriminator)) - - if event.channel.guild: - member = event.channel.guild.get_member(user) - parts.append('Nickname: {}'.format(member.nick)) - parts.append('Joined At: {}'.format(member.joined_at)) - - event.msg.reply('```\n{}\n```'.format( - '\n'.join(parts) - )) + return event.msg.reply(u'Unknown tag: `{}`'.format(S(name))) @Plugin.command('test', parser=True) @Plugin.parser.add_argument('-a', '--asdf', help='wow') @Plugin.parser.add_argument('--help', action='store_true') def on_test(self, event, args): + # Disco supports using an argparse.ArgumentParser for parsing commands as + # well, which helps for large complex commands with many options or flags. if args.help: return event.msg.reply(event.parser.format_help()) event.msg.reply(args.asdf) @Plugin.route('/test') def on_test_route(self): - print 'WOW!' + # Disco has built-in support for Flask (if installed and enabled) which + # allows plugins to create HTTP routes. + from flask import request + print dict(request.headers) return 'Hi!' From bc0878f4cf10175424c196d6a4ff8c3c70dce90a Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 17 Jun 2017 04:49:21 -0700 Subject: [PATCH 4/7] Remove requirement for inflection library --- disco/__init__.py | 2 +- disco/gateway/events.py | 5 ++--- disco/state.py | 4 ++-- disco/util/string.py | 9 +++++++++ requirements.txt | 1 - 5 files changed, 14 insertions(+), 7 deletions(-) create mode 100644 disco/util/string.py diff --git a/disco/__init__.py b/disco/__init__.py index 636ac44..b481566 100644 --- a/disco/__init__.py +++ b/disco/__init__.py @@ -1 +1 @@ -VERSION = '0.0.10' +VERSION = '0.0.11-rc.1' diff --git a/disco/gateway/events.py b/disco/gateway/events.py index 99b5c03..c08ad2e 100644 --- a/disco/gateway/events.py +++ b/disco/gateway/events.py @@ -1,6 +1,5 @@ from __future__ import print_function -import inflection import six from disco.types.user import User, Presence @@ -8,8 +7,8 @@ from disco.types.channel import Channel, PermissionOverwrite from disco.types.message import Message, MessageReactionEmoji from disco.types.voice import VoiceState from disco.types.guild import Guild, GuildMember, Role, GuildEmoji - from disco.types.base import Model, ModelMeta, Field, ListField, AutoDictField, snowflake, datetime +from disco.util.string import underscore # Mapping of discords event name to our event classes EVENTS_MAP = {} @@ -20,7 +19,7 @@ class GatewayEventMeta(ModelMeta): obj = super(GatewayEventMeta, mcs).__new__(mcs, name, parents, dct) if name != 'GatewayEvent': - EVENTS_MAP[inflection.underscore(name).upper()] = obj + EVENTS_MAP[underscore(name).upper()] = obj return obj diff --git a/disco/state.py b/disco/state.py index 49a63e0..1d0f6d7 100644 --- a/disco/state.py +++ b/disco/state.py @@ -1,12 +1,12 @@ import six import weakref -import inflection from collections import deque, namedtuple from gevent.event import Event from disco.types.base import UNSET from disco.util.config import Config +from disco.util.string import underscore from disco.util.hashmap import HashMap, DefaultHashMap @@ -131,7 +131,7 @@ class State(object): assert not len(self.listeners), 'Binding while already bound is dangerous' for event in self.EVENTS: - func = 'on_' + inflection.underscore(event) + func = 'on_' + underscore(event) self.listeners.append(self.client.events.on(event, getattr(self, func))) def fill_messages(self, channel): diff --git a/disco/util/string.py b/disco/util/string.py new file mode 100644 index 0000000..00d515e --- /dev/null +++ b/disco/util/string.py @@ -0,0 +1,9 @@ +import re + + +# Taken from inflection library +def underscore(word): + word = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', word) + word = re.sub(r'([a-z\d])([A-Z])', r'\1_\2', word) + word = word.replace('-', '_') + return word.lower() diff --git a/requirements.txt b/requirements.txt index e10e806..40a45c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ gevent==1.2.1 holster==1.0.15 -inflection==0.3.1 requests==2.13.0 six==1.10.0 websocket-client==0.40.0 From 831f7566e9b0cd78256f7ff90da30c4d42a0e7b9 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 17 Jun 2017 04:51:29 -0700 Subject: [PATCH 5/7] Remove requirement around pyyaml, bump gevent --- disco/cli.py | 2 +- requirements.txt | 3 +-- setup.py | 1 + 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/disco/cli.py b/disco/cli.py index 4d69cbb..7e54333 100644 --- a/disco/cli.py +++ b/disco/cli.py @@ -14,7 +14,7 @@ from gevent import monkey monkey.patch_all() parser = argparse.ArgumentParser() -parser.add_argument('--config', help='Configuration file', default='config.yaml') +parser.add_argument('--config', help='Configuration file', default='config.json') parser.add_argument('--token', help='Bot Authentication Token', default=None) parser.add_argument('--shard-count', help='Total number of shards', default=None) parser.add_argument('--shard-id', help='Current shard number/id', default=None) diff --git a/requirements.txt b/requirements.txt index 40a45c3..4228bdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ -gevent==1.2.1 +gevent==1.2.2 holster==1.0.15 requests==2.13.0 six==1.10.0 websocket-client==0.40.0 -pyyaml==3.12 diff --git a/setup.py b/setup.py index 7f4488b..dd4b3b6 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ with open('README.md') as f: extras_require = { 'voice': ['pynacl==1.1.2'], 'http': ['flask==0.12.2'], + 'yaml': ['pyyaml==3.12'], 'music': ['youtube_dl==2017.4.26'], 'performance': ['erlpack==0.3.2', 'ujson==1.35'], 'sharding': ['gipc==0.6.0'], From 6a185908ac06f666fb5063c56bcee4e775ba2179 Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 17 Jun 2017 16:16:38 -0700 Subject: [PATCH 6/7] Always return a list from get_commands_for_message (fixes #36) --- disco/bot/bot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/disco/bot/bot.py b/disco/bot/bot.py index 00d1e6d..4ca2a67 100644 --- a/disco/bot/bot.py +++ b/disco/bot/bot.py @@ -295,7 +295,7 @@ class Bot(LoggingClass): mention_rules.get('role', False) and any(mention_roles), msg.channel.is_dm )): - return + return [] if mention_direct: if msg.guild: @@ -316,12 +316,12 @@ class Bot(LoggingClass): content = content.lstrip() if prefix and not content.startswith(prefix): - return + return [] else: content = content[len(prefix):] if not self.command_matches_re or not self.command_matches_re.match(content): - return + return [] options = [] for command in self.commands: From 41e35c4381d9b93b8f2a38b10a85b806c5aef09f Mon Sep 17 00:00:00 2001 From: Andrei Date: Sat, 17 Jun 2017 16:23:44 -0700 Subject: [PATCH 7/7] Add support for limit/after query args in guild_members_list (fixes #37) --- disco/api/client.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/disco/api/client.py b/disco/api/client.py index abc261f..2f2533e 100644 --- a/disco/api/client.py +++ b/disco/api/client.py @@ -263,8 +263,11 @@ class APIClient(LoggingClass): 'position': position, }) - def guilds_members_list(self, guild): - r = self.http(Routes.GUILDS_MEMBERS_LIST, dict(guild=guild)) + def guilds_members_list(self, guild, limit=1000, after=None): + r = self.http(Routes.GUILDS_MEMBERS_LIST, dict(guild=guild), params=optional( + limit=limit, + after=after, + )) return GuildMember.create_hash(self.client, 'id', r.json(), guild_id=guild) def guilds_members_get(self, guild, member):