├── .gitignore ├── LICENSE ├── README.md ├── example ├── .env.example ├── .gitignore ├── Procfile ├── manage.py └── rot13bot │ ├── __init__.py │ ├── asgi.py │ ├── consumers.py │ ├── routing.py │ └── settings.py ├── setup.py └── slackline ├── cli.py └── slack_protocol.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Jacob Kaplan-Moss 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of slackline nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slackline - Channels for Slack Bots 2 | 3 | Slackline lets you write [Slack Bot Users](https://api.slack.com/bot-users) 4 | using Django and [Channels](http://channels.readthedocs.org/). Specifically, 5 | Slackline speaks Slack's [Real Time Messaging API](https://api.slack.com/rtm), 6 | and pushes those messages onto a channel for your app to consume. 7 | 8 | **This is a work in progress, it probably doesn't work very well yet, and is 9 | likely to change substantially before I release it.** If you want to give it 10 | a try anyway, these steps might work: 11 | 12 | 1. Create a Slack bot user (see https://api.slack.com/bot-users). 13 | 2. `python setup.py develop` 14 | 3. `pip install channels` 15 | 4. `cd example`, then create a `.env` with your Slack bot token and Redis URL. 16 | 5. `foreman start` 17 | 6. :fingers-crossed: -------------------------------------------------------------------------------- /example/.env.example: -------------------------------------------------------------------------------- 1 | SLACK_TOKEN=xoxb-REPLACE-WITH-BOT-TOKEN 2 | REDIS_URL=redis://replaceme -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /example/Procfile: -------------------------------------------------------------------------------- 1 | slackline: slackline rot13bot.asgi:channel_layer --log-level=debug 2 | worker: python manage.py runworker -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rot13bot.settings") 7 | from django.core.management import execute_from_command_line 8 | execute_from_command_line(sys.argv) 9 | -------------------------------------------------------------------------------- /example/rot13bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobian/slackline/d69436d546e6d3f374a4b11f55462cc24113878a/example/rot13bot/__init__.py -------------------------------------------------------------------------------- /example/rot13bot/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | from channels.asgi import get_channel_layer 3 | 4 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "rot13bot.settings") 5 | channel_layer = get_channel_layer() 6 | -------------------------------------------------------------------------------- /example/rot13bot/consumers.py: -------------------------------------------------------------------------------- 1 | import re 2 | import codecs 3 | import logging 4 | 5 | import slackclient 6 | 7 | log = logging.getLogger(__name__) 8 | rot13_pattern = re.compile(r'rot13[: ]+?(.*)') 9 | 10 | def message_consumer(message): 11 | log.debug("message: %s", message['event']) 12 | slack = slackclient.SlackClient(message['slack_token']) 13 | m = rot13_pattern.match(message["event"]["text"]) 14 | if m: 15 | rotated = codecs.encode(m.group(1), 'rot13') 16 | reply = slack.api_call('chat.postMessage', 17 | channel = message["event"]["channel"], 18 | text = rotated, 19 | as_user = True 20 | ) 21 | if not reply.get('ok'): 22 | log.error('slack error posting message: %s', reply) -------------------------------------------------------------------------------- /example/rot13bot/routing.py: -------------------------------------------------------------------------------- 1 | from channels.routing import route 2 | from .consumers import message_consumer 3 | 4 | channel_routing = [ 5 | route("slack.rtm.message", message_consumer) 6 | ] -------------------------------------------------------------------------------- /example/rot13bot/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | SECRET_KEY = "it's a secret to everyone" 4 | 5 | INSTALLED_APPS = ['channels'] 6 | 7 | CHANNEL_LAYERS = { 8 | "default": { 9 | "BACKEND": "asgi_redis.RedisChannelLayer", 10 | "CONFIG": {"hosts": [os.environ.get('REDIS_URL', 'redis://localhost:6379')]}, 11 | "ROUTING": "rot13bot.routing.channel_routing", 12 | }, 13 | } 14 | 15 | LOGGING = { 16 | 'version': 1, 17 | 'disable_existing_loggers': False, 18 | 'handlers': { 19 | 'console': { 20 | 'class': 'logging.StreamHandler', 21 | }, 22 | }, 23 | 'loggers': { 24 | 'django': { 25 | 'handlers': ['console'], 26 | 'propagate': True, 27 | 'level': 'INFO' 28 | }, 29 | 'rot13bot': { 30 | 'handlers': ['console'], 31 | 'propagate': False, 32 | 'level': 'DEBUG', 33 | }, 34 | }, 35 | } -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import find_packages, setup 4 | 5 | setup( 6 | name='slackline', 7 | version="1.0.0", 8 | url='FIXME', 9 | author='FIXME', 10 | author_email='FIXME', 11 | description='FIXME', 12 | long_description="FIXME", 13 | license='MIT', 14 | zip_safe=False, 15 | packages=find_packages(exclude=['example']), 16 | include_package_data=True, 17 | install_requires=[ 18 | 'asgiref>=0.10', 19 | 'twisted>=15.5', 20 | 'autobahn>=0.12', 21 | 'click>=6.6', 22 | 'slackclient>=1.0.0', 23 | 'pyopenssl>=16.0.0', 24 | 'service_identity>=16.0.0', 25 | ], 26 | entry_points={'console_scripts': [ 27 | 'slackline = slackline.cli:cli', 28 | ]}, 29 | ) 30 | -------------------------------------------------------------------------------- /slackline/cli.py: -------------------------------------------------------------------------------- 1 | import os 2 | import importlib 3 | import logging 4 | import sys 5 | 6 | import click 7 | import slackclient 8 | from autobahn.twisted.websocket import WebSocketClientFactory, connectWS 9 | from twisted.internet import reactor 10 | 11 | from .slack_protocol import SlackRealTimeMessagingFactory 12 | 13 | @click.command() 14 | @click.option('--log-level', type=click.Choice('critical error warning info debug'.split()), default='info') 15 | @click.option('--token', metavar='SLACK_TOKEN', envvar='SLACK_TOKEN', help='Slack API Token') 16 | @click.argument('channel_layer', required=True) 17 | def cli(log_level, token, channel_layer): 18 | # Configure logging 19 | logging.basicConfig(level = getattr(logging, log_level.upper()), 20 | format = "%(asctime)-15s %(levelname)-8s %(message)s") 21 | 22 | # load the channel layer - total copypasta from daphne 23 | sys.path.insert(0, ".") 24 | module_path, object_path = channel_layer.split(":", 1) 25 | channel_layer = importlib.import_module(module_path) 26 | for bit in object_path.split("."): 27 | channel_layer = getattr(channel_layer, bit) 28 | 29 | # Run the RTM client. This is two steps: we have to call Slack's rtm.start 30 | # REST API method, which returns a WebSocket URL we can then connect the 31 | # client to. 32 | # 33 | # FIXME: I don't know what happens if/when this socket gets closed; 34 | # presumably we'd want to reconnect, but I don't know how/where we'd do 35 | # that. 36 | slack_client = slackclient.SlackClient(token) 37 | reply = slack_client.api_call('rtm.start') 38 | if not reply.get('ok'): 39 | click.echo('Error starting RTM connection: {}'.format(reply)) 40 | sys.exit(1) 41 | 42 | factory = SlackRealTimeMessagingFactory(reply['url'], slack_token=token, channel_layer=channel_layer) 43 | connectWS(factory) 44 | reactor.run() -------------------------------------------------------------------------------- /slackline/slack_protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from autobahn.twisted.websocket import WebSocketClientFactory, WebSocketClientProtocol 4 | 5 | log = logging.getLogger(__name__) 6 | 7 | class SlackRealTimeMessagingProtocol(WebSocketClientProtocol): 8 | def onConnect(self, response): 9 | log.info("rtm connected peer=%s", response.peer) 10 | 11 | def onMessage(self, payload, isBinary): 12 | message = json.loads(payload.decode('utf-8')) 13 | log.info("message type=%s", message['type']) 14 | log.debug("message payload=%s", message) 15 | 16 | # FIXME: should this just be a general "slack.rtm.event" message? 17 | # FIXME: do we need reply_channel? We might not. 18 | self.factory.channel_layer.send("slack.rtm." + message['type'], { 19 | "slack_token": self.factory.slack_token, 20 | "event": message 21 | }) 22 | 23 | def onClose(self, wasClean, code, reason): 24 | log.info("rtm closed reason=%s", reason) 25 | 26 | class SlackRealTimeMessagingFactory(WebSocketClientFactory): 27 | protocol = SlackRealTimeMessagingProtocol 28 | 29 | def __init__(self, *args, **kwargs): 30 | self.slack_token = kwargs.pop('slack_token') 31 | self.channel_layer = kwargs.pop('channel_layer') 32 | super(SlackRealTimeMessagingFactory, self).__init__(*args, **kwargs) --------------------------------------------------------------------------------