import functools
import inspect
import json
import logging
import pprint
import sys
import traceback
from docopt import docopt, DocoptExit
from slacker import Slacker
import websocket
from . import testing # noqa
from ._version import __version__ # noqa
# Message server will reject a message longer than 16kbs
# or 4000 characters. See https://api.slack.com/rtm#limits
SLACK_MESSAGE_LIMIT = 4000
def _dual_decorator(func):
"""This is a decorator that converts a paramaterized decorator for
no-param use.
source: http://stackoverflow.com/questions/3888158
"""
@functools.wraps(func)
def inner(*args, **kwargs):
if ((len(args) == 1 and not kwargs and callable(args[0])
and not (type(args[0]) == type and issubclass(args[0], BaseException)))):
return func()(args[0])
elif len(args) == 2 and inspect.isclass(args[0]) and callable(args[1]):
return func(args[0], **kwargs)(args[1])
else:
return func(*args, **kwargs)
return inner
def help(opts, bot, _):
"""Usage: help [<command>]
With no arguments, print the form of all supported commands.
With an argument, print a detailed explanation of a command.
"""
command = opts['<command>']
if command is None:
return bot.help_text()
if command not in bot.commands:
return "%r is not a known command" % command
return bot.commands[command].__doc__
class _CommandMeta(type):
"""
If the commands dict is a class field on Bot, then all subclasses will share one registry.
This metaclass initializes a separate registry on each class.
"""
def __new__(cls, name, bases, dct):
new_cls = super(_CommandMeta, cls).__new__(cls, name, bases, dct)
new_cls.commands = {}
return new_cls
[docs]class Bot(object):
"""
A Bot connects to Slack using the `RTM API <https://api.slack.com/rtm>`__
and responds to public messages that are directed to it with username-
or at-mentions.
Manage the Bot's channels in Slack itself with the `/join` command.
A Bot can be in multiple Slack channels (though state is not isolated by channel).
"""
__metaclass__ = _CommandMeta
@classmethod
@_dual_decorator
[docs] def command(cls, name=None):
"""
A decorator to convert a function to a command.
A command's docstring must be a docopt usage string.
See docopt.org for what it supports.
Commands receive three arguments:
* opts: a dictionary output by docopt
* bot: the Bot instance handling the command (eg for storing state between commands)
* event: the Slack event that triggered the command (eg for finding the message's sender)
Additional options may be passed in as keyword arguments:
* name: the string used to execute the command (no spaces allowed)
They must return one of three things:
* a string of response text. It will be sent via the RTM api to the channel where
the bot received the message. Slack will format it as per https://api.slack.com/docs/message-formatting.
* None, to send no response.
* a dictionary of kwargs representing a message to send via https://api.slack.com/methods/chat.postMessage.
Use this to send more complex messages, such as those with custom link text or DMs.
For example, to respond with a DM containing custom link text, return
`{'text': '<http://example.com|my text>', 'channel': event['user'], 'username': bot.name}`.
Note that this api has higher latency than the RTM api; use it only when necessary.
"""
# adapted from https://github.com/docopt/docopt/blob/master/examples/interactive_example.py
def decorator(func):
@functools.wraps(func)
def _cmd_wrapper(rest, *args, **kwargs):
try:
usage = _cmd_wrapper.__doc__.partition('\n')[0]
opts = docopt(usage, rest)
except (SystemExit, DocoptExit) as e:
# opts did not match
return str(e)
return func(opts, *args, **kwargs)
cls.commands[name or func.__name__] = _cmd_wrapper
return _cmd_wrapper
return decorator
@classmethod
[docs] def help_text(cls):
"""Return a slack-formatted list of commands with their usage."""
docs = [cmd_func.__doc__ for cmd_func in cls.commands.values()]
# Don't want to include 'usage: ' or explanation.
usage_lines = [doc.partition('\n')[0] for doc in docs]
terse_lines = [line[len('Usage: '):] for line in usage_lines]
terse_lines.sort()
return '\n'.join(['Available commands:\n'] + terse_lines)
[docs] def __init__(self, slack_token, config):
"""
Do not override this to perform implementation-specific setup;
use :func:`prepare_bot` instead.
No IO will be done until :func:`run_forever` is called (unless :func:`prepare_bot`
is overridden to perform some).
:param slack_token: a Slack api token.
:param config: an arbitrary dictionary for implementation-specific configuration.
The same object is stored as the `config` attribute and passed to prepare methods.
"""
#: the same config dictionary passed to init.
self.config = config
self._current_message_id = 0
#: a Logger (``logging.getLogger(__name__)``).
self.log = logging.getLogger(__name__)
# This doesn't perform IO.
#: a `Slacker <https://github.com/os/slacker>`__ instance created with `slack_token`.
self.slack = Slacker(slack_token)
#: the bot's Slack id.
#: Not available until :func:`prepare_connection`.
self.id = None
#: the bot's Slack name.
#: Not available until :func:`prepare_connection`.
self.name = None
#: the bot's Slack mention, equal to ``<@%s> % self.id`` .
#: Not available until :func:`prepare_connection`.
self.my_mention = None
#: a `WebSocketApp <https://github.com/liris/websocket-client>`__ instance.
#: Not available until :func:`prepare_connection`.
self.ws = None
self.prepare_bot(self.config)
[docs] def prepare_bot(self, config):
"""
Override to perform implementation-specific setup.
This is called once by :func:`__init__` and is not called on connection restarts.
"""
pass
[docs] def prepare_connection(self, config):
"""
Override to perform per-connection setup.
This is called by run_forever and on connection restarts.
"""
pass
[docs] def run_forever(self):
"""Run the bot, blocking forever."""
res = self.slack.rtm.start()
self.log.info("current channels: %s",
','.join(c['name'] for c in res.body['channels']
if c['is_member']))
self.id = res.body['self']['id']
self.name = res.body['self']['name']
self.my_mention = "<@%s>" % self.id
self.ws = websocket.WebSocketApp(
res.body['url'],
on_message=self._on_message,
on_error=self._on_error,
on_close=self._on_close,
on_open=self._on_open)
self.prepare_connection(self.config)
self.ws.run_forever()
def _bot_identifier(self, message):
"""Return the identifier used to address this bot in this message.
If one is not found, return None.
:param message: a message dict from the slack api.
"""
text = message['text']
formatters = [
lambda identifier: "%s " % identifier,
lambda identifier: "%s:" % identifier,
]
my_identifiers = [formatter(identifier) for identifier in [self.name, self.my_mention] for formatter in formatters]
for identifier in my_identifiers:
if text.startswith(identifier):
self.log.debug("sent to me:\n%s", pprint.pformat(message))
return identifier
return None
def _handle_command_response(self, res, event):
"""Either send a message (choosing between rtm and postMessage) or ignore the response.
:param event: a slacker event dict
:param res: a string, a dict, or None.
See the command docstring for what these represent.
"""
response_handler = None
if isinstance(res, basestring):
response_handler = functools.partial(self._send_rtm_message, event['channel'])
elif isinstance(res, dict):
response_handler = self._send_api_message
if response_handler is not None:
response_handler(res)
def _handle_long_response(self, res):
"""Splits messages that are too long into multiple events
:param res: a slack response string or dict
"""
is_rtm_message = isinstance(res, basestring)
is_api_message = isinstance(res, dict)
if is_rtm_message:
text = res
elif is_api_message:
text = res['text']
message_length = len(text)
if message_length <= SLACK_MESSAGE_LIMIT:
return [res]
remaining_str = text
responses = []
while remaining_str:
less_than_limit = len(remaining_str) < SLACK_MESSAGE_LIMIT
if less_than_limit:
last_line_break = None
else:
last_line_break = remaining_str[:SLACK_MESSAGE_LIMIT].rfind('\n')
if is_rtm_message:
responses.append(remaining_str[:last_line_break])
elif is_api_message:
template = res.copy()
template['text'] = remaining_str[:last_line_break]
responses.append(template)
if less_than_limit:
remaining_str = None
else:
remaining_str = remaining_str[last_line_break:]
self.log.debug("_handle_long_response: splitting long response %s, returns: \n %s",
pprint.pformat(res), pprint.pformat(responses))
return responses
def _send_rtm_message(self, channel_id, text):
"""Send a Slack message to a channel over RTM.
:param channel_id: a slack channel id.
:param text: a slack message. Serverside formatting is done
in a similar way to normal user message; see
`Slack's docs <https://api.slack.com/docs/formatting>`__.
"""
message = {
'id': self._current_message_id,
'type': 'message',
'channel': channel_id,
'text': text,
}
self.ws.send(json.dumps(message))
self._current_message_id += 1
def _send_api_message(self, message):
"""Send a Slack message via the chat.postMessage api.
:param message: a dict of kwargs to be passed to slacker.
"""
self.slack.chat.post_message(**message)
self.log.debug("sent api message %r", message)
# Websocket callbacks.
def _on_message(self, ws, raw_event):
try:
event = json.loads(raw_event)
if 'type' not in event or event['type'] != 'message':
return
if 'text' not in event:
# These are mostly changed messages, which we don't respond to right now.
return
identifier = self._bot_identifier(event)
if not identifier:
return
body = event['text'].partition(identifier)[2].strip()
cmd, _, rest = body.partition(' ')
if cmd in self.commands:
try:
res = self.commands[cmd](rest, self, event)
except Exception as e:
self.log.exception("%s while handling %r", e, body)
# Send the exception and the final line of the traceback.
# TODO this doesn't always pick out the right line.
t, v, tb = sys.exc_info()
res = ''.join(traceback.format_exception_only(t, v))
tb_entries = traceback.extract_tb(tb, 3)
res += ''.join(traceback.format_list(tb_entries[2:]))
else:
res = "Unrecognized command.\n%s" % self.help_text()
self.log.debug("received command response %r", res)
responses = self._handle_long_response(res)
for r in responses:
self._handle_command_response(r, event)
except Exception as e:
# websocket-client swallows exceptions in callbacks
self.log.exception("%s during _on_message. event:\n%s", e, pprint.pformat(raw_event))
def _on_error(self, ws, error):
self.log.error(error)
def _on_close(self, ws, code, reason):
self.log.warning("websocket closed. code: %r, reason: %r", code, reason)
# Attempt to reconnect.
# No need to reset _current_message_id: slack just requires ids that are unique per session.
self.run_forever()
def _on_open(self, ws):
self.log.info("websocket opened")