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/api/client.py b/disco/api/client.py index 05bb594..6d85c60 100644 --- a/disco/api/client.py +++ b/disco/api/client.py @@ -279,8 +279,11 @@ class APIClient(LoggingClass): 'position': position, }, headers=_reason_header(reason)) - 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): diff --git a/disco/bot/bot.py b/disco/bot/bot.py index b88d6f1..4ca2a67 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 = {} @@ -275,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: @@ -296,17 +316,19 @@ 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: 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 +381,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/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/disco/cli.py b/disco/cli.py index f96f8d2..854a344 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) @@ -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): @@ -77,6 +78,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/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/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 947388e..6dc57c3 100644 --- a/examples/basic_plugin.py +++ b/examples/basic_plugin.py @@ -1,4 +1,5 @@ from disco.bot import Plugin +from disco.util.sanitize import S class BasicPlugin(Plugin): @@ -9,76 +10,66 @@ class BasicPlugin(Plugin): # channel = event.guild.create_channel('audit-log-test', 'text', reason='TEST CREATE') # channel.delete(reason='TEST AUDIT 2') - @Plugin.command('reload') - def on_reload(self, event): - self.reload() - event.msg.reply('Reloaded!') + @Plugin.command('ban', ' ') + def on_ban(self, event, user, reason): + event.guild.create_ban(user, reason=reason + u'\U0001F4BF') + + @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('test') @Plugin.command('echo', '') def on_echo_command(self, event, content): - event.msg.reply(content) - - @Plugin.command('spam', ' ') - def on_spam_command(self, event, count, content): - for i in range(count): - event.msg.reply(content) - - @Plugin.command('count', group='messages') - def on_stats(self, event): - msg = event.msg.reply('Ok, one moment...') - msg_count = 0 + # 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) - for msgs in event.channel.messages_iter(bulk=True): - msg_count += len(msgs) + @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)) - 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): + # 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!' diff --git a/requirements.txt b/requirements.txt index e10e806..4228bdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ -gevent==1.2.1 +gevent==1.2.2 holster==1.0.15 -inflection==0.3.1 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 0fb1232..dd4b3b6 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,8 @@ 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'], 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])