diff --git a/Dockerfile b/Dockerfile index 8ed5e1c..7a57013 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,4 +2,4 @@ FROM python:2.7-slim ADD . /src WORKDIR /src RUN pip install -r requirements.txt -CMD python rtmbot.py +CMD python ./bot/app.py diff --git a/README.md b/README.md index 6aa6666..bd69fb8 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,13 @@ Visit [Beep Boop](https://beepboophq.com/docs/article/overview) to get the scoop Install dependencies ([virtualenv](http://virtualenv.readthedocs.org/en/latest/) is recommended.) pip install -r requirements.txt - export SLACK_TOKEN=; python rtmbot.py + export SLACK_TOKEN=; python ./bot/app.py Things are looking good if the console prints something like: Connected to team at https://.slack.com. -If you want change the logging level, prepend `export LOG_LEVEL=; ` to the `python rtmbot.py` command. +If you want change the logging level, prepend `export LOG_LEVEL=; ` to the `python ./bot/app.py` command. ### Run locally in Docker docker build -t starter-python-bot . @@ -31,15 +31,47 @@ If you want change the logging level, prepend `export LOG_LEVEL=; ` ### Run in BeepBoop If you have linked your local repo with the Beep Boop service (check [here](https://beepboophq.com/0_o/my-projects)), changes pushed to the remote master branch will automatically deploy. -## Customizing the Bot -If you are looking to change what the bot responds to and how they respond, take a look at the `plugins/starter.py` file. You'll see a function that gets called on all "message" type events, which has various regular expression matches that determine when the bot responds and how it responds. Each "Plugin" is registered with the RtmBot on startup by scanning the "plugins/" directory and communicates back to the RtmBot through variables like output[] and attachments[]. +### First Conversations +When you go through the `Add your App to Slack` flow, you'll setup a new Bot User and give them a handle (like @python-rtmbot). -For more information on the Plugins pattern see the sections "Add Plugins" and "Create Plugins" at: https://github.com/slackhq/python-rtmbot/blob/master/README.md +Here is an example interaction dialog that works with this bot: +``` +Joe Dev [3:29 PM] +hi @python-rtmbot -## Acknowledgements +Slacks PythonBot BOT [3:29 PM] +Nice to meet you, @randall.barnhart! -This code was forked from https://github.com/slackhq/python-rtmbot and utilizes the awesome https://github.com/slackhq/python-slackclient project by [@rawdigits](https://github.com/rawdigits). Please see https://github.com/slackhq/python-rtmbot/blob/master/README.md for -a description about the organization of this code and using the plugins architecture. +Joe Dev [3:30 PM] +help @python-rtmbot + +Slacks PythonBot BOT [3:30 PM] +I'm your friendly Slack bot written in Python. I'll ​*​_respond_​*​ to the following commands: +>`hi @python-rtmbot` - I'll respond with a randomized greeting mentioning your user. :wave: +> `@python-rtmbot joke` - I'll tell you one of my finest jokes, with a typing pause for effect. :laughing: +> `@python-rtmbot attachment` - I'll demo a post with an attachment using the Web API. :paperclip: + +Joe Dev [3:31 PM] +@python-rtmbot: joke + +Slacks PythonBot BOT [3:31 PM] +Why did the python cross the road? + +[3:31] +To eat the chicken on the other side! :laughing: +``` + +## Code Organization +If you want to add or change an event that the bot responds (e.g. when the bot is mentioned, when the bot joins a channel, when a user types a message, etc.), you can modify the `_handle_by_type` method in `event_handler.py`. + +If you want to change the responses, then you can modify the `messenger.py` class, and make the corresponding invocation in `event_handler.py`. + +The `slack_clients.py` module provides a facade of two different Slack API clients which can be enriched to access data from Slack that is needed by your Bot: + +1. [slackclient](https://github.com/slackhq/python-slackclient) - Realtime Messaging (RTM) API to Slack via a websocket connection. +2. [slacker](https://github.com/os/slacker) - Web API to Slack via RESTful methods. + +The `slack_bot.py` module implements and interface that is needed to run a multi-team bot using the Beep Boop Resource API client, by implementing an interface that includes `start()` and `stop()` methods and a function that spawns new instances of your bot: `spawn_bot`. It is the main run loop of your bot instance that will listen to a particular Slack team's RTM events, and dispatch them to the `event_handler`. ## License diff --git a/plugins/.gitkeep b/bot/__init__.py similarity index 100% rename from plugins/.gitkeep rename to bot/__init__.py diff --git a/bot/app.py b/bot/app.py new file mode 100755 index 0000000..ab9bd25 --- /dev/null +++ b/bot/app.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python + +import logging +import os + +from beepboop import resourcer +from beepboop import bot_manager + +from slack_bot import SlackBot +from slack_bot import spawn_bot + +logger = logging.getLogger(__name__) + + +if __name__ == "__main__": + + log_level = os.getenv("LOG_LEVEL", "INFO") + logging.basicConfig(format='%(asctime)s - %(levelname)s: %(message)s', level=log_level) + + slack_token = os.getenv("SLACK_TOKEN", "") + logging.info("token: {}".format(slack_token)) + + if slack_token == "": + logging.info("SLACK_TOKEN env var not set, expecting token to be provided by Resourcer events") + slack_token = None + botManager = bot_manager.BotManager(spawn_bot) + res = resourcer.Resourcer(botManager) + res.start() + else: + # only want to run a single instance of the bot in dev mode + bot = SlackBot(slack_token) + bot.start({}) diff --git a/bot/event_handler.py b/bot/event_handler.py new file mode 100644 index 0000000..bbe4f15 --- /dev/null +++ b/bot/event_handler.py @@ -0,0 +1,52 @@ +import json +import logging +import re + +logger = logging.getLogger(__name__) + + +class RtmEventHandler(object): + def __init__(self, slack_clients, msg_writer): + self.clients = slack_clients + self.msg_writer = msg_writer + + def handle(self, event): + + if 'type' in event: + self._handle_by_type(event['type'], event) + + def _handle_by_type(self, event_type, event): + # See https://api.slack.com/rtm for a full list of events + if event_type == 'error': + # error + self.msg_writer.write_error(event['channel'], json.dumps(event)) + elif event_type == 'message': + # message was sent to channel + self._handle_message(event) + elif event_type == 'channel_joined': + # you joined a channel + self.msg_writer.write_help_message(event['channel']) + elif event_type == 'group_joined': + # you joined a private group + self.msg_writer.write_help_message(event['channel']) + else: + pass + + def _handle_message(self, event): + # Filter out messages from the bot itself + if not self.clients.is_message_from_me(event['user']): + + msg_txt = event['text'] + + if self.clients.is_bot_mention(msg_txt): + # e.g. user typed: "@pybot tell me a joke!" + if 'help' in msg_txt: + self.msg_writer.write_help_message(event['channel']) + elif re.search('hi|hey|hello|howdy', msg_txt): + self.msg_writer.write_greeting(event['channel'], event['user']) + elif 'joke' in msg_txt: + self.msg_writer.write_joke(event['channel']) + elif 'attachment' in msg_txt: + self.msg_writer.demo_attachment(event['channel']) + else: + self.msg_writer.write_prompt(event['channel']) diff --git a/bot/messenger.py b/bot/messenger.py new file mode 100644 index 0000000..8522bc8 --- /dev/null +++ b/bot/messenger.py @@ -0,0 +1,61 @@ +import logging +import random + +logger = logging.getLogger(__name__) + + +class Messenger(object): + def __init__(self, slack_clients): + self.clients = slack_clients + + def send_message(self, channel_id, msg): + # in the case of Group and Private channels, RTM channel payload is a complex dictionary + if isinstance(channel_id, dict): + channel_id = channel_id['id'] + logger.debug('Sending msg: {} to channel: {}'.format(msg, channel_id)) + channel = self.clients.rtm.server.channels.find(channel_id) + channel.send_message("{}".format(msg.encode('ascii', 'ignore'))) + + def write_help_message(self, channel_id): + bot_uid = self.clients.bot_user_id() + txt = '{}\n{}\n{}\n{}'.format( + "I'm your friendly Slack bot written in Python. I'll *_respond_* to the following commands:", + "> `hi <@" + bot_uid + ">` - I'll respond with a randomized greeting mentioning your user. :wave:", + "> `<@" + bot_uid + "> joke` - I'll tell you one of my finest jokes, with a typing pause for effect. :laughing:", + "> `<@" + bot_uid + "> attachment` - I'll demo a post with an attachment using the Web API. :paperclip:") + self.send_message(channel_id, txt) + + def write_greeting(self, channel_id, user_id): + greetings = ['Hi', 'Hello', 'Nice to meet you', 'Howdy', 'Salutations'] + txt = '{}, <@{}>!'.format(random.choice(greetings), user_id) + self.send_message(channel_id, txt) + + def write_prompt(self, channel_id): + bot_uid = self.clients.bot_user_id() + txt = "I'm sorry, I didn't quite understand... Can I help you? (e.g. `<@" + bot_uid + "> help`)" + self.send_message(channel_id, txt) + + def write_joke(self, channel_id): + question = "Why did the python cross the road?" + self.send_message(channel_id, question) + self.clients.send_user_typing_pause(channel_id) + answer = "To eat the chicken on the other side! :laughing:" + self.send_message(channel_id, answer) + + + def write_error(self, channel_id, err_msg): + txt = ":face_with_head_bandage: my maker didn't handle this error very well:\n>```{}```".format(err_msg) + self.send_message(channel_id, txt) + + def demo_attachment(self, channel_id): + txt = "Beep Beep Boop is a ridiculously simple hosting platform for your Slackbots." + attachment = { + "pretext": "We bring bots to life. :sunglasses: :thumbsup:", + "title": "Host, deploy and share your bot in seconds.", + "title_link": "https://beepboophq.com/", + "text": txt, + "fallback": txt, + "image_url": "https://storage.googleapis.com/beepboophq/_assets/bot-1.22f6fb.png", + "color": "#7CD197", + } + self.clients.web.chat.post_message(channel_id, txt, attachments=[attachment], as_user='true') diff --git a/bot/slack_bot.py b/bot/slack_bot.py new file mode 100644 index 0000000..4ef4582 --- /dev/null +++ b/bot/slack_bot.py @@ -0,0 +1,80 @@ +import time +import logging +import traceback + +from slack_clients import SlackClients +from messenger import Messenger +from event_handler import RtmEventHandler + +logger = logging.getLogger(__name__) + + +def spawn_bot(): + return SlackBot() + + +class SlackBot(object): + def __init__(self, token=None): + """Creates Slacker Web and RTM clients with API Bot User token. + + Args: + token (str): Slack API Bot User token (for development token set in env) + """ + self.last_ping = 0 + self.keep_running = True + if token is not None: + self.clients = SlackClients(token) + + def start(self, resource): + """Creates Slack Web and RTM clients for the given Resource + using the provided API tokens and configuration, then connects websocket + and listens for RTM events. + + Args: + resource (dict of Resource JSON): See message payloads - https://beepboophq.com/docs/article/resourcer-api + """ + logger.debug('Starting bot for resource: {}'.format(resource)) + if 'resource' in resource and 'SlackBotAccessToken' in resource['resource']: + res_access_token = resource['resource']['SlackBotAccessToken'] + self.clients = SlackClients(res_access_token) + + if self.clients.rtm.rtm_connect(): + logging.info(u'Connected {} to {} team at https://{}.slack.com'.format( + self.clients.rtm.server.username, + self.clients.rtm.server.login_data['team']['name'], + self.clients.rtm.server.domain)) + + msg_writer = Messenger(self.clients) + event_handler = RtmEventHandler(self.clients, msg_writer) + + while self.keep_running: + for event in self.clients.rtm.rtm_read(): + try: + event_handler.handle(event) + except: + err_msg = traceback.format_exc() + logging.error('Unexpected error: {}'.format(err_msg)) + msg_writer.write_error(event['channel'], err_msg) + continue + + self._auto_ping() + time.sleep(.1) + + else: + logger.error('Failed to connect to RTM client with token: {}'.format(self.clients.token)) + + def _auto_ping(self): + # hard code the interval to 3 seconds + now = int(time.time()) + if now > self.last_ping + 3: + self.clients.rtm.server.ping() + self.last_ping = now + + def stop(self, resource): + """Stop any polling loops on clients, clean up any resources, + close connections if possible. + + Args: + resource (dict of Resource JSON): See message payloads - https://beepboophq.com/docs/article/resourcer-api + """ + self.keep_running = False diff --git a/bot/slack_clients.py b/bot/slack_clients.py new file mode 100644 index 0000000..a6c9789 --- /dev/null +++ b/bot/slack_clients.py @@ -0,0 +1,38 @@ + +import logging +import re +import time + +from slacker import Slacker +from slackclient import SlackClient + +logger = logging.getLogger(__name__) + + +class SlackClients(object): + def __init__(self, token): + self.token = token + + # Slacker is a Slack Web API Client + self.web = Slacker(token) + + # SlackClient is a Slack Websocket RTM API Client + self.rtm = SlackClient(token) + + def bot_user_id(self): + return self.rtm.server.login_data['self']['id'] + + def is_message_from_me(self, user): + return user == self.rtm.server.login_data['self']['id'] + + def is_bot_mention(self, message): + bot_user_name = self.rtm.server.login_data['self']['id'] + if re.search("@{}".format(bot_user_name), message): + return True + else: + return False + + def send_user_typing_pause(self, channel_id, sleep_time=3.0): + user_typing_json = {"type": "typing", "channel": channel_id} + self.rtm.server.send_to_websocket(user_typing_json) + time.sleep(sleep_time) diff --git a/plugins/starter.py b/plugins/starter.py deleted file mode 100644 index fde1562..0000000 --- a/plugins/starter.py +++ /dev/null @@ -1,62 +0,0 @@ -import time -import re -import random -import logging -crontable = [] -outputs = [] -attachments = [] -typing_sleep = 0 - -greetings = ['Hi friend!', 'Hello there.', 'Howdy!', 'Wazzzup!!!', 'Hi!', 'Hey.'] -help_text = "{}\n{}\n{}\n{}\n{}\n{}".format( - "I will respond to the following messages: ", - "`pybot hi` for a random greeting.", - "`pybot joke` for a question, typing indicator, then answer style joke.", - "`pybot attachment` to see a Slack attachment message.", - "`@` to demonstrate detecting a mention.", - "`pybot help` to see this again.") - -# regular expression patterns for string matching -p_bot_hi = re.compile("pybot[\s]*hi") -p_bot_joke = re.compile("pybot[\s]*joke") -p_bot_attach = re.compile("pybot[\s]*attachment") -p_bot_help = re.compile("pybot[\s]*help") - -def process_message(data): - logging.debug("process_message:data: {}".format(data)) - - if p_bot_hi.match(data['text']): - outputs.append([data['channel'], "{}".format(random.choice(greetings))]) - - elif p_bot_joke.match(data['text']): - outputs.append([data['channel'], "Why did the python cross the road?"]) - outputs.append([data['channel'], "__typing__", 5]) - outputs.append([data['channel'], "To eat the chicken on the other side! :laughing:"]) - - elif p_bot_attach.match(data['text']): - txt = "Beep Beep Boop is a ridiculously simple hosting platform for your Slackbots." - attachments.append([data['channel'], txt, build_demo_attachment(txt)]) - - elif p_bot_help.match(data['text']): - outputs.append([data['channel'], "{}".format(help_text)]) - - elif data['text'].startswith("pybot"): - outputs.append([data['channel'], "I'm sorry, I don't know how to: `{}`".format(data['text'])]) - - elif data['channel'].startswith("D"): # direct message channel to the bot - outputs.append([data['channel'], "Hello, I'm the BeepBoop python starter bot.\n{}".format(help_text)]) - -def process_mention(data): - logging.debug("process_mention:data: {}".format(data)) - outputs.append([data['channel'], "You really do care about me. :heart:"]) - -def build_demo_attachment(txt): - return { - "pretext" : "We bring bots to life. :sunglasses: :thumbsup:", - "title" : "Host, deploy and share your bot in seconds.", - "title_link" : "https://beepboophq.com/", - "text" : txt, - "fallback" : txt, - "image_url" : "https://storage.googleapis.com/beepboophq/_assets/bot-1.22f6fb.png", - "color" : "#7CD197", - } diff --git a/requirements.txt b/requirements.txt index d740102..9def97a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,10 @@ -requests -python-daemon -pyyaml -websocket-client -slackclient +beepboop==0.1.1 +docutils==0.12 +lockfile==0.12.2 +python-daemon==2.1.1 +PyYAML==3.11 +requests==2.9.1 +six==1.10.0 +slackclient==1.0.0 +slacker==0.9.9 +websocket-client==0.35.0 diff --git a/rtmbot.py b/rtmbot.py deleted file mode 100755 index 0f5eb72..0000000 --- a/rtmbot.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python - -import sys -sys.dont_write_bytecode = True - -import glob -import yaml -import json -import os -import sys -import time -import logging -import re -from argparse import ArgumentParser - -from slackclient import SlackClient - -def dbg(debug_string): - if debug: - logging.debug(debug_string) - -class RtmBot(object): - def __init__(self, token): - self.last_ping = 0 - self.token = token - self.bot_plugins = [] - self.slack_client = None - def connect(self): - """Convenience method that creates Server instance""" - self.slack_client = SlackClient(self.token) - self.slack_client.rtm_connect() - logging.info(u"Connected {} to {} team at https://{}.slack.com".format( - self.slack_client.server.username, - self.slack_client.server.login_data['team']['name'], - self.slack_client.server.domain)) - def start(self): - self.connect() - self.load_plugins() - while True: - for reply in self.slack_client.rtm_read(): - self.input(reply) - self.crons() - self.output() - self.autoping() - time.sleep(.1) - def autoping(self): - #hardcode the interval to 60 seconds - now = int(time.time()) - if now > self.last_ping + 60: - self.slack_client.server.ping() - self.last_ping = now - def isBotMention(self, message): - botUserName = self.slack_client.server.login_data['self']['id'] - if re.search("@{}".format(botUserName), message): - return True - else: - return False - def input(self, data): - # Make sure we're not responding to ourselves - if "user" in data and data['user'] != self.slack_client.server.login_data['self']['id']: - if "type" in data: - function_name = "process_" + data["type"] - dbg("got {}".format(function_name)) - if "text" in data and self.isBotMention(data["text"]): - function_name = "process_mention" - for plugin in self.bot_plugins: - plugin.register_jobs() - plugin.do(function_name, data) - def output(self): - for plugin in self.bot_plugins: - limiter = False - for output in plugin.do_output(): - channel = self.slack_client.server.channels.find(output[0]) - if channel != None and output[1] != None: - if limiter == True: - time.sleep(.1) - limiter = False - message = output[1].encode('ascii','ignore') - if message.startswith("__typing__"): - user_typing_json = { "type": "typing", "channel": channel.id} - logging.debug(user_typing_json) - self.slack_client.server.send_to_websocket(user_typing_json) - time.sleep(output[2]) - else: - channel.send_message("{}".format(message)) - limiter = True - for attachment in plugin.do_attachment(): - channel = self.slack_client.server.channels.find(attachment[0]) - if channel != None and attachment[1] != None: - attachments = [] - if attachment != None and attachment[2] != None: - attachments.append(attachment[2]) - attachments_json = json.dumps(attachments) - resp = self.slack_client.api_call("chat.postMessage", - text="{}".format(attachment[1]), - channel="{}".format(channel.id), - as_user="true", - attachments=attachments_json, - ) - logging.debug(resp) - def crons(self): - for plugin in self.bot_plugins: - plugin.do_jobs() - def load_plugins(self): - for plugin in glob.glob(directory+'/plugins/*'): - sys.path.insert(0, plugin) - sys.path.insert(0, directory+'/plugins/') - for plugin in glob.glob(directory+'/plugins/*.py') + glob.glob(directory+'/plugins/*/*.py'): - logging.info(plugin) - name = plugin.split('/')[-1][:-3] -# try: - self.bot_plugins.append(Plugin(name)) -# except: -# print "error loading plugin %s" % name - -class Plugin(object): - def __init__(self, name, plugin_config={}): - self.name = name - self.jobs = [] - self.module = __import__(name) - self.register_jobs() - self.outputs = [] - if 'setup' in dir(self.module): - self.module.setup() - def register_jobs(self): - if 'crontable' in dir(self.module): - for interval, function in self.module.crontable: - self.jobs.append(Job(interval, eval("self.module."+function))) - logging.debug("crontable: {}".format(self.module.crontable)) - self.module.crontable = [] - else: - self.module.crontable = [] - def do(self, function_name, data): - if function_name in dir(self.module): - #this makes the plugin fail with stack trace in debug mode - if not debug: - try: - eval("self.module."+function_name)(data) - except: - dbg("problem in module {} {}".format(function_name, data)) - else: - eval("self.module."+function_name)(data) - if "catch_all" in dir(self.module): - try: - self.module.catch_all(data) - except: - dbg("problem in catch all") - def do_jobs(self): - for job in self.jobs: - job.check() - def do_output(self): - output = [] - while True: - if 'outputs' in dir(self.module): - if len(self.module.outputs) > 0: - logging.debug("output from {}".format(self.module)) - output.append(self.module.outputs.pop(0)) - else: - break - else: - self.module.outputs = [] - return output - def do_attachment(self): - attachment = [] - while True: - if 'attachments' in dir(self.module): - if len(self.module.attachments) > 0: - logging.debug("attachments from {}".format(self.module)) - attachment.append(self.module.attachments.pop(0)) - else: - break - else: - self.module.attachments = [] - return attachment - -class Job(object): - def __init__(self, interval, function): - self.function = function - self.interval = interval - self.lastrun = 0 - def __str__(self): - return "{} {} {}".format(self.function, self.interval, self.lastrun) - def __repr__(self): - return self.__str__() - def check(self): - if self.lastrun + self.interval < time.time(): - if not debug: - try: - self.function() - except: - dbg("problem") - else: - self.function() - self.lastrun = time.time() - pass - -class UnknownChannel(Exception): - pass - - -def main_loop(): - - logging.info(directory) - try: - bot.start() - except KeyboardInterrupt: - sys.exit(0) - except: - logging.exception('OOPS') - - -if __name__ == "__main__": - - directory = os.path.dirname(sys.argv[0]) - if not directory.startswith('/'): - directory = os.path.abspath("{}/{}".format(os.getcwd(), - directory - )) - - log_level = os.getenv("LOG_LEVEL", "DEBUG") - logging.basicConfig(format='%(asctime)s - %(levelname)s: %(message)s', level=log_level) - debug = False - if log_level == "DEBUG": - debug = True - - slack_token = os.getenv("SLACK_TOKEN", "") - logging.info("token: {}".format(slack_token)) - if slack_token == "": - logging.error("SLACK_TOKEN env var not set!") - sys.exit(1) - bot = RtmBot(slack_token) - site_plugins = [] - files_currently_downloading = [] - job_hash = {} - - main_loop()