Merge remote-tracking branch 'origin/master'

This commit is contained in:
Eryn Wells 2016-05-17 10:20:34 -04:00
commit f2ab674906
20 changed files with 621 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
*.pyc
/rtmbot.conf
/plugins/**
/build/**
*.log
env
.tox
*.un~
0/
tests/.cache
.coverage
.cache

18
.travis.yml Normal file
View file

@ -0,0 +1,18 @@
sudo: false
language: python
python:
- "3.5"
env:
matrix:
- TOX_ENV=py27
- TOX_ENV=py34
- TOX_ENV=py35
- TOX_ENV=flake8
cache: pip
install:
- "travis_retry pip install setuptools --upgrade"
- "travis_retry pip install tox"
script:
- tox -e $TOX_ENV
after_script:
- cat .tox/$TOX_ENV/log/*.log

17
LICENSE.txt Normal file
View file

@ -0,0 +1,17 @@
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -0,0 +1,91 @@
python-rtmbot
=============
[![Build Status](https://travis-ci.org/slackhq/python-rtmbot.png)](https://travis-ci.org/slackhq/python-rtmbot)
[![Coverage Status](https://coveralls.io/repos/github/slackhq/python-rtmbot/badge.svg?branch=master)](https://coveralls.io/github/slackhq/python-rtmbot?branch=master)
A Slack bot written in python that connects via the RTM API.
Python-rtmbot is a callback based bot engine. The plugins architecture should be familiar to anyone with knowledge to the [Slack API](https://api.slack.com) and Python. The configuration file format is YAML.
Some differences to webhooks:
1. Doesn't require a webserver to receive messages
2. Can respond to direct messages from users
3. Logs in as a slack user (or bot)
4. Bot users must be invited to a channel
Dependencies
----------
* websocket-client https://pypi.python.org/pypi/websocket-client/
* python-slackclient https://github.com/slackhq/python-slackclient
Installation
-----------
1. Download the python-rtmbot code
git clone https://github.com/slackhq/python-rtmbot.git
cd python-rtmbot
2. Install dependencies ([virtualenv](http://virtualenv.readthedocs.org/en/latest/) is recommended.)
pip install -r requirements.txt
3. Configure rtmbot (https://api.slack.com/bot-users)
cp doc/example-config/rtmbot.conf .
vi rtmbot.conf
SLACK_TOKEN: "xoxb-11111111111-222222222222222"
*Note*: At this point rtmbot is ready to run, however no plugins are configured.
Add Plugins
-------
Plugins can be installed as .py files in the ```plugins/``` directory OR as a .py file in any first level subdirectory. If your plugin uses multiple source files and libraries, it is recommended that you create a directory. You can install as many plugins as you like, and each will handle every event received by the bot indepentently.
To install the example 'repeat' plugin
mkdir plugins/repeat
cp doc/example-plugins/repeat.py plugins/repeat
The repeat plugin will now be loaded by the bot on startup.
./rtmbot.py
Create Plugins
--------
####Incoming data
Plugins are callback based and respond to any event sent via the rtm websocket. To act on an event, create a function definition called process_(api_method) that accepts a single arg. For example, to handle incoming messages:
def process_message(data):
print data
This will print the incoming message json (dict) to the screen where the bot is running.
Plugins having a method defined as ```catch_all(data)``` will receive ALL events from the websocket. This is useful for learning the names of events and debugging.
####Outgoing data
Plugins can send messages back to any channel, including direct messages. This is done by appending a two item array to the outputs global array. The first item in the array is the channel ID and the second is the message text. Example that writes "hello world" when the plugin is started:
outputs = []
outputs.append(["C12345667", "hello world"])
*Note*: you should always create the outputs array at the start of your program, i.e. ```outputs = []```
####Timed jobs
Plugins can also run methods on a schedule. This allows a plugin to poll for updates or perform housekeeping during its lifetime. This is done by appending a two item array to the crontable array. The first item is the interval in seconds and the second item is the method to run. For example, this will print "hello world" every 10 seconds.
outputs = []
crontable = []
crontable.append([10, "say_hello"])
def say_hello():
outputs.append(["C12345667", "hello world"])
####Plugin misc
The data within a plugin persists for the life of the rtmbot process. If you need persistent data, you should use something like sqlite or the python pickle libraries.
####Todo:
Some rtm data should be handled upstream, such as channel and user creation. These should create the proper objects on-the-fly.

View file

@ -0,0 +1,3 @@
DEBUG: False
SLACK_TOKEN: "xoxb-111111111111-2222222222222222222"

76
doc/example-init/rtmbot.init Executable file
View file

@ -0,0 +1,76 @@
#!/bin/sh
### BEGIN INIT INFO
# Provides: exampledaemon
# Required-Start: $local_fs $remote_fs $network $syslog
# Required-Stop: $local_fs $remote_fs $network $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Example
# Description: Example start-stop-daemon - Debian
### END INIT INFO
NAME="rtmbot"
PATH="/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin"
APPDIR="/opt/rtmbot"
APPBIN="rtmbot.py"
APPARGS="--config ${APPDIR}/rtmbot.conf"
USER="ubuntu"
GROUP="ubuntu"
# Include functions
set -e
. /lib/lsb/init-functions
start() {
printf "Starting '$NAME'... "
start-stop-daemon --start --background --chuid "$USER:$GROUP" --make-pidfile --pidfile /var/run/$NAME.pid --exec "$APPDIR/$APPBIN" -- $APPARGS || true
printf "done\n"
}
#We need this function to ensure the whole process tree will be killed
killtree() {
local _pid=$1
local _sig=${2-TERM}
for _child in $(ps -o pid --no-headers --ppid ${_pid}); do
killtree ${_child} ${_sig}
done
kill -${_sig} ${_pid}
}
stop() {
printf "Stopping '$NAME'... "
[ -z `cat /var/run/$NAME.pid 2>/dev/null` ] || \
while test -d /proc/$(cat /var/run/$NAME.pid); do
killtree $(cat /var/run/$NAME.pid) 15
sleep 0.5
done
[ -z `cat /var/run/$NAME.pid 2>/dev/null` ] || rm /var/run/$NAME.pid
printf "done\n"
}
status() {
status_of_proc -p /var/run/$NAME.pid "" $NAME && exit 0 || exit $?
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart)
stop
start
;;
status)
status
;;
*)
echo "Usage: $NAME {start|stop|restart|status}" >&2
exit 1
;;
esac
exit 0

View file

@ -0,0 +1,9 @@
import time
outputs = []
def canary():
# NOTE: you must add a real channel ID for this to work
outputs.append(["D12345678", "bot started: " + str(time.time())])
canary()

View file

@ -0,0 +1,10 @@
import time
crontable = []
outputs = []
crontable.append([5, "say_time"])
def say_time():
# NOTE: you must add a real channel ID for this to work
outputs.append(["D12345678", time.time()])

View file

@ -0,0 +1,9 @@
crontable = []
outputs = []
def process_message(data):
if data['channel'].startswith("D"):
outputs.append([data['channel'], "from repeat1 \"{}\" in channel {}".format(
data['text'], data['channel'])]
)

View file

@ -0,0 +1,42 @@
from __future__ import print_function
import os
import pickle
outputs = []
crontabs = []
tasks = {}
FILE = "plugins/todo.data"
if os.path.isfile(FILE):
tasks = pickle.load(open(FILE, 'rb'))
def process_message(data):
global tasks
channel = data["channel"]
text = data["text"]
# only accept tasks on DM channels
if channel.startswith("D"):
if channel not in tasks.keys():
tasks[channel] = []
# do command stuff
if text.startswith("todo"):
tasks[channel].append(text[5:])
outputs.append([channel, "added"])
if text == "tasks":
output = ""
counter = 1
for task in tasks[channel]:
output += "%i) %s\n" % (counter, task)
counter += 1
outputs.append([channel, output])
if text == "fin":
tasks[channel] = []
if text.startswith("done"):
num = int(text.split()[1]) - 1
tasks[channel].pop(num)
if text == "show":
print(tasks)
pickle.dump(tasks, open(FILE, "wb"))

0
plugins/.gitkeep Normal file
View file

9
requirements-dev.txt Normal file
View file

@ -0,0 +1,9 @@
coveralls==1.1
ipdb==0.9.3
ipython==4.1.2
pdbpp==0.8.3
pytest>=2.8.2
pytest-cov==2.2.1
pytest-pythonpath>=0.3
testfixtures==4.9.1
tox>=1.8.0

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
requests
python-daemon
pyyaml
websocket-client
slackclient

26
rtmbot.py Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env python
import sys
from argparse import ArgumentParser
import yaml
from rtmbot import RtmBot
def parse_args():
parser = ArgumentParser()
parser.add_argument(
'-c',
'--config',
help='Full path to config file.',
metavar='path'
)
return parser.parse_args()
# load args with config path
args = parse_args()
config = yaml.load(open(args.config or 'rtmbot.conf', 'r'))
bot = RtmBot(config)
try:
bot.start()
except KeyboardInterrupt:
sys.exit(0)

1
rtmbot/__init__.py Normal file
View file

@ -0,0 +1 @@
from .core import *

225
rtmbot/core.py Executable file
View file

@ -0,0 +1,225 @@
#!/usr/bin/env python
import sys
import glob
import os
import time
import logging
from slackclient import SlackClient
sys.dont_write_bytecode = True
class RtmBot(object):
def __init__(self, config):
'''
Params:
- config (dict):
- SLACK_TOKEN: your authentication token from Slack
- BASE_PATH (optional: defaults to execution directory) RtmBot will
look in this directory for plugins.
- LOGFILE (optional: defaults to rtmbot.log) The filename for logs, will
be stored inside the BASE_PATH directory
- DEBUG (optional: defaults to False) with debug enabled, RtmBot will
break on errors
'''
# set the config object
self.config = config
# set slack token
self.token = config.get('SLACK_TOKEN')
# set working directory for loading plugins or other files
working_directory = os.path.dirname(sys.argv[0])
self.directory = self.config.get('BASE_PATH', working_directory)
if not self.directory.startswith('/'):
path = '{}/{}'.format(os.getcwd(), self.directory)
self.directory = os.path.abspath(path)
# establish logging
log_file = config.get('LOGFILE', 'rtmbot.log')
logging.basicConfig(filename=log_file,
level=logging.INFO,
format='%(asctime)s %(message)s')
logging.info('Initialized in: {}'.format(self.directory))
self.debug = self.config.get('DEBUG', False)
# initialize stateful fields
self.last_ping = 0
self.bot_plugins = []
self.slack_client = None
def _dbg(self, debug_string):
if self.debug:
logging.info(debug_string)
def connect(self):
"""Convenience method that creates Server instance"""
self.slack_client = SlackClient(self.token)
self.slack_client.rtm_connect()
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 start(self):
if 'DAEMON' in self.config:
if self.config.get('DAEMON'):
import daemon
with daemon.DaemonContext():
self._start()
self._start()
def autoping(self):
# hardcode the interval to 3 seconds
now = int(time.time())
if now > self.last_ping + 3:
self.slack_client.server.ping()
self.last_ping = now
def input(self, data):
if "type" in data:
function_name = "process_" + data["type"]
self._dbg("got {}".format(function_name))
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 is not None and output[1] is not None:
if limiter:
time.sleep(.1)
limiter = False
message = output[1].encode('ascii', 'ignore')
channel.send_message("{}".format(message))
limiter = True
def crons(self):
for plugin in self.bot_plugins:
plugin.do_jobs()
def load_plugins(self):
for plugin in glob.glob(self.directory + '/plugins/*'):
sys.path.insert(0, plugin)
sys.path.insert(0, self.directory + '/plugins/')
for plugin in glob.glob(self.directory + '/plugins/*.py') + \
glob.glob(self.directory + '/plugins/*/*.py'):
logging.info(plugin)
name = plugin.split('/')[-1][:-3]
if name in self.config:
logging.info("config found for: " + name)
plugin_config = self.config.get(name, {})
plugin_config['DEBUG'] = self.debug
self.bot_plugins.append(Plugin(name, plugin_config))
class Plugin(object):
def __init__(self, name, plugin_config=None):
'''
A plugin in initialized with:
- name (str)
- plugin config (dict) - (from the yaml config)
Values in config:
- DEBUG (bool) - this will be overridden if debug is set in config for this plugin
'''
if plugin_config is None:
plugin_config = {}
self.name = name
self.jobs = []
self.module = __import__(name)
self.module.config = plugin_config
self.debug = self.module.config.get('DEBUG', False)
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), self.debug))
logging.info(self.module.crontable)
self.module.crontable = []
else:
self.module.crontable = []
def do(self, function_name, data):
if function_name in dir(self.module):
if self.debug is True:
# this makes the plugin fail with stack trace in debug mode
eval("self.module." + function_name)(data)
else:
# otherwise we log the exception and carry on
try:
eval("self.module." + function_name)(data)
except Exception:
logging.exception("problem in module {} {}".format(function_name, data))
if "catch_all" in dir(self.module):
if self.debug is True:
# this makes the plugin fail with stack trace in debug mode
self.module.catch_all(data)
else:
try:
self.module.catch_all(data)
except Exception:
logging.exception("problem in catch all: {} {}".format(self.module, data))
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.info("output from {}".format(self.module))
output.append(self.module.outputs.pop(0))
else:
break
else:
self.module.outputs = []
return output
class Job(object):
def __init__(self, interval, function, debug):
self.function = function
self.interval = interval
self.lastrun = 0
self.debug = debug
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 self.debug is True:
# this makes the plugin fail with stack trace in debug mode
self.function()
else:
# otherwise we log the exception and carry on
try:
self.function()
except Exception:
logging.exception("Problem in job check: {}".format(self.function))
self.lastrun = time.time()
class UnknownChannel(Exception):
pass

13
setup.py Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env python
from distutils.core import setup
setup(
name='rtmbot',
version='0.10',
description='A Slack bot written in python that connects via the RTM API.',
author='Ryan Huber',
author_email='rhuber@gmail.com',
url='https://github.com/slackhq/python-rtmbot',
packages=['rtmbot'],
)

2
tests/test_example.py Normal file
View file

@ -0,0 +1,2 @@
def test_example():
assert True

20
tests/test_rtmbot_core.py Normal file
View file

@ -0,0 +1,20 @@
from testfixtures import LogCapture
from rtmbot.core import RtmBot
def test_init():
with LogCapture() as l:
rtmbot = RtmBot({
'SLACK_TOKEN': 'test-12345',
'BASE_PATH': '/tmp/',
'LOGFILE': '/tmp/rtmbot.log',
'DEBUG': True
})
assert rtmbot.token == 'test-12345'
assert rtmbot.directory == '/tmp/'
assert rtmbot.debug == True
l.check(
('root', 'INFO', 'Initialized in: /tmp/')
)

33
tox.ini Normal file
View file

@ -0,0 +1,33 @@
[tox]
envlist=
py{27,34,35},
flake8
skipsdist=true
[flake8]
max-line-length= 100
exclude= tests/*
[testenv]
passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH
commands =
py.test --cov-report= --cov=rtmbot {posargs:tests}
coveralls
deps =
-r{toxinidir}/requirements-dev.txt
-r{toxinidir}/requirements.txt
basepython =
py27: python2.7
py34: python3.4
py35: python3.5
[testenv:flake8]
basepython=python
deps=flake8
commands=
flake8 \
{toxinidir}/rtmbot.py \
{toxinidir}/rtmbot/core.py \
{toxinidir}/setup.py \
{toxinidir}/doc/example-plugins