diff options
| -rw-r--r-- | .gitignore | 89 | ||||
| -rw-r--r-- | README.org | 23 | ||||
| -rwxr-xr-x | bin/dbolla | 132 | ||||
| -rw-r--r-- | dbolla.conf.example | 20 | ||||
| -rw-r--r-- | warmachine/addons/__init__.py | 0 | ||||
| -rw-r--r-- | warmachine/addons/base.py | 6 | ||||
| -rw-r--r-- | warmachine/addons/giphy.py | 33 | ||||
| -rw-r--r-- | warmachine/addons/standup.py | 28 | ||||
| -rw-r--r-- | warmachine/config.py | 20 | ||||
| -rw-r--r-- | warmachine/connections/__init__.py | 0 | ||||
| -rw-r--r-- | warmachine/connections/base.py | 40 | ||||
| -rw-r--r-- | warmachine/connections/irc.py | 64 | ||||
| -rw-r--r-- | warmachine/connections/slack.py | 281 | ||||
| -rw-r--r-- | warmachine/utils/__init__.py | 0 | ||||
| -rw-r--r-- | warmachine/utils/decorators.py | 40 |
15 files changed, 776 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..72364f9 --- /dev/null +++ b/.gitignore | |||
| @@ -0,0 +1,89 @@ | |||
| 1 | # Byte-compiled / optimized / DLL files | ||
| 2 | __pycache__/ | ||
| 3 | *.py[cod] | ||
| 4 | *$py.class | ||
| 5 | |||
| 6 | # C extensions | ||
| 7 | *.so | ||
| 8 | |||
| 9 | # Distribution / packaging | ||
| 10 | .Python | ||
| 11 | env/ | ||
| 12 | build/ | ||
| 13 | develop-eggs/ | ||
| 14 | dist/ | ||
| 15 | downloads/ | ||
| 16 | eggs/ | ||
| 17 | .eggs/ | ||
| 18 | lib/ | ||
| 19 | lib64/ | ||
| 20 | parts/ | ||
| 21 | sdist/ | ||
| 22 | var/ | ||
| 23 | *.egg-info/ | ||
| 24 | .installed.cfg | ||
| 25 | *.egg | ||
| 26 | |||
| 27 | # PyInstaller | ||
| 28 | # Usually these files are written by a python script from a template | ||
| 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. | ||
| 30 | *.manifest | ||
| 31 | *.spec | ||
| 32 | |||
| 33 | # Installer logs | ||
| 34 | pip-log.txt | ||
| 35 | pip-delete-this-directory.txt | ||
| 36 | |||
| 37 | # Unit test / coverage reports | ||
| 38 | htmlcov/ | ||
| 39 | .tox/ | ||
| 40 | .coverage | ||
| 41 | .coverage.* | ||
| 42 | .cache | ||
| 43 | nosetests.xml | ||
| 44 | coverage.xml | ||
| 45 | *,cover | ||
| 46 | .hypothesis/ | ||
| 47 | |||
| 48 | # Translations | ||
| 49 | *.mo | ||
| 50 | *.pot | ||
| 51 | |||
| 52 | # Django stuff: | ||
| 53 | *.log | ||
| 54 | local_settings.py | ||
| 55 | |||
| 56 | # Flask stuff: | ||
| 57 | instance/ | ||
| 58 | .webassets-cache | ||
| 59 | |||
| 60 | # Scrapy stuff: | ||
| 61 | .scrapy | ||
| 62 | |||
| 63 | # Sphinx documentation | ||
| 64 | docs/_build/ | ||
| 65 | |||
| 66 | # PyBuilder | ||
| 67 | target/ | ||
| 68 | |||
| 69 | # IPython Notebook | ||
| 70 | .ipynb_checkpoints | ||
| 71 | |||
| 72 | # pyenv | ||
| 73 | .python-version | ||
| 74 | |||
| 75 | # celery beat schedule file | ||
| 76 | celerybeat-schedule | ||
| 77 | |||
| 78 | # dotenv | ||
| 79 | .env | ||
| 80 | |||
| 81 | # virtualenv | ||
| 82 | venv/ | ||
| 83 | ENV/ | ||
| 84 | |||
| 85 | # Spyder project settings | ||
| 86 | .spyderproject | ||
| 87 | |||
| 88 | # Rope project settings | ||
| 89 | .ropeproject | ||
diff --git a/README.org b/README.org new file mode 100644 index 0000000..18641d4 --- /dev/null +++ b/README.org | |||
| @@ -0,0 +1,23 @@ | |||
| 1 | * Setting up the bot | ||
| 2 | D'bolla is a no bullshit extensible IRC/Slack bot written for Python 3.5 using | ||
| 3 | asyncio. | ||
| 4 | |||
| 5 | ** Install dependencies: | ||
| 6 | |||
| 7 | #+BEGIN_SRC bash | ||
| 8 | pip install websockets | ||
| 9 | #+END_SRC | ||
| 10 | |||
| 11 | ** Configure | ||
| 12 | Copy dbolla.conf.example to dbolla.conf and edit this file. You can | ||
| 13 | specify multiples of the same connection by prefixing the config section with | ||
| 14 | 'slack:' or 'irc:' followed by a unique name for this connection. | ||
| 15 | |||
| 16 | ** Running | ||
| 17 | Simply run the command: | ||
| 18 | |||
| 19 | #+BEGIN_SRC bash | ||
| 20 | ./bin/dbolla -c /path/to/my/dbolla.conf | ||
| 21 | #+END_SRC | ||
| 22 | |||
| 23 | * Writing a Plugin | ||
diff --git a/bin/dbolla b/bin/dbolla new file mode 100755 index 0000000..7a1eba6 --- /dev/null +++ b/bin/dbolla | |||
| @@ -0,0 +1,132 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | # -*- mode: python -*- | ||
| 3 | import asyncio | ||
| 4 | import functools | ||
| 5 | import logging.config | ||
| 6 | |||
| 7 | from warmachine.config import Config | ||
| 8 | from warmachine.connections.irc import AioIRC | ||
| 9 | from warmachine.connections.slack import SlackWS | ||
| 10 | |||
| 11 | logging.config.dictConfig({ | ||
| 12 | 'version': 1, | ||
| 13 | 'disable_existing_loggers': False, | ||
| 14 | |||
| 15 | 'formatters': { | ||
| 16 | 'standard': { | ||
| 17 | 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s', | ||
| 18 | } | ||
| 19 | }, | ||
| 20 | 'handlers': { | ||
| 21 | 'console': { | ||
| 22 | 'class': 'logging.StreamHandler', | ||
| 23 | 'level': 'DEBUG', | ||
| 24 | 'formatter': 'standard', | ||
| 25 | }, | ||
| 26 | }, | ||
| 27 | 'loggers': { | ||
| 28 | 'websockets': { | ||
| 29 | 'level': 'INFO', | ||
| 30 | 'handlers': ['console', ] | ||
| 31 | }, | ||
| 32 | '': { | ||
| 33 | 'level': 'DEBUG', | ||
| 34 | 'handlers': ['console', ] | ||
| 35 | } | ||
| 36 | } | ||
| 37 | }) | ||
| 38 | |||
| 39 | |||
| 40 | class Bot(object): | ||
| 41 | def __init__(self, settings): | ||
| 42 | self.log = logging.getLogger(self.__class__.__name__) | ||
| 43 | self._loop = asyncio.get_event_loop() | ||
| 44 | |||
| 45 | self.settings = settings | ||
| 46 | |||
| 47 | self.connections = {} | ||
| 48 | self.tasks = [] | ||
| 49 | |||
| 50 | self.loaded_plugins = [] | ||
| 51 | |||
| 52 | self.load_plugin('asdf') | ||
| 53 | |||
| 54 | def start(self): | ||
| 55 | for connection in self.connections: | ||
| 56 | t = asyncio.ensure_future(connection.connect()) | ||
| 57 | t.add_done_callback(functools.partial(self.on_connect, connection)) | ||
| 58 | |||
| 59 | self._loop.run_forever() | ||
| 60 | |||
| 61 | def add_connection(self, connection): | ||
| 62 | self.connections[connection] = {} | ||
| 63 | |||
| 64 | def on_connect(self, connection, task): | ||
| 65 | asyncio.ensure_future(self.process_message(connection)) | ||
| 66 | |||
| 67 | async def process_message(self, connection): | ||
| 68 | """ | ||
| 69 | Constantly read new messages from the connection in a non-blocking way | ||
| 70 | """ | ||
| 71 | while True: | ||
| 72 | message = await connection.read() | ||
| 73 | if not message: | ||
| 74 | continue | ||
| 75 | |||
| 76 | for p in self.loaded_plugins: | ||
| 77 | self.log.debug('Calling {}'.format(p.__class__.__name__)) | ||
| 78 | await p.recv_msg(connection, message) | ||
| 79 | |||
| 80 | self.log.debug('MSG {}: {}'.format( | ||
| 81 | connection.__class__.__name__, message)) | ||
| 82 | |||
| 83 | def load_plugin(self, path): | ||
| 84 | """ | ||
| 85 | Loads plugins | ||
| 86 | """ | ||
| 87 | from importlib import import_module | ||
| 88 | |||
| 89 | mod_path, cls_name = 'warmachine.addons.giphy.GiphySearch'.rsplit('.', 1) | ||
| 90 | |||
| 91 | mod = import_module(mod_path) | ||
| 92 | |||
| 93 | if hasattr(mod, cls_name): | ||
| 94 | cls = getattr(mod, cls_name)() | ||
| 95 | |||
| 96 | self.loaded_plugins.append(cls) | ||
| 97 | |||
| 98 | def reload_plugin(self, path): | ||
| 99 | """ | ||
| 100 | Reload a plugin | ||
| 101 | """ | ||
| 102 | |||
| 103 | def unload_plugin(self, path): | ||
| 104 | """ | ||
| 105 | Unload a plugin | ||
| 106 | """ | ||
| 107 | |||
| 108 | if __name__ == "__main__": | ||
| 109 | import argparse | ||
| 110 | import sys | ||
| 111 | |||
| 112 | parser = argparse.ArgumentParser() | ||
| 113 | parser.add_argument('-c', '--config', help='define warmachine config file', | ||
| 114 | type=str) | ||
| 115 | args = parser.parse_args() | ||
| 116 | |||
| 117 | if args.config: | ||
| 118 | settings = Config(args.config) | ||
| 119 | else: | ||
| 120 | sys.exit(1) | ||
| 121 | |||
| 122 | bot = Bot(settings) | ||
| 123 | |||
| 124 | for s in settings.sections(): | ||
| 125 | options = settings.options_as_dict(s) | ||
| 126 | if options.get('enable', False) == 'true': | ||
| 127 | if s.startswith('slack'): | ||
| 128 | bot.add_connection(SlackWS(options)) | ||
| 129 | # elif s.startswith('irc'): | ||
| 130 | # bot.add_connection(AioIRC(options)) | ||
| 131 | |||
| 132 | bot.start() | ||
diff --git a/dbolla.conf.example b/dbolla.conf.example new file mode 100644 index 0000000..206a394 --- /dev/null +++ b/dbolla.conf.example | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | [irc:freenode] | ||
| 2 | # Address to the server Defaut: irc.freenode.org | ||
| 3 | server=irc.freenode.org | ||
| 4 | # Port to the server Default: 6697 | ||
| 5 | port=6697 | ||
| 6 | # Use SSL | default: true | ||
| 7 | ssl=true | ||
| 8 | # The nickname the bot should use | ||
| 9 | nick=warmachine | ||
| 10 | # Shows up in Real Name | ||
| 11 | name=War Machine | ||
| 12 | # Password to connect to the server | ||
| 13 | password= | ||
| 14 | |||
| 15 | [slack:myslack] | ||
| 16 | # In your Slack account go to the admin section followed by | ||
| 17 | # "Custom Integrations". Click on bots and create a new bot user. | ||
| 18 | |||
| 19 | # Slack bot API token | ||
| 20 | token=xoxb-random_acharacters | ||
diff --git a/warmachine/addons/__init__.py b/warmachine/addons/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/warmachine/addons/__init__.py | |||
diff --git a/warmachine/addons/base.py b/warmachine/addons/base.py new file mode 100644 index 0000000..aa23c78 --- /dev/null +++ b/warmachine/addons/base.py | |||
| @@ -0,0 +1,6 @@ | |||
| 1 | import logging | ||
| 2 | |||
| 3 | |||
| 4 | class WarMachinePlugin(object): | ||
| 5 | def __init__(self): | ||
| 6 | self.log = logging.getLogger(self.__class__.__name__) | ||
diff --git a/warmachine/addons/giphy.py b/warmachine/addons/giphy.py new file mode 100644 index 0000000..a9bdac1 --- /dev/null +++ b/warmachine/addons/giphy.py | |||
| @@ -0,0 +1,33 @@ | |||
| 1 | import urllib.request | ||
| 2 | import json | ||
| 3 | |||
| 4 | from .base import WarMachinePlugin | ||
| 5 | |||
| 6 | __author__ = 'jason@zzq.org' | ||
| 7 | __class_name__ = 'GiphySearch' | ||
| 8 | __version__ = 1.0 | ||
| 9 | |||
| 10 | |||
| 11 | class GiphySearch(WarMachinePlugin): | ||
| 12 | async def recv_msg(self, connection, message): | ||
| 13 | if message['message'].startswith('!giphy '): | ||
| 14 | search_terms = ' '.join(message['message'].split(' ')[1:]) | ||
| 15 | |||
| 16 | self.log.debug('Searching giphy.com for: {}'.format(search_terms)) | ||
| 17 | |||
| 18 | url = ('http://api.giphy.com/v1/gifs/search?' | ||
| 19 | 'q={}&api_key=dc6zaTOxFJmzC&limit=1'.format( | ||
| 20 | search_terms.replace(' ', '%20'))) | ||
| 21 | |||
| 22 | # TODO: This blocks | ||
| 23 | req = urllib.request.Request(url) | ||
| 24 | data = urllib.request.urlopen(req).read().decode('utf-8') | ||
| 25 | |||
| 26 | data = json.loads(data) | ||
| 27 | self.log.debug(data) | ||
| 28 | try: | ||
| 29 | result = data['data'][0]['images']['original']['url'] | ||
| 30 | await connection.say(result, message['channel']) | ||
| 31 | except IndexError as e: | ||
| 32 | await connection.say('No match for: {}'.format(search_terms), | ||
| 33 | message['channel']) | ||
diff --git a/warmachine/addons/standup.py b/warmachine/addons/standup.py new file mode 100644 index 0000000..a21efdd --- /dev/null +++ b/warmachine/addons/standup.py | |||
| @@ -0,0 +1,28 @@ | |||
| 1 | from .base import WarMachinePlugin | ||
| 2 | |||
| 3 | |||
| 4 | class StandUpPlugin(WarMachinePlugin): | ||
| 5 | """ | ||
| 6 | WarMachine stand up plugin. | ||
| 7 | |||
| 8 | Commands: | ||
| 9 | !standup-add <24 hr time to kick off> <SunMTWThFSat> [channel] | ||
| 10 | !standup-remove [channel] | ||
| 11 | """ | ||
| 12 | async def recv_msg(self, connection, message): | ||
| 13 | if not message['message'].startswith('!standup'): | ||
| 14 | return | ||
| 15 | |||
| 16 | self.log.debug('standup recv: {}'.format(message)) | ||
| 17 | |||
| 18 | cmd = message['message'].split(' ')[0] | ||
| 19 | parts = message['message'].split(' ')[1:] | ||
| 20 | |||
| 21 | if cmd == '!standup-add': | ||
| 22 | await connection.say('Scheduling standup for {} on {}'.format( | ||
| 23 | parts[1], parts[2])) | ||
| 24 | |||
| 25 | # await connection.say('{}, {}'.format(cmd, parts), message['channel']) | ||
| 26 | |||
| 27 | async def start_standup(self, connection): | ||
| 28 | pass | ||
diff --git a/warmachine/config.py b/warmachine/config.py new file mode 100644 index 0000000..6be84e3 --- /dev/null +++ b/warmachine/config.py | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | import configparser | ||
| 2 | |||
| 3 | |||
| 4 | class Config(configparser.ConfigParser): | ||
| 5 | def __init__(self, config_path=None): | ||
| 6 | super().__init__() | ||
| 7 | |||
| 8 | self.config_path = config_path | ||
| 9 | |||
| 10 | if self.config_path: | ||
| 11 | self.read(self.config_path) | ||
| 12 | |||
| 13 | def options_as_dict(self, section): | ||
| 14 | """ | ||
| 15 | Returns: | ||
| 16 | dict: Dictionary of the options defined in this config | ||
| 17 | """ | ||
| 18 | d = dict(self.items(section)) | ||
| 19 | d['section_name'] = section | ||
| 20 | return d | ||
diff --git a/warmachine/connections/__init__.py b/warmachine/connections/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/warmachine/connections/__init__.py | |||
diff --git a/warmachine/connections/base.py b/warmachine/connections/base.py new file mode 100644 index 0000000..e68e9b9 --- /dev/null +++ b/warmachine/connections/base.py | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | INITALIZED = 'Initalized' | ||
| 2 | CONNECTED = 'Connected' | ||
| 3 | |||
| 4 | |||
| 5 | class Connection(object): | ||
| 6 | def connect(self, *args, **kwargs): | ||
| 7 | """ | ||
| 8 | This is called by the main start method. It should prepare your | ||
| 9 | Connection and connect. | ||
| 10 | """ | ||
| 11 | raise NotImplementedError('{} must implement `connect` method'.format( | ||
| 12 | self.__class__.__name__)) | ||
| 13 | |||
| 14 | def read(self): | ||
| 15 | """ | ||
| 16 | Dictionary of data in the following format: | ||
| 17 | { | ||
| 18 | 'sender': 'username/id', | ||
| 19 | 'channel': 'channel name' or None, | ||
| 20 | 'message': 'actual message', | ||
| 21 | } | ||
| 22 | |||
| 23 | Returns: | ||
| 24 | dict: Data from the connection that the bot should consider. | ||
| 25 | """ | ||
| 26 | raise NotImplementedError('{} must implement `read` method'.format( | ||
| 27 | self.__class__.__name__)) | ||
| 28 | |||
| 29 | def id(self): | ||
| 30 | """ | ||
| 31 | Unique ID for this connection. Since there can be more than one | ||
| 32 | connection it should be unique per actual connection to the server. For | ||
| 33 | example `bot nickname` + `server host:port`. This is used to store | ||
| 34 | settings and other information. | ||
| 35 | |||
| 36 | Returns: | ||
| 37 | str: Unique ID for this connection object | ||
| 38 | """ | ||
| 39 | raise NotImplementedError('{} must implement `id` method'.format( | ||
| 40 | self.__class__.__name__)) | ||
diff --git a/warmachine/connections/irc.py b/warmachine/connections/irc.py new file mode 100644 index 0000000..f7f682c --- /dev/null +++ b/warmachine/connections/irc.py | |||
| @@ -0,0 +1,64 @@ | |||
| 1 | import asyncio | ||
| 2 | import logging | ||
| 3 | |||
| 4 | from .base import Connection, INITALIZED | ||
| 5 | from ..utils.decorators import memoize | ||
| 6 | |||
| 7 | |||
| 8 | class AioIRC(Connection): | ||
| 9 | def __init__(self, host, port): | ||
| 10 | self._loop = asyncio.get_event_loop() | ||
| 11 | self.log = logging.getLogger(self.__class__.__name__) | ||
| 12 | |||
| 13 | self.status = INITALIZED | ||
| 14 | |||
| 15 | self.transport = None | ||
| 16 | self.protocol = None | ||
| 17 | self.reader = None | ||
| 18 | self.writer = None | ||
| 19 | |||
| 20 | self.host = host | ||
| 21 | self.port = port | ||
| 22 | self.nick = 'warmachin49' | ||
| 23 | self.user = 'WarMachine' | ||
| 24 | |||
| 25 | self.server_info = { | ||
| 26 | 'host': '' | ||
| 27 | } | ||
| 28 | |||
| 29 | async def connect(self): | ||
| 30 | self.log.info('Connecting to {}:{}'.format(self.host, self.port)) | ||
| 31 | |||
| 32 | self.reader, self.writer = await asyncio.open_connection( | ||
| 33 | self.host, self.port) | ||
| 34 | |||
| 35 | self.writer.write('NICK {} r\n'.format(self.nick).encode()) | ||
| 36 | self.writer.write('USER {} 8 * :War Machine\r\n'.format( | ||
| 37 | self.user).encode()) | ||
| 38 | |||
| 39 | return True | ||
| 40 | |||
| 41 | @asyncio.coroutine | ||
| 42 | def read(self): | ||
| 43 | if self.reader.at_eof(): | ||
| 44 | raise Exception('eof') | ||
| 45 | if self.reader: | ||
| 46 | message = yield from self.reader.readline() | ||
| 47 | |||
| 48 | # if not self.server_info['host']: | ||
| 49 | # self.server_info['host'] = message.split(' ')[0].replace(':', '') | ||
| 50 | |||
| 51 | # if message.startswith('PING'): | ||
| 52 | # yield from self.send_pong() | ||
| 53 | # return | ||
| 54 | |||
| 55 | return message.decode().strip() | ||
| 56 | |||
| 57 | async def send_pong(self): | ||
| 58 | msg = 'PONG :{}'.format(self.server_info['host']) | ||
| 59 | return self.writer.write(msg) | ||
| 60 | |||
| 61 | @property | ||
| 62 | @memoize | ||
| 63 | def id(self): | ||
| 64 | return 'asdfasdf' | ||
diff --git a/warmachine/connections/slack.py b/warmachine/connections/slack.py new file mode 100644 index 0000000..b1a5390 --- /dev/null +++ b/warmachine/connections/slack.py | |||
| @@ -0,0 +1,281 @@ | |||
| 1 | import asyncio | ||
| 2 | import json | ||
| 3 | import logging | ||
| 4 | from pprint import pformat | ||
| 5 | from urllib.parse import urlencode | ||
| 6 | |||
| 7 | import websockets | ||
| 8 | |||
| 9 | from .base import Connection, INITALIZED, CONNECTED | ||
| 10 | |||
| 11 | |||
| 12 | #: Define slack as a config section prefix | ||
| 13 | __config_prefix__ = 'slack' | ||
| 14 | |||
| 15 | |||
| 16 | class SlackWS(Connection): | ||
| 17 | def __init__(self, options, *args, **kwargs): | ||
| 18 | super().__init__(*args, **kwargs) | ||
| 19 | |||
| 20 | self._loop = asyncio.get_event_loop() | ||
| 21 | self.log = logging.getLogger(self.__class__.__name__) | ||
| 22 | self.host = None | ||
| 23 | self.token = options['token'] | ||
| 24 | |||
| 25 | self._info = None | ||
| 26 | self.reconnect_url = '' | ||
| 27 | |||
| 28 | self.channel_map = {} # channel and im info keyed by the slack id | ||
| 29 | self.user_map = {} # user info keyed by their slack id | ||
| 30 | self.user_nick_to_id = {} # slack user id mapped to the (nick)name | ||
| 31 | |||
| 32 | self.ws = None | ||
| 33 | |||
| 34 | self.status = INITALIZED | ||
| 35 | |||
| 36 | async def connect(self): | ||
| 37 | self.host = self.authenticate() | ||
| 38 | self.log.info('Connecting to {}'.format(self.host)) | ||
| 39 | self.ws = await websockets.connect(self.host) | ||
| 40 | |||
| 41 | async def read(self): | ||
| 42 | if self.ws: | ||
| 43 | message = json.loads(await self.ws.recv()) | ||
| 44 | |||
| 45 | # Slack is acknowledging a message was sent. Do nothing | ||
| 46 | if 'type' not in message and 'reply_to' in message: | ||
| 47 | # {'ok': True, | ||
| 48 | # 'reply_to': 1, | ||
| 49 | # 'text': "['!whois', 'synic']", | ||
| 50 | # 'ts': '1469743355.000150'} | ||
| 51 | return | ||
| 52 | |||
| 53 | # Handle actual messages | ||
| 54 | elif message['type'] == 'message' and 'subtype' not in message: | ||
| 55 | return await self.process_message(message) | ||
| 56 | else: | ||
| 57 | if 'subtype' in message: | ||
| 58 | # This is a message with a subtype and should be processed | ||
| 59 | # differently | ||
| 60 | msgtype = '{}_{}'.format( | ||
| 61 | message['type'], message['subtype']) | ||
| 62 | else: | ||
| 63 | msgtype = message['type'] | ||
| 64 | |||
| 65 | # Look for on_{type} methods to pass the dictionary to for | ||
| 66 | # additional processing | ||
| 67 | func_name = 'on_{}'.format(msgtype) | ||
| 68 | if hasattr(self, func_name): | ||
| 69 | getattr(self, func_name)(message) | ||
| 70 | else: | ||
| 71 | self.log.debug('{} does not exist for message: {}'.format( | ||
| 72 | func_name, message)) | ||
| 73 | |||
| 74 | async def say(self, message, destination_id): | ||
| 75 | """ | ||
| 76 | Say something in the provided channel or IM by id | ||
| 77 | """ | ||
| 78 | await self._send(json.dumps({ | ||
| 79 | 'id': 1, # TODO: this should be a get_msgid call or something | ||
| 80 | 'type': 'message', | ||
| 81 | 'channel': destination_id, | ||
| 82 | 'text': message | ||
| 83 | })) | ||
| 84 | |||
| 85 | async def _send(self, message): | ||
| 86 | """ | ||
| 87 | Send ``message`` to the connected slack server | ||
| 88 | """ | ||
| 89 | await self.ws.send(message) | ||
| 90 | |||
| 91 | def authenticate(self): | ||
| 92 | """ | ||
| 93 | Populate ``self._info`` | ||
| 94 | |||
| 95 | Returns: | ||
| 96 | str: websocket url to connect to | ||
| 97 | """ | ||
| 98 | import urllib.request | ||
| 99 | url = 'https://slack.com/api/rtm.start?{}'.format( | ||
| 100 | urlencode( | ||
| 101 | {'token': | ||
| 102 | self.token})) | ||
| 103 | self.log.debug('Connecting to {}'.format(url)) | ||
| 104 | req = urllib.request.Request(url) | ||
| 105 | |||
| 106 | r = urllib.request.urlopen(req).read().decode('utf-8') | ||
| 107 | self._info = json.loads(r) | ||
| 108 | |||
| 109 | if not self._info.get('ok', True): | ||
| 110 | raise Exception('Slack Error: {}'.format( | ||
| 111 | self._info.get('error', 'Unknown Error'))) | ||
| 112 | |||
| 113 | self.process_connect_info() | ||
| 114 | |||
| 115 | self.log.debug('Got websocket url: {}'.format(self._info.get('url'))) | ||
| 116 | return self._info.get('url') | ||
| 117 | |||
| 118 | def process_connect_info(self): | ||
| 119 | """ | ||
| 120 | Processes the connection info provided by slack | ||
| 121 | """ | ||
| 122 | if not self._info: | ||
| 123 | return | ||
| 124 | |||
| 125 | self.status = CONNECTED | ||
| 126 | |||
| 127 | # Map users | ||
| 128 | for u in self._info.get('users', []): | ||
| 129 | self.user_map[u['id']] = u | ||
| 130 | self.user_nick_to_id[u['name']] = u['id'] | ||
| 131 | |||
| 132 | # Map IM | ||
| 133 | for i in self._info.get('ims', []): | ||
| 134 | self.channel_map[i['id']] = i | ||
| 135 | |||
| 136 | # Map Channels | ||
| 137 | for c in self._info.get('channels', []): | ||
| 138 | self.channel_map[c['id']] = c | ||
| 139 | |||
| 140 | async def process_message(self, msg): | ||
| 141 | # Built-in !whois action | ||
| 142 | if 'text' not in msg: | ||
| 143 | raise Exception(msg) | ||
| 144 | if msg['text'].startswith('!whois'): | ||
| 145 | nicknames = msg['text'].split(' ')[1:] | ||
| 146 | for n in nicknames: | ||
| 147 | await self.say(pformat(self.user_map[self.user_nick_to_id[n]]), | ||
| 148 | msg['channel']) | ||
| 149 | return | ||
| 150 | |||
| 151 | retval = { | ||
| 152 | 'sender': msg['user'], | ||
| 153 | 'channel': msg['channel'], | ||
| 154 | 'message': msg['text'] | ||
| 155 | } | ||
| 156 | return retval | ||
| 157 | |||
| 158 | def on_user_change(self, msg): | ||
| 159 | """ | ||
| 160 | The user_change event is sent to all connections for a team when a team | ||
| 161 | member updates their profile or data. Clients can use this to update | ||
| 162 | their local cache of team members. | ||
| 163 | |||
| 164 | https://api.slack.com/events/user_change | ||
| 165 | """ | ||
| 166 | user_info = msg['user'] | ||
| 167 | old_nick = self.user_map[user_info['id']]['nick'] | ||
| 168 | |||
| 169 | self.user_map[user_info['id']] = user_info | ||
| 170 | |||
| 171 | # Update the nick mapping if the user changed their nickname | ||
| 172 | if old_nick != user_info['nick']: | ||
| 173 | del self.user_nick_to_id[old_nick] | ||
| 174 | self.user_nick_to_id[user_info['nick']] = user_info['id'] | ||
| 175 | |||
| 176 | def on_reconnect_url(self, msg): | ||
| 177 | """ | ||
| 178 | The reconnect_url event is currently unsupported and experimental. | ||
| 179 | |||
| 180 | https://api.slack.com/events/reconnect_url | ||
| 181 | """ | ||
| 182 | # self.reconnect_url = msg['url'] | ||
| 183 | # self.log.debug('updated_reconnect_url: {}'.format(self.reconnect_url)) | ||
| 184 | |||
| 185 | def on_presence_change(self, msg): | ||
| 186 | self.log.debug('updated_presence: {} ({}) was: {} is_now: {}'.format( | ||
| 187 | msg['user'], self.user_map[msg['user']]['name'], | ||
| 188 | self.user_map[msg['user']]['presence'], | ||
| 189 | msg['presence'] | ||
| 190 | )) | ||
| 191 | self.user_map[msg['user']]['presence'] = msg['presence'] | ||
| 192 | |||
| 193 | async def on_group_join(self, channel): | ||
| 194 | """ | ||
| 195 | The group_joined event is sent to all connections for a user when that | ||
| 196 | user joins a private channel. In addition to this message, all existing | ||
| 197 | members of the private channel will receive a group_join message event. | ||
| 198 | |||
| 199 | https://api.slack.com/events/group_joined | ||
| 200 | """ | ||
| 201 | # { | ||
| 202 | # 'channel': { | ||
| 203 | # 'members': ['U0286NL58', 'U1U05AF5J'], | ||
| 204 | # 'id': 'G1W837CGP', | ||
| 205 | # 'is_group': True, | ||
| 206 | # 'is_archived': False, | ||
| 207 | # 'latest': { | ||
| 208 | # 'user': 'U0286NL58', | ||
| 209 | # 'subtype': 'group_join', | ||
| 210 | # 'ts': '1469746594 .000002', | ||
| 211 | # 'type': 'message', | ||
| 212 | # 'text': '<@U0286NL58|jason> has joined the group' | ||
| 213 | # }, | ||
| 214 | # 'is_mpim': False, | ||
| 215 | # 'unread_count': 0, | ||
| 216 | # 'purpose': { | ||
| 217 | # 'creator': '', | ||
| 218 | # 'value': '', | ||
| 219 | # 'last_set': 0 | ||
| 220 | # }, | ||
| 221 | # 'is_open': True, | ||
| 222 | # 'topic': { | ||
| 223 | # 'creator': '', | ||
| 224 | # 'value': '', | ||
| 225 | # 'last_set': 0 | ||
| 226 | # }, | ||
| 227 | # 'creator': 'U0286NL58', | ||
| 228 | # 'unread_count_display': 0, | ||
| 229 | # 'name': 'wm-test', | ||
| 230 | # 'last_read': '1469746594.000002', | ||
| 231 | # 'created': 1469746594 | ||
| 232 | # }, | ||
| 233 | # 'type': 'group_joined' | ||
| 234 | # } | ||
| 235 | |||
| 236 | def on_message_message_changed(self, msg): | ||
| 237 | """ | ||
| 238 | A message_changed message is sent when a message in a channel is edited | ||
| 239 | using the chat.update method. The message property contains the updated | ||
| 240 | message object. | ||
| 241 | |||
| 242 | When clients receive this message type, they should look for an | ||
| 243 | existing message with the same message.ts in that channel. If they | ||
| 244 | find one the existing message should be replaced with the new one. | ||
| 245 | |||
| 246 | https://api.slack.com/events/message/message_changed | ||
| 247 | """ | ||
| 248 | # { | ||
| 249 | # 'hidden': True, | ||
| 250 | # 'event_ts': '1469748743.218081', | ||
| 251 | # 'subtype': 'message_changed', | ||
| 252 | # 'message': { | ||
| 253 | # 'attachments': [{ | ||
| 254 | # 'id': 1, | ||
| 255 | # 'image_width': 800, | ||
| 256 | # 'fallback': '800x450px image', | ||
| 257 | # 'from_url': | ||
| 258 | # 'http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif', | ||
| 259 | # 'image_bytes': 4847496, | ||
| 260 | # 'image_url': | ||
| 261 | # 'http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif', | ||
| 262 | # 'image_height': 450, | ||
| 263 | # 'is_animated': True | ||
| 264 | # }], | ||
| 265 | # 'type': 'message', | ||
| 266 | # 'ts': '1469748743.000019', | ||
| 267 | # 'text': | ||
| 268 | # '<http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif>', | ||
| 269 | # 'user': 'U1U05AF5J' | ||
| 270 | # }, | ||
| 271 | # 'channel': 'G1W837CGP', | ||
| 272 | # 'ts': '1469748743.000020', | ||
| 273 | # 'type': 'message', | ||
| 274 | # 'previous_message': { | ||
| 275 | # 'type': 'message', | ||
| 276 | # 'ts': '1469748743.000019', | ||
| 277 | # 'text': | ||
| 278 | # '<http://media1.giphy.com/media/3o85fPE3Irg8Wazl9S/giphy.gif>', | ||
| 279 | # 'user': 'U1U05AF5J' | ||
| 280 | # } | ||
| 281 | # } | ||
diff --git a/warmachine/utils/__init__.py b/warmachine/utils/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/warmachine/utils/__init__.py | |||
diff --git a/warmachine/utils/decorators.py b/warmachine/utils/decorators.py new file mode 100644 index 0000000..e049e79 --- /dev/null +++ b/warmachine/utils/decorators.py | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | from collections import Hashable | ||
| 2 | import functools | ||
| 3 | from hashlib import sha1 as hash_ | ||
| 4 | import logging | ||
| 5 | |||
| 6 | |||
| 7 | class memoize(object): | ||
| 8 | """ | ||
| 9 | Decorator that caches a function's return value each time it is called with | ||
| 10 | the same arguments. | ||
| 11 | """ | ||
| 12 | def __init__(self, func): | ||
| 13 | self.func = func | ||
| 14 | self.cache = {} | ||
| 15 | self.log = logging.getLogger('memoize') | ||
| 16 | |||
| 17 | @classmethod | ||
| 18 | def _hash(cls, string): | ||
| 19 | return hash_(string.encode()).hexdigest() | ||
| 20 | |||
| 21 | def __call__(self, *args, **kwargs): | ||
| 22 | # if not isinstance(args, Hashable) or not isinstance(kwargs, Hashable): | ||
| 23 | # self.log.debug('Uncacheable') | ||
| 24 | # return self.func(*args, **kwargs) | ||
| 25 | |||
| 26 | h = self._hash(str(args) + str(kwargs)) | ||
| 27 | if h in self.cache: | ||
| 28 | self.log.debug('Using cached value for {}({}, {})'.format( | ||
| 29 | self.func.__name__, ', '.join(str(a) for a in args), | ||
| 30 | ','.join('{}={} '.format(k, v) for k, v in kwargs.items()))) | ||
| 31 | return self.cache[h] | ||
| 32 | else: | ||
| 33 | self.log.debug('Caching value') | ||
| 34 | value = self.func(*args, **kwargs) | ||
| 35 | self.cache[h] = value | ||
| 36 | |||
| 37 | return value | ||
| 38 | |||
| 39 | def __get__(self, obj, objtype): | ||
| 40 | return functools.partial(self.__call__, obj) | ||