├── tests ├── __init__.py ├── unit │ ├── __init__.py │ ├── fake_plugin_module.py │ ├── conftest.py │ ├── test_utils.py │ ├── test_manager.py │ ├── test_slackclient.py │ ├── test_dispatcher.py │ └── slackclient_data.py └── functional │ ├── __init__.py │ ├── slack.png │ ├── run.py │ ├── slackbot_settings.py │ ├── test_functional.py │ └── driver.py ├── slackbot ├── __init__.py ├── VERSION ├── plugins │ ├── __init__.py │ ├── upload.py │ └── hello.py ├── settings.py ├── manager.py ├── utils.py ├── bot.py ├── slackclient.py └── dispatcher.py ├── MANIFEST.in ├── setup.cfg ├── pytest.ini ├── requirements.txt ├── tox.ini ├── .bumpversion.cfg ├── .gitignore ├── run.py ├── .travis.yml ├── LICENSE.txt ├── scripts └── slackbot-test-ctl ├── setup.py ├── CHANGELOG ├── CONTRIBUTING.md ├── README_ja.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /slackbot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /slackbot/VERSION: -------------------------------------------------------------------------------- 1 | 1.0.5 2 | -------------------------------------------------------------------------------- /slackbot/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/fake_plugin_module.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include slackbot/VERSION 2 | include LICENSE.txt 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | [metadata] 4 | description-file=README.md 5 | -------------------------------------------------------------------------------- /tests/functional/slack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scrapinghub/slackbot/HEAD/tests/functional/slack.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -s -v --ignore=build --ignore=dist --ignore=slackbot.egg-info --ignore=setup.py -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=2.4.0 2 | websocket-client>=0.22.0,<=1.6 3 | slacker>=0.9.50 4 | six>=1.10.0 5 | pytest>=2.9.1 6 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36 3 | [testenv] 4 | deps = 5 | pytest 6 | commands = py.test -s -vv 7 | passenv = SLACKBOT_* TRAVIS 8 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.0.5 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bumpversion:file:slackbot/VERSION] 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /.tox/ 3 | *.pyc 4 | *# 5 | *~ 6 | *.swp 7 | #* 8 | local_settings.py 9 | local_test_settings.py 10 | slackbot_settings.py 11 | !tests/functional/slackbot_settings.py 12 | slackbot_test_settings.py 13 | /build 14 | /dist 15 | /*.egg-info 16 | .cache 17 | /.vscode/ 18 | -------------------------------------------------------------------------------- /tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(scope='function', autouse=True) 5 | def mock_settings(monkeypatch): 6 | class Settings: 7 | ALIASES = '' 8 | 9 | def __getattr__(self, item): 10 | return None 11 | 12 | settings = Settings() 13 | monkeypatch.setattr('slackbot.settings', settings) 14 | monkeypatch.setattr('slackbot.dispatcher.settings', settings) 15 | -------------------------------------------------------------------------------- /tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | from slackbot.utils import get_http_proxy 2 | 3 | def test_get_http_proxy(): 4 | environ = {'http_proxy': 'foo:8080'} 5 | assert get_http_proxy(environ) == ('foo', '8080', None) 6 | 7 | environ = {'http_proxy': 'http://foo:8080'} 8 | assert get_http_proxy(environ) == ('foo', '8080', None) 9 | 10 | environ = {'no_proxy': '*.slack.com'} 11 | assert get_http_proxy(environ) == (None, None, '*.slack.com') 12 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import logging 5 | import logging.config 6 | from slackbot import settings 7 | from slackbot.bot import Bot 8 | 9 | 10 | def main(): 11 | kw = { 12 | 'format': '[%(asctime)s] %(message)s', 13 | 'datefmt': '%m/%d/%Y %H:%M:%S', 14 | 'level': logging.DEBUG if settings.DEBUG else logging.INFO, 15 | 'stream': sys.stdout, 16 | } 17 | logging.basicConfig(**kw) 18 | logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.WARNING) 19 | bot = Bot() 20 | bot.run() 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /tests/functional/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | import logging 4 | import logging.config 5 | 6 | from slackbot import settings 7 | from slackbot.bot import Bot 8 | 9 | 10 | def main(): 11 | kw = { 12 | 'format': '[%(asctime)s] %(message)s', 13 | 'datefmt': '%m/%d/%Y %H:%M:%S', 14 | 'level': logging.DEBUG if settings.DEBUG else logging.INFO, 15 | 'stream': sys.stdout, 16 | } 17 | logging.basicConfig(**kw) 18 | logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.WARNING) 19 | bot = Bot() 20 | bot.run() 21 | 22 | if __name__ == '__main__': 23 | main() 24 | -------------------------------------------------------------------------------- /tests/unit/test_manager.py: -------------------------------------------------------------------------------- 1 | import re 2 | import sys 3 | import os 4 | 5 | import pytest 6 | 7 | from slackbot.manager import PluginsManager 8 | 9 | 10 | @pytest.fixture(scope='session', autouse=True) 11 | def update_path(): 12 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 13 | 14 | 15 | def test_import_plugin_single_module(): 16 | assert 'fake_plugin_module' not in sys.modules 17 | PluginsManager()._load_plugins('fake_plugin_module') 18 | assert 'fake_plugin_module' in sys.modules 19 | 20 | 21 | def test_get_plugins_none_text(): 22 | p = PluginsManager() 23 | p.commands['respond_to'][re.compile(r'^dummy regexp$')] = lambda x: x 24 | # Calling get_plugins() with `text == None` 25 | for func, args in p.get_plugins('respond_to', None): 26 | assert func is None 27 | assert args is None 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | language: python 3 | python: 3.6 4 | cache: 5 | directories: 6 | - "$HOME/.cache/pip" 7 | - "$TRAVIS_BUILD_DIR/.tox" 8 | before_install: 9 | - mkdir -p $HOME/opt/local/bin 10 | - export PATH=$HOME/opt/local/bin:$PATH 11 | - pushd /tmp/ && git clone https://github.com/rofl0r/proxychains-ng.git && cd proxychains-ng 12 | && ./configure --prefix=$HOME/opt/local && make -j2 && make install && popd 13 | - pip install shadowsocks 14 | - cp scripts/slackbot-test-ctl $HOME/opt/local/bin 15 | - slackbot-test-ctl init 16 | - slackbot-test-ctl startproxy 17 | install: pip install tox 18 | script: tox 19 | deploy: 20 | provider: pypi 21 | distributions: sdist bdist_wheel 22 | user: lins05_slackbot 23 | password: 24 | secure: d+RIBeI8E66mLPZan5vgEYR5P4K+J8YfcAJR16MFVuoKkvMaCze+Ho5bFgUzN47wW+SzAjFixPI4fkB4Silk+7BCwMeQrBQq+CL9OYCZf2vPwl2K3QTScwVCreEF05u3V1x5lm3/UvzTAuz0knaXwrWelRm5DOVf3DZPjWEcXtA= 25 | on: 26 | tags: true 27 | repo: lins05/slackbot 28 | -------------------------------------------------------------------------------- /tests/functional/slackbot_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ['SLACKBOT_TEST'] = 'true' 4 | ALIASES = ",".join(["!", "$"]) 5 | 6 | def load_driver_settings(): 7 | KEYS = ( 8 | 'testbot_apitoken', 9 | 'testbot_username', 10 | 'driver_apitoken', 11 | 'driver_username', 12 | 'test_channel', 13 | 'test_private_channel', 14 | ) 15 | 16 | _private_group_patch = 'SLACKBOT_TEST_GROUP' 17 | 18 | for key in KEYS: 19 | envkey = 'SLACKBOT_' + key.upper() 20 | 21 | # Backwards compatibility patch for TravisCI env variables 22 | if 'PRIVATE_CHANNEL' in envkey and os.environ.get(_private_group_patch): 23 | globals()[key] = os.environ.get(_private_group_patch, None) 24 | else: 25 | globals()[key] = os.environ.get(envkey, None) 26 | 27 | load_driver_settings() 28 | 29 | try: 30 | from slackbot_test_settings import * # pylint: disable=wrong-import-position 31 | except ImportError: 32 | pass 33 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015 Slackbot Contributors 2 | 3 | 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: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | 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. 8 | -------------------------------------------------------------------------------- /slackbot/plugins/upload.py: -------------------------------------------------------------------------------- 1 | # coding=utf8 2 | import os 3 | 4 | from slackbot.bot import respond_to 5 | from slackbot.utils import download_file, create_tmp_file 6 | 7 | 8 | @respond_to(r'upload \?') 9 | def upload(message, thing): 10 | # message.channel.upload_file(slack_filename, local_filename, 11 | # initial_comment='') 12 | if thing == 'favicon': 13 | url = 'https://slack.com/favicon.ico' 14 | message.reply('uploading {}'.format(url)) 15 | with create_tmp_file() as tmpf: 16 | download_file(url, tmpf) 17 | message.channel.upload_file(url, tmpf, 18 | 'downloaded from {}'.format(url)) 19 | elif thing == 'slack.png': 20 | message.reply('uploading slack.png') 21 | cwd = os.path.abspath(os.path.dirname(__file__)) 22 | fname = os.path.join(cwd, '../../tests/functional/slack.png') 23 | message.channel.upload_file(thing, fname) 24 | 25 | 26 | @respond_to('send_string_content') 27 | def upload_content(message): 28 | # message.channel.upload_content(slack_filename, content, 29 | # initial_comment='') 30 | content=u"你好! here's some data\nthat will appear\nas a plain text snippet" 31 | message.channel.upload_content('content.txt', content) 32 | -------------------------------------------------------------------------------- /scripts/slackbot-test-ctl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x -e 4 | 5 | ssconfig=/tmp/config.json 6 | proxychainsconfig=/tmp/proxychains.conf 7 | 8 | init_proxychains() { 9 | cat >$ssconfig <$proxychainsconfig <&2 2>/tmp/sslocal.log & 42 | } 43 | pgrep -f "ssserver -c $ssserver" || { 44 | ssserver -c $ssconfig 1>&2 2>/tmp/ssserver.log & 45 | } 46 | ;; 47 | stopproxy) 48 | pkill -f "sslocal -c $ssconfig" 49 | pkill -f "ssserver -c $ssconfig" 50 | ;; 51 | run) 52 | exec proxychains4 -f $proxychainsconfig "$@" 53 | ;; 54 | *) 55 | echo "WARNING: unknown command $action" 56 | exit 1 57 | ;; 58 | esac 59 | } 60 | 61 | main "$@" 62 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from os.path import join, dirname 2 | from setuptools import setup, find_packages 3 | 4 | __version__ = open(join(dirname(__file__), 'slackbot/VERSION')).read().strip() 5 | 6 | install_requires = ( 7 | 'requests>=2.4.0', 8 | 'websocket-client>=0.22.0,<=1.6', 9 | 'slacker>=0.9.50', 10 | 'six>=1.10.0' 11 | ) # yapf: disable 12 | 13 | excludes = ( 14 | '*test*', 15 | '*local_settings*', 16 | ) # yapf: disable 17 | 18 | setup(name='slackbot', 19 | version=__version__, 20 | license='MIT', 21 | description='A simple chat bot for Slack', 22 | author='Shuai Lin', 23 | author_email='linshuai2012@gmail.com', 24 | url='http://github.com/lins05/slackbot', 25 | platforms=['Any'], 26 | packages=find_packages(exclude=excludes), 27 | install_requires=install_requires, 28 | classifiers=['Development Status :: 4 - Beta', 29 | 'License :: OSI Approved :: MIT License', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python', 32 | 'Programming Language :: Python :: 2', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.4', 36 | 'Programming Language :: Python :: 3.5', 37 | 'Programming Language :: Python :: 3.6']) 38 | -------------------------------------------------------------------------------- /slackbot/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | 5 | DEBUG = False 6 | 7 | PLUGINS = [ 8 | 'slackbot.plugins', 9 | ] 10 | 11 | ERRORS_TO = None 12 | 13 | ''' 14 | Setup timeout for slacker API requests (e.g. uploading a file). 15 | ''' 16 | TIMEOUT = 100 17 | 18 | # API_TOKEN = '###token###' 19 | 20 | ''' 21 | Setup a comma delimited list of aliases that the bot will respond to. 22 | 23 | Example: if you set ALIASES='!,$' then a bot which would respond to: 24 | 'botname hello' 25 | will now also respond to 26 | '$ hello' 27 | ''' 28 | ALIASES = '' 29 | 30 | ''' 31 | If you use Slack Web API to send messages (with 32 | send_webapi(text, as_user=False) or reply_webapi(text, as_user=False)), 33 | you can customize the bot logo by providing Icon or Emoji. If you use Slack 34 | RTM API to send messages (with send() or reply()), or if as_user is True 35 | (default), the used icon comes from bot settings and Icon or Emoji has no 36 | effect. 37 | ''' 38 | # BOT_ICON = 'http://lorempixel.com/64/64/abstract/7/' 39 | # BOT_EMOJI = ':godmode:' 40 | 41 | '''Specify a different reply when the bot is messaged with no matching cmd''' 42 | DEFAULT_REPLY = None 43 | 44 | for key in os.environ: 45 | if key[:9] == 'SLACKBOT_': 46 | name = key[9:] 47 | globals()[name] = os.environ[key] 48 | 49 | try: 50 | from slackbot_settings import * 51 | except ImportError: 52 | try: 53 | from local_settings import * 54 | except ImportError: 55 | pass 56 | 57 | # convert default_reply to DEFAULT_REPLY 58 | try: 59 | DEFAULT_REPLY = default_reply 60 | except NameError: 61 | pass 62 | -------------------------------------------------------------------------------- /slackbot/plugins/hello.py: -------------------------------------------------------------------------------- 1 | #coding: UTF-8 2 | import re 3 | from slackbot.bot import respond_to 4 | from slackbot.bot import listen_to 5 | 6 | 7 | @respond_to('hello$', re.IGNORECASE) 8 | def hello_reply(message): 9 | message.reply('hello sender!') 10 | 11 | 12 | @respond_to('^reply_webapi$') 13 | def hello_webapi(message): 14 | message.reply_webapi('hello there!', attachments=[{ 15 | 'fallback': 'test attachment', 16 | 'fields': [ 17 | { 18 | 'title': 'test table field', 19 | 'value': 'test table value', 20 | 'short': True 21 | } 22 | ] 23 | }]) 24 | 25 | 26 | @respond_to('^reply_webapi_not_as_user$') 27 | def hello_webapi_not_as_user(message): 28 | message.reply_webapi('hi!', as_user=False) 29 | 30 | 31 | @respond_to('hello_formatting') 32 | def hello_reply_formatting(message): 33 | # Format message with italic style 34 | message.reply('_hello_ sender!') 35 | 36 | 37 | @listen_to('hello$') 38 | def hello_send(message): 39 | message.send('hello channel!') 40 | 41 | 42 | @listen_to('hello_decorators') 43 | @respond_to('hello_decorators') 44 | def hello_decorators(message): 45 | message.send('hello!') 46 | 47 | @listen_to('hey!') 48 | def hey(message): 49 | message.react('eggplant') 50 | 51 | 52 | @respond_to(u'你好') 53 | def hello_unicode_message(message): 54 | message.reply(u'你好!') 55 | 56 | 57 | @listen_to('start a thread') 58 | def start_thread(message): 59 | message.reply('I started a thread', in_thread=True) 60 | 61 | @respond_to('say hi to me') 62 | def direct_hello(message): 63 | message.direct_reply("Here you are") 64 | 65 | -------------------------------------------------------------------------------- /slackbot/manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import logging 5 | from glob import glob 6 | from six import PY2 7 | from importlib import import_module 8 | from slackbot import settings 9 | from slackbot.utils import to_utf8 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class PluginsManager(object): 15 | def __init__(self): 16 | pass 17 | 18 | commands = { 19 | 'respond_to': {}, 20 | 'listen_to': {}, 21 | 'default_reply': {} 22 | } 23 | 24 | def init_plugins(self): 25 | if hasattr(settings, 'PLUGINS'): 26 | plugins = settings.PLUGINS 27 | else: 28 | plugins = 'slackbot.plugins' 29 | 30 | for plugin in plugins: 31 | self._load_plugins(plugin) 32 | 33 | def _load_plugins(self, plugin): 34 | logger.info('loading plugin "%s"', plugin) 35 | path_name = None 36 | 37 | if PY2: 38 | import imp 39 | 40 | for mod in plugin.split('.'): 41 | if path_name is not None: 42 | path_name = [path_name] 43 | _, path_name, _ = imp.find_module(mod, path_name) 44 | else: 45 | from importlib.util import find_spec as importlib_find 46 | 47 | path_name = importlib_find(plugin) 48 | try: 49 | path_name = path_name.submodule_search_locations[0] 50 | except TypeError: 51 | path_name = path_name.origin 52 | 53 | module_list = [plugin] 54 | if not path_name.endswith('.py'): 55 | module_list = glob('{}/[!_]*.py'.format(path_name)) 56 | module_list = ['.'.join((plugin, os.path.split(f)[-1][:-3])) for f 57 | in module_list] 58 | for module in module_list: 59 | try: 60 | import_module(module) 61 | except Exception: 62 | # TODO Better exception handling 63 | logger.exception('Failed to import %s', module) 64 | 65 | def get_plugins(self, category, text): 66 | has_matching_plugin = False 67 | if text is None: 68 | text = '' 69 | for matcher in self.commands[category]: 70 | m = matcher.search(text) 71 | if m: 72 | has_matching_plugin = True 73 | yield self.commands[category][matcher], to_utf8(m.groups()) 74 | 75 | if not has_matching_plugin: 76 | yield None, None 77 | -------------------------------------------------------------------------------- /slackbot/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import logging 5 | import tempfile 6 | import requests 7 | from contextlib import contextmanager 8 | from six.moves import _thread, range, queue 9 | import six 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def download_file(url, fpath, token=''): 15 | logger.debug('starting to fetch %s', url) 16 | headers = {"Authorization": "Bearer "+token} if token else None 17 | r = requests.get(url, stream=True, headers=headers) 18 | with open(fpath, 'wb') as f: 19 | for chunk in r.iter_content(chunk_size=1024*64): 20 | if chunk: # filter out keep-alive new chunks 21 | f.write(chunk) 22 | f.flush() 23 | logger.debug('fetch %s', fpath) 24 | return fpath 25 | 26 | 27 | def to_utf8(s): 28 | """Convert a string to utf8. If the argument is an iterable 29 | (list/tuple/set), then each element of it would be converted instead. 30 | 31 | >>> to_utf8('a') 32 | 'a' 33 | >>> to_utf8(u'a') 34 | 'a' 35 | >>> to_utf8([u'a', u'b', u'\u4f60']) 36 | ['a', 'b', '\\xe4\\xbd\\xa0'] 37 | """ 38 | if six.PY2: 39 | if isinstance(s, str): 40 | return s 41 | elif isinstance(s, unicode): 42 | return s.encode('utf-8') 43 | elif isinstance(s, (list, tuple, set)): 44 | return [to_utf8(v) for v in s] 45 | else: 46 | return s 47 | else: 48 | return s 49 | 50 | 51 | @contextmanager 52 | def create_tmp_file(content=''): 53 | fd, name = tempfile.mkstemp() 54 | try: 55 | if content: 56 | os.write(fd, content) 57 | yield name 58 | finally: 59 | os.close(fd) 60 | os.remove(name) 61 | 62 | 63 | class WorkerPool(object): 64 | def __init__(self, func, nworker=10): 65 | self.nworker = nworker 66 | self.func = func 67 | self.queue = queue.Queue() 68 | 69 | def start(self): 70 | for __ in range(self.nworker): 71 | _thread.start_new_thread(self.do_work, tuple()) 72 | 73 | def add_task(self, msg): 74 | self.queue.put(msg) 75 | 76 | def do_work(self): 77 | while True: 78 | msg = self.queue.get() 79 | self.func(msg) 80 | 81 | 82 | def get_http_proxy(environ): 83 | proxy, proxy_port, no_proxy = None, None, None 84 | 85 | if 'http_proxy' in environ: 86 | http_proxy = environ['http_proxy'] 87 | prefix = 'http://' 88 | if http_proxy.startswith(prefix): 89 | http_proxy = http_proxy[len(prefix):] 90 | proxy, proxy_port = http_proxy.split(':') 91 | 92 | if 'no_proxy' in environ: 93 | no_proxy = environ['no_proxy'] 94 | 95 | return proxy, proxy_port, no_proxy 96 | -------------------------------------------------------------------------------- /tests/unit/test_slackclient.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from slackbot.slackclient import SlackClient 4 | from . import slackclient_data 5 | 6 | 7 | @pytest.fixture 8 | def slack_client(): 9 | c = SlackClient(None, connect=False) 10 | c.channels = slackclient_data.CHANNELS 11 | c.users = slackclient_data.USERS 12 | return c 13 | 14 | 15 | def test_find_channel_by_name(slack_client): 16 | assert slack_client.find_channel_by_name('slackbot') == 'D0X6385P1' 17 | assert slack_client.find_channel_by_name('user') == 'D0X6EF55G' 18 | assert slack_client.find_channel_by_name('random') == 'C0X4HEKPA' 19 | assert slack_client.find_channel_by_name('testbot-test') == 'G0X62KL92' 20 | 21 | 22 | def test_find_user_by_name(slack_client): 23 | assert slack_client.find_user_by_name('user') == 'U0X4QA7R7' 24 | assert slack_client.find_user_by_name('slackbot') == 'USLACKBOT' 25 | 26 | 27 | def test_parse_channel_data(slack_client): 28 | assert slack_client.find_channel_by_name('fun') is None 29 | slack_client.parse_channel_data([{ 30 | "id": "C024BE91L", 31 | "name": "fun", 32 | "created": 1360782804, 33 | "creator": "U024BE7LH" 34 | }]) 35 | assert slack_client.find_channel_by_name('fun') == 'C024BE91L' 36 | slack_client.parse_channel_data([{ 37 | "id": "C024BE91L", 38 | "name": "fun2", 39 | "created": 1360782804, 40 | "creator": "U024BE7LH" 41 | }]) 42 | assert slack_client.find_channel_by_name('fun') is None 43 | assert slack_client.find_channel_by_name('fun2') == 'C024BE91L' 44 | 45 | # Although Slack has changed terminology for 'Groups' (now 'private channels'), 46 | # The Slack API still uses the `is_group` property for private channels (as of 09/10/2017) 47 | assert slack_client.find_channel_by_name('test-group-joined') is None 48 | slack_client.parse_channel_data([{ 49 | 'created': 1497473029, 50 | 'creator': "U0X642GBF", 51 | 'id': "G5TV5TW3W", 52 | 'is_archived': False, 53 | 'is_group': True, 54 | 'is_mpim': False, 55 | 'is_open': True, 56 | 'last_read': "0000000000.000000", 57 | 'latest': None, 58 | 'members': ["U0X642GBF"], 59 | 'name': "test-group-joined", 60 | 'name_normalized': "test-group-joined" 61 | }]) 62 | assert slack_client.find_channel_by_name( 63 | 'test-group-joined') == 'G5TV5TW3W' 64 | 65 | 66 | def test_parse_user_data(slack_client): 67 | assert slack_client.find_user_by_name('bob') is None 68 | slack_client.parse_user_data([{ 69 | 'id': 'U123456', 70 | 'name': 'bob' 71 | }]) 72 | assert slack_client.find_user_by_name('bob') == 'U123456' 73 | slack_client.parse_user_data([{ 74 | 'id': 'U123456', 75 | 'name': 'bob2' 76 | }]) 77 | assert slack_client.find_user_by_name('bob') is None 78 | assert slack_client.find_user_by_name('bob2') == 'U123456' 79 | 80 | 81 | def test_init_with_timeout(): 82 | client = SlackClient(None, connect=False) 83 | assert client.webapi.api.timeout == 10 # seconds default timeout 84 | 85 | expected_timeout = 42 # seconds 86 | client = SlackClient(None, connect=False, timeout=expected_timeout) 87 | assert client.webapi.api.timeout == expected_timeout 88 | -------------------------------------------------------------------------------- /slackbot/bot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import 3 | import imp 4 | import importlib 5 | import logging 6 | import re 7 | import time 8 | from glob import glob 9 | from six.moves import _thread 10 | from slackbot import settings 11 | from slackbot.manager import PluginsManager 12 | from slackbot.slackclient import SlackClient 13 | from slackbot.dispatcher import MessageDispatcher 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Bot(object): 19 | def __init__(self): 20 | self._client = SlackClient( 21 | settings.API_TOKEN, 22 | timeout=settings.TIMEOUT if hasattr(settings, 23 | 'TIMEOUT') else None, 24 | bot_icon=settings.BOT_ICON if hasattr(settings, 25 | 'BOT_ICON') else None, 26 | bot_emoji=settings.BOT_EMOJI if hasattr(settings, 27 | 'BOT_EMOJI') else None 28 | ) 29 | self._plugins = PluginsManager() 30 | self._dispatcher = MessageDispatcher(self._client, self._plugins, 31 | settings.ERRORS_TO) 32 | 33 | def run(self): 34 | self._plugins.init_plugins() 35 | self._dispatcher.start() 36 | if not self._client.connected: 37 | self._client.rtm_connect() 38 | 39 | _thread.start_new_thread(self._keepactive, tuple()) 40 | logger.info('connected to slack RTM api') 41 | self._dispatcher.loop() 42 | 43 | def _keepactive(self): 44 | logger.info('keep active thread started') 45 | while True: 46 | time.sleep(30 * 60) 47 | self._client.ping() 48 | 49 | 50 | def respond_to(matchstr, flags=0): 51 | def wrapper(func): 52 | PluginsManager.commands['respond_to'][ 53 | re.compile(matchstr, flags)] = func 54 | logger.info('registered respond_to plugin "%s" to "%s"', func.__name__, 55 | matchstr) 56 | return func 57 | 58 | return wrapper 59 | 60 | 61 | def listen_to(matchstr, flags=0): 62 | def wrapper(func): 63 | PluginsManager.commands['listen_to'][ 64 | re.compile(matchstr, flags)] = func 65 | logger.info('registered listen_to plugin "%s" to "%s"', func.__name__, 66 | matchstr) 67 | return func 68 | 69 | return wrapper 70 | 71 | 72 | # def default_reply(matchstr=r'^.*$', flags=0): 73 | def default_reply(*args, **kwargs): 74 | """ 75 | Decorator declaring the wrapped function to the default reply hanlder. 76 | 77 | May be invoked as a simple, argument-less decorator (i.e. ``@default_reply``) or 78 | with arguments customizing its behavior (e.g. ``@default_reply(matchstr='pattern')``). 79 | """ 80 | invoked = bool(not args or kwargs) 81 | matchstr = kwargs.pop('matchstr', r'^.*$') 82 | flags = kwargs.pop('flags', 0) 83 | 84 | if not invoked: 85 | func = args[0] 86 | 87 | def wrapper(func): 88 | PluginsManager.commands['default_reply'][ 89 | re.compile(matchstr, flags)] = func 90 | logger.info('registered default_reply plugin "%s" to "%s"', func.__name__, 91 | matchstr) 92 | return func 93 | 94 | return wrapper if invoked else wrapper(func) 95 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Release Notes - Slackbot - Version 1.0.1 - 1.0.4 2 | -------------------------------------------- 3 | This release contains the following fixes: 4 | 5 | * Fixed rtm.start deprecation issue. 6 | 7 | Release Notes - Slackbot - Version 1.0.0 8 | -------------------------------------------- 9 | This release contains the following new features: 10 | 11 | This release contains the following fixes: 12 | 13 | * Removed Python 2 Support 14 | 15 | 16 | Release Notes - Slackbot - Version 0.5.7 17 | -------------------------------------------- 18 | This release contains the following new features: 19 | 20 | This release contains the following fixes: 21 | 22 | * [#208] Handle new line #208 (thanks, @louhow) 23 | 24 | Release Notes - Slackbot - Version 0.5.4 25 | -------------------------------------------- 26 | This release contains the following new features: 27 | 28 | This release contains the following fixes: 29 | 30 | * [#161] Handle new line #176 (thanks, @twcurrie) 31 | 32 | Release Notes - Slackbot - Version 0.5.3 33 | -------------------------------------------- 34 | This release contains the following new features: 35 | 36 | This release contains the following fixes: 37 | 38 | * [#176] Handle new line #176 (thanks, @suzuki4) 39 | 40 | Release Notes - Slackbot - Version 0.5.2 41 | -------------------------------------------- 42 | This release contains the following new features: 43 | 44 | This release contains the following fixes: 45 | 46 | * [#168] Add a user attribute to Message (thanks, @jonas-schulze) 47 | * [#93, #160] Change "group" to "private channel" to match Slack terminology 48 | (thanks, @DannyHinshaw!) 49 | 50 | Release Notes - Slackbot - Version 0.5.1 51 | -------------------------------------------- 52 | This release has nothing new, just to trigger a new pypi release after fixing pypi credentials. 53 | 54 | Release Notes - Slackbot - Version 0.5.0 55 | -------------------------------------------- 56 | This release contains the following new features: 57 | 58 | * [#101, #111] Add support for http_proxy and no_proxy environment 59 | variables 60 | 61 | * [#109] message.channel can be compared to channel name or channel ID 62 | 63 | * [#138, #144] Messages from other bots could raise exceptions 64 | 65 | * [#141] The bot can now respond to users that joined after the bot logged 66 | in 67 | 68 | * [#149] utils.download_file() now accepts an optional token parameter 69 | 70 | * [#136] Let slackbot add replies to a thread 71 | 72 | * message.channel.upload_content() for uploading data without storing it in 73 | a file first 74 | 75 | * Official support for Python 3.6 76 | 77 | 78 | Release Notes - Slackbot - Version 0.4.1 79 | -------------------------------------------- 80 | This release contains the following new features: 81 | 82 | * [#82] - Support @default_reply decorator 83 | 84 | Release Notes - Slackbot - Version 0.4.0 85 | -------------------------------------------- 86 | This release contains the following new features: 87 | 88 | * [#60] - Added support for aliases. 89 | 90 | * [#67] Send tracebacks to a specified channel/group/user 91 | 92 | * [#68] Respond to botname in addition to @botname 93 | 94 | * [#71] Support importing individual plugin modules 95 | 96 | * [#73] DEFAULT_REPLY can now be passed via environment variables. 97 | 98 | * [#74] Update send_webapi to send as_user by default 99 | 100 | * [#75] Support attachments in message.reply_webapi 101 | 102 | * [#81] Improve reconnection logic to avoid getting 429 Too Many Request 103 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Slackbot developer guide 2 | 3 | Thanks for your interest in developing Slackbot! These notes should help you produce pull requests that will get merged without any issues. 4 | 5 | ## Style guide 6 | 7 | ### Code style 8 | 9 | There are places in the code that do not follow PEP 8 conventions. Do follow PEP 8 with new code, but do not fix formatting throughout the file you're editing. If your commit has a lot of unrelated reformatting in addition to your new/changed code, you may be asked to resubmit it with the extra changes removed. 10 | 11 | ### Commits 12 | 13 | It's a good idea to use one branch per pull request. This will allow you to work on multiple changes at once. 14 | 15 | Most pull requests should contain only a single commit. If you have to make corrections to a pull request, rebase and squash your branch, then do a forced push. Clean up the commit message so it's clear and as concise as needed. 16 | 17 | ## Developing 18 | 19 | These steps will help you prepare your development environment to work on slackbot. 20 | 21 | ### Clone the repo 22 | 23 | Begin by forking the repo. You will then clone your fork and add the central repo as another remote. This will help you incorporate changes as you develop. 24 | 25 | ``` 26 | $ git clone git@github.com:yourusername/slackbot.git 27 | $ cd slackbot 28 | $ git remote add upstream git@github.com:lins05/slackbot.git 29 | ``` 30 | 31 | Do not make commits to develop, even in your local copy. All commits should be on a branch. Start your branch: 32 | 33 | ``` 34 | $ git checkout develop -b name_of_feature 35 | ``` 36 | 37 | To incorporate upstream changes into your local copy and fork: 38 | 39 | ``` 40 | $ git checkout develop 41 | $ git fetch upstream 42 | $ git merge upstream/master 43 | $ git push origin develop 44 | ``` 45 | 46 | See git documentation for info on merging, rebasing, and squashing commits. 47 | 48 | ### virtualenv/pyvenv 49 | 50 | A virtualenv allows you to install the Python packages you need to develop and run slackbot without adding a bunch of unneeded junk to your system's Python installation. Once you create the virtualenv, you need to activate it any time you're developing or running slackbot. The steps are slightly different for Python 2 and Python 3. For Python 2, run: 51 | 52 | ``` 53 | $ virtualenv --no-site-packages .env 54 | ``` 55 | 56 | For Python 3, run: 57 | 58 | ``` 59 | $ pyvenv .env 60 | ``` 61 | 62 | Now that the virtualenv has been created, activate it and install the packages needed for development: 63 | 64 | ``` 65 | $ source .env/bin/activate 66 | $ pip install -r requirements.txt 67 | ``` 68 | 69 | At this point, you should be able to run slackbot as described in the README. 70 | 71 | ### Configure tests 72 | 73 | In order to run tests, you will need a slack instance. Create a free one at http://slack.com. Do not use an existing Slack for tests. The tests produce quite a bit of chat, and depending on how you set up Travis, it's possible for your API tokens to get leaked. Don't risk it. Use a slack created just for development and test. 74 | 75 | Create a file named `slackbot_test_settings.py` and add the following settings: 76 | 77 | ``` 78 | testbot_apitoken = 'xoxb-token' 79 | testbot_username = 'testbot' 80 | driver_apitoken = 'xoxp-token' 81 | driver_username = 'your username' 82 | test_channel = 'testchannel' 83 | test_private_channel = 'testprivatechannel' 84 | ``` 85 | 86 | **Important note:** The bot token can be obtained by adding a custom bot integration in Slack. User tokens can be obtained at https://api.slack.com/docs/oauth-test-tokens. Slack tokens are like passwords! Don't commit them. If you're using them in some kind of Github or Travis automation, ensure they are for Slacks that are only for testing. 87 | 88 | At this point, you should be able to run tests: 89 | 90 | ``` 91 | $ py.test 92 | ``` 93 | 94 | If you're signed into slack, you'll see your user account and bot account chatting with each other as the tests run. 95 | 96 | Tox is also available. If your system has Python 2.7, 3.4, and 3.5 installed, installing and running tox will automatically manage the virtual Python environments and dependencies for you. 97 | 98 | ### Configure Travis 99 | 100 | Log in to Travis and enable tests for your slackbot fork. Open Travis settings. You must add the following environment variables, which should correlate to settings in `slackbot_test_settings.py`: 101 | 102 | - SLACKBOT_TESTBOT_APITOKEN 103 | - SLACKBOT_TESTBOT_USERNAME 104 | - SLACKBOT_DRIVER_APITOKEN 105 | - SLACKBOT_DRIVER_USERNAME 106 | - SLACKBOT_TEST_CHANNEL 107 | - SLACKBOT_TEST_PRIVATE_CHANNEL 108 | 109 | You must also set `Limit concurrent jobs` to `1`. If you don't, you will see false positives/failures, especially in the test cases that verify slackbot's ability to automatically reconnect on disconnection. 110 | -------------------------------------------------------------------------------- /README_ja.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://badge.fury.io/py/slackbot.svg)](https://pypi.python.org/pypi/slackbot) [![Build Status](https://secure.travis-ci.org/lins05/slackbot.svg?branch=master)](http://travis-ci.org/lins05/slackbot) 2 | 3 | [llimllib/limbo](https://github.com/llimllib/limbo)と[will](https://github.com/skoczen/will)に触発された[Slack](https://slack.com)のチャットボットです。 4 | 5 | ## 機能 6 | 7 | * slack [Real Time Messaging API](https://api.slack.com/rtm) に基づいています 8 | * プラグインの仕組みがシンプルです 9 | * メッセージは同時に処理することができます 10 | * 接続が失われたときに自動的に再接続します 11 | * Python3 をサポートしています 12 | * [Full-fledged functional tests](tests/functional/test_functional.py) 13 | 14 | ## インストール 15 | 16 | 17 | ``` 18 | pip install slackbot 19 | ``` 20 | 21 | ## 使用方法 22 | 23 | ### Slack APIトークンを生成する 24 | 25 | まず、ボットのための Slack API トークンを取得する必要があります。それには2つの選択肢があります: 26 | 27 | 1. もし Slack の [bot user integration](https://api.slack.com/bot-users) を使用している場合は、インテグレーションページで API トークンを取得することができます 28 | 2. 実際の Slack ユーザを使用している場合は、[slack web api ページ](https://api.slack.com/web)で API トークンを生成することができます 29 | 30 | ### ボットを設定する 31 | 始めに、あなた自身の slackbot のインスタンスに `slackbot_settings.py` と `run.py` のファイルを作成します。 32 | 33 | ##### APIトークンを設定する 34 | 35 | そして、 `slackbot_settings.py` という Python モジュールで `API_TOKEN` を設定する必要があります。これは Python のインポートパスに置かなければなりません。これはボットによって自動的にインポートされます。 36 | 37 | slackbot_settings.py: 38 | 39 | ```python 40 | API_TOKEN = "" 41 | ``` 42 | 43 | 代わりに、環境変数 `SLACKBOT_API_TOKEN` を使用することもできます。 44 | 45 | ##### ボットを実行する 46 | 47 | ```python 48 | from slackbot.bot import Bot 49 | def main(): 50 | bot = Bot() 51 | bot.run() 52 | 53 | if __name__ == "__main__": 54 | main() 55 | ``` 56 | ##### 既定の回答を設定する 57 | DEFAULT_REPLY を `slackbot_settings.py` に追加します: 58 | ```python 59 | DEFAULT_REPLY = "Sorry but I didn't understand you" 60 | ``` 61 | 62 | ##### ドキュメントの回答を設定する 63 | [カスタムプラグイン](#create-plugins)に渡される `message` 属性は特別な関数 `message.docs_reply()` を持ち、利用可能なすべてのプラグインを解析し、それぞれのDocを返します。 64 | 65 | ##### すべてのトレースバックをチャネル、プライベートチャネル、またはユーザーに直接送信する 66 | `slackbot_settings.py` の `ERRORS_TO` に目的の受信者を設定してください。任意のチャネル、プライベートチャネル、またはユーザーが可能です。ボットがあらかじめチャンネルに入っている必要があります。 ユーザーが指定されている場合は、少なくとも1つの DM を最初にボットに送信したことを確認してください。 67 | 68 | ```python 69 | ERRORS_TO = 'some_channel' 70 | # or... 71 | ERRORS_TO = 'username' 72 | ``` 73 | 74 | ##### プラグインの設定をする 75 | [自身のプラグインモジュール](#create-plugins)を`slackbot_settings.py`の`PLUGINS`一覧に追加します: 76 | 77 | ```python 78 | PLUGINS = [ 79 | 'slackbot.plugins', 80 | 'mybot.plugins', 81 | ] 82 | ``` 83 | 84 | これであなたの Slack クライアントでボットに話しかけることができます! 85 | 86 | ### [添付ファイルのサポート](https://api.slack.com/docs/attachments) 87 | 88 | ```python 89 | from slackbot.bot import respond_to 90 | import re 91 | import json 92 | 93 | 94 | @respond_to('github', re.IGNORECASE) 95 | def github(message): 96 | attachments = [ 97 | { 98 | 'fallback': 'Fallback text', 99 | 'author_name': 'Author', 100 | 'author_link': 'http://www.github.com', 101 | 'text': 'Some text', 102 | 'color': '#59afe1' 103 | }] 104 | message.send_webapi('', json.dumps(attachments)) 105 | ``` 106 | ## プラグインの作成 107 | 108 | あなたの利用用途に合わせて拡張/カスタマイズすることができない限り、チャットボットは無意味です。 109 | 110 | 新しいプラグインを作成するには、単純に `slackbot.bot.respond_to` または `slackbot.bot.listen_to` で装飾された関数を作成します: 111 | 112 | - `respond_to` で装飾された関数は、パターンにマッチしたメッセージがボットに送信されたときに呼び出されます(ダイレクトメッセージ、またはチャンネル/プライベートチャンネルチャットの @botname) 113 | - `listen_to` で装飾された関数は、パターンにマッチするメッセージがチャンネル/プライベートチャンネルチャット(ボットに直接送信されない)で送信されたときに呼び出されます。 114 | 115 | ```python 116 | from slackbot.bot import respond_to 117 | from slackbot.bot import listen_to 118 | import re 119 | 120 | @respond_to('hi', re.IGNORECASE) 121 | def hi(message): 122 | message.reply('I can understand hi or HI!') 123 | # react with thumb up emoji 124 | message.react('+1') 125 | 126 | @respond_to('I love you') 127 | def love(message): 128 | message.reply('I love you too!') 129 | 130 | @listen_to('Can someone help me?') 131 | def help(message): 132 | # Message is replied to the sender (prefixed with @user) 133 | message.reply('Yes, I can!') 134 | 135 | # Message is sent on the channel 136 | message.send('I can help everybody!') 137 | 138 | # Start a thread on the original message 139 | message.reply("Here's a threaded reply", in_thread=True) 140 | ``` 141 | 142 | メッセージから params を抽出するには、正規表現を使用します。 143 | 144 | ```python 145 | from slackbot.bot import respond_to 146 | 147 | @respond_to('Give me (.*)') 148 | def giveme(message, something): 149 | message.reply('Here is {}'.format(something)) 150 | ``` 151 | 152 | 'stats' や 'stats start_date end_date' のようなコマンドが必要な場合は、次のような正規表現を作成することができます: 153 | 154 | ```python 155 | from slackbot.bot import respond_to 156 | import re 157 | 158 | 159 | @respond_to('stat$', re.IGNORECASE) 160 | @respond_to('stat (.*) (.*)', re.IGNORECASE) 161 | def stats(message, start_date=None, end_date=None): 162 | ``` 163 | 164 | プラグインモジュールを slackbot 設定の `PLUGINS` リストに追加します。 165 | 例) slackbot_settings.py: 166 | 167 | ```python 168 | PLUGINS = [ 169 | 'slackbot.plugins', 170 | 'mybot.plugins', 171 | ] 172 | ``` 173 | 174 | ## `@default_reply` デコレータ 175 | 176 | *slackbot 0.4.1 で追加されました* 177 | 178 | Besides specifying `DEFAULT_REPLY` in `slackbot_settings.py`, you can also decorate a function with the `@default_reply` decorator to make it the default reply handler, which is more handy. 179 | 180 | `slackbot_settings.py` に `DEFAULT_REPLY` を指定する以外に、デフォルトの返信ハンドラにするために `@default_reply` デコレータで関数を修飾することもできます。これはもっと便利です。 181 | 182 | ```python 183 | @default_reply 184 | def my_default_handler(message): 185 | message.reply('...') 186 | ``` 187 | 188 | デコレータの別の変形例を次に示します。 189 | 190 | ```python 191 | @default_reply(r'hello.*)') 192 | def my_default_handler(message): 193 | message.reply('...') 194 | ``` 195 | 196 | 上記のデフォルトのハンドラは、(1)指定されたパターンと一致しなければならないメッセージと(2)他の登録されたハンドラによって処理されないメッセージのみを処理します。 197 | 198 | ## サードパーティプラグインの一覧 199 | 200 | [このページ](https://github.com/lins05/slackbot/wiki/Plugins)で利用可能なサードパーティプラグインの一覧を見ることができます. 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://badge.fury.io/py/slackbot.svg)](https://pypi.python.org/pypi/slackbot) [![Build Status](https://secure.travis-ci.org/lins05/slackbot.svg?branch=master)](http://travis-ci.org/lins05/slackbot) 2 | 3 | A chat bot for [Slack](https://slack.com) inspired by [llimllib/limbo](https://github.com/llimllib/limbo) and [will](https://github.com/skoczen/will). 4 | 5 | ## Features 6 | 7 | * Based on slack [Real Time Messaging API](https://api.slack.com/rtm) 8 | * Simple plugins mechanism 9 | * Messages can be handled concurrently 10 | * Automatically reconnect to slack when connection is lost 11 | * [Full-fledged functional tests](tests/functional/test_functional.py) 12 | 13 | ## Installation 14 | 15 | 16 | ``` 17 | pip install slackbot 18 | ``` 19 | 20 | ## Usage 21 | 22 | ### Generate the slack api token 23 | 24 | First you need to get the slack api token for your bot. You have two options: 25 | 26 | 1. If you use a [bot user integration](https://api.slack.com/bot-users) of slack, you can get the api token on the integration page. 27 | 2. If you use a real slack user, you can generate an api token on [slack web api page](https://api.slack.com/web). 28 | 29 | 30 | ### Configure the bot 31 | First create a `slackbot_settings.py` and a `run.py` in your own instance of slackbot. 32 | 33 | ##### Configure the api token 34 | 35 | Then you need to configure the `API_TOKEN` in a python module `slackbot_settings.py`, which must be located in a python import path. This will be automatically imported by the bot. 36 | 37 | slackbot_settings.py: 38 | 39 | ```python 40 | API_TOKEN = "" 41 | ``` 42 | 43 | Alternatively, you can use the environment variable `SLACKBOT_API_TOKEN`. 44 | 45 | ##### Run the bot 46 | 47 | ```python 48 | from slackbot.bot import Bot 49 | def main(): 50 | bot = Bot() 51 | bot.run() 52 | 53 | if __name__ == "__main__": 54 | main() 55 | ``` 56 | 57 | ##### Configure the default answer 58 | 59 | Add a DEFAULT_REPLY to `slackbot_settings.py`: 60 | ```python 61 | DEFAULT_REPLY = "Sorry but I didn't understand you" 62 | ``` 63 | 64 | ##### Configure the docs answer 65 | 66 | The `message` attribute passed to [your custom plugins](#create-plugins) has an special function `message.docs_reply()` that will parse all the plugins available and return the Docs in each of them. 67 | 68 | ##### Send all tracebacks directly to a channel, private channel, or user 69 | Set `ERRORS_TO` in `slackbot_settings.py` to the desired recipient. It can be any channel, private channel, or user. Note that the bot must already be in the channel. If a user is specified, ensure that they have sent at least one DM to the bot first. 70 | 71 | ```python 72 | ERRORS_TO = 'some_channel' 73 | # or... 74 | ERRORS_TO = 'username' 75 | ``` 76 | 77 | ##### Configure the plugins 78 | 79 | Add [your plugin modules](#create-plugins) to a `PLUGINS` list in `slackbot_settings.py`: 80 | 81 | ```python 82 | PLUGINS = [ 83 | 'slackbot.plugins', 84 | 'mybot.plugins', 85 | ] 86 | ``` 87 | 88 | Now you can talk to your bot in your slack client! 89 | 90 | ### [Attachment Support](https://api.slack.com/docs/attachments) 91 | 92 | ```python 93 | from slackbot.bot import respond_to 94 | import re 95 | import json 96 | 97 | 98 | @respond_to('github', re.IGNORECASE) 99 | def github(message): 100 | attachments = [ 101 | { 102 | 'fallback': 'Fallback text', 103 | 'author_name': 'Author', 104 | 'author_link': 'http://www.github.com', 105 | 'text': 'Some text', 106 | 'color': '#59afe1' 107 | }] 108 | message.send_webapi('', json.dumps(attachments)) 109 | ``` 110 | 111 | ## Create Plugins 112 | 113 | A chat bot is meaningless unless you can extend/customize it to fit your own use cases. 114 | 115 | To write a new plugin, simplely create a function decorated by `slackbot.bot.respond_to` or `slackbot.bot.listen_to`: 116 | 117 | - A function decorated with `respond_to` is called when a message matching the pattern is sent to the bot (direct message or @botname in a channel/private channel chat) 118 | - A function decorated with `listen_to` is called when a message matching the pattern is sent on a channel/private channel chat (not directly sent to the bot) 119 | 120 | ```python 121 | from slackbot.bot import respond_to 122 | from slackbot.bot import listen_to 123 | import re 124 | 125 | @respond_to('hi', re.IGNORECASE) 126 | def hi(message): 127 | message.reply('I can understand hi or HI!') 128 | # react with thumb up emoji 129 | message.react('+1') 130 | 131 | @respond_to('I love you') 132 | def love(message): 133 | message.reply('I love you too!') 134 | 135 | @listen_to('Can someone help me?') 136 | def help(message): 137 | # Message is replied to the sender (prefixed with @user) 138 | message.reply('Yes, I can!') 139 | 140 | # Message is sent on the channel 141 | message.send('I can help everybody!') 142 | 143 | # Start a thread on the original message 144 | message.reply("Here's a threaded reply", in_thread=True) 145 | ``` 146 | 147 | To extract params from the message, you can use regular expression: 148 | ```python 149 | from slackbot.bot import respond_to 150 | 151 | @respond_to('Give me (.*)') 152 | def giveme(message, something): 153 | message.reply('Here is {}'.format(something)) 154 | ``` 155 | 156 | If you would like to have a command like 'stats' and 'stats start_date end_date', you can create reg ex like so: 157 | 158 | ```python 159 | from slackbot.bot import respond_to 160 | import re 161 | 162 | 163 | @respond_to('stat$', re.IGNORECASE) 164 | @respond_to('stat (.*) (.*)', re.IGNORECASE) 165 | def stats(message, start_date=None, end_date=None): 166 | ``` 167 | 168 | 169 | And add the plugins module to `PLUGINS` list of slackbot settings, e.g. slackbot_settings.py: 170 | 171 | ```python 172 | PLUGINS = [ 173 | 'slackbot.plugins', 174 | 'mybot.plugins', 175 | ] 176 | ``` 177 | 178 | ## The `@default_reply` decorator 179 | 180 | *Added in slackbot 0.4.1* 181 | 182 | Besides specifying `DEFAULT_REPLY` in `slackbot_settings.py`, you can also decorate a function with the `@default_reply` decorator to make it the default reply handler, which is more handy. 183 | 184 | ```python 185 | @default_reply 186 | def my_default_handler(message): 187 | message.reply('...') 188 | ``` 189 | 190 | Here is another variant of the decorator: 191 | 192 | ```python 193 | @default_reply(r'hello.*)') 194 | def my_default_handler(message): 195 | message.reply('...') 196 | ``` 197 | 198 | The above default handler would only handle the messages which must (1) match the specified pattern and (2) can't be handled by any other registered handler. 199 | 200 | ## List of third party plugins 201 | 202 | You can find a list of the available third party plugins on [this page](https://github.com/lins05/slackbot/wiki/Plugins). 203 | -------------------------------------------------------------------------------- /tests/unit/test_dispatcher.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import slackbot.dispatcher 4 | 5 | 6 | TEST_ALIASES = ['!', '$', 'botbro'] 7 | FAKE_BOT_ID = 'US99999' 8 | FAKE_BOT_ATNAME = '<@' + FAKE_BOT_ID + '>' 9 | FAKE_BOT_NAME = 'fakebot' 10 | FAKE_CHANNEL = 'C12942JF92' 11 | 12 | 13 | class FakePluginManager: 14 | def raising(self, message): 15 | raise RuntimeError 16 | 17 | def okay(self, message): 18 | message.reply('okay') 19 | 20 | def default_okay(self, message): 21 | message.reply('default_okay') 22 | 23 | def get_plugins(self, category, message): 24 | if message == 'no_plugin_defined': 25 | return [[None, None]] 26 | if category == 'default_reply': 27 | return [[getattr(self, 'default_'+message), []]] 28 | else: 29 | return [[getattr(self, message), []]] 30 | 31 | 32 | class FakeClient: 33 | def __init__(self): 34 | self.rtm_messages = [] 35 | 36 | def rtm_send_message(self, channel, message, attachments=None): 37 | self.rtm_messages.append((channel, message)) 38 | 39 | 40 | class FakeMessage: 41 | def __init__(self, client, msg): 42 | self._client = client 43 | self._msg = msg 44 | 45 | def reply(self, message): 46 | # Perhaps a bit unnecessary to do it this way, but it's close to how 47 | # dispatcher and message actually works 48 | self._client.rtm_send_message(self._msg['channel'], message) 49 | 50 | 51 | @pytest.fixture() 52 | def setup_aliases(monkeypatch): 53 | monkeypatch.setattr('slackbot.settings.ALIASES', ','.join(TEST_ALIASES)) 54 | 55 | 56 | @pytest.fixture() 57 | def dispatcher(monkeypatch): 58 | monkeypatch.setattr('slackbot.settings.DEFAULT_REPLY', 'sorry') 59 | dispatcher = slackbot.dispatcher.MessageDispatcher(None, None, None) 60 | monkeypatch.setattr(dispatcher, '_get_bot_id', lambda: FAKE_BOT_ID) 61 | monkeypatch.setattr(dispatcher, '_get_bot_name', lambda: FAKE_BOT_NAME) 62 | dispatcher._client = FakeClient() 63 | dispatcher._plugins = FakePluginManager() 64 | return dispatcher 65 | 66 | 67 | def test_aliases(setup_aliases, dispatcher): 68 | msg = { 69 | 'channel': 'C99999' 70 | } 71 | 72 | for a in TEST_ALIASES: 73 | msg['text'] = a + ' hello' 74 | msg = dispatcher.filter_text(msg) 75 | assert msg['text'] == 'hello' 76 | msg['text'] = a + 'hello' 77 | msg = dispatcher.filter_text(msg) 78 | assert msg['text'] == 'hello' 79 | 80 | 81 | def test_nondirectmsg_works(dispatcher): 82 | 83 | # the ID of someone that is not the bot 84 | other_id = '<@U1111>' 85 | msg = { 86 | 'text': other_id + ' hello', 87 | 'channel': 'C99999' 88 | } 89 | 90 | assert dispatcher.filter_text(msg) is None 91 | 92 | 93 | def test_bot_atname_works(dispatcher): 94 | msg = { 95 | 'text': FAKE_BOT_ATNAME + ' hello', 96 | 'channel': 'C99999' 97 | } 98 | 99 | msg = dispatcher.filter_text(msg) 100 | assert msg['text'] == 'hello' 101 | 102 | 103 | def test_bot_name_works(dispatcher): 104 | msg = { 105 | 'channel': 'C99999' 106 | } 107 | 108 | msg['text'] = FAKE_BOT_NAME + ': hello' 109 | msg = dispatcher.filter_text(msg) 110 | assert msg['text'] == 'hello' 111 | msg['text'] = FAKE_BOT_NAME + ':hello' 112 | msg = dispatcher.filter_text(msg) 113 | assert msg['text'] == 'hello' 114 | 115 | 116 | def test_botname_works_with_aliases_present(setup_aliases, dispatcher): 117 | msg = { 118 | 'text': FAKE_BOT_ATNAME + ' hello', 119 | 'channel': 'G99999' 120 | } 121 | 122 | msg = dispatcher.filter_text(msg) 123 | assert msg['text'] == 'hello' 124 | 125 | 126 | def test_no_aliases_doesnt_work(dispatcher): 127 | msg = { 128 | 'channel': 'G99999' 129 | } 130 | for a in TEST_ALIASES: 131 | text = a + ' hello' 132 | msg['text'] = text 133 | assert dispatcher.filter_text(msg) is None 134 | assert msg['text'] == text 135 | text = a + 'hello' 136 | msg['text'] = text 137 | assert dispatcher.filter_text(msg) is None 138 | assert msg['text'] == text 139 | 140 | 141 | def test_direct_message(dispatcher): 142 | msg = { 143 | 'text': 'hello', 144 | 'channel': 'D99999' 145 | } 146 | 147 | msg = dispatcher.filter_text(msg) 148 | assert msg['text'] == 'hello' 149 | 150 | 151 | def test_direct_message_with_name(dispatcher): 152 | msg = { 153 | 'text': FAKE_BOT_ATNAME + ' hello', 154 | 'channel': 'D99999' 155 | } 156 | 157 | msg = dispatcher.filter_text(msg) 158 | assert msg['text'] == 'hello' 159 | 160 | 161 | def test_dispatch_msg(dispatcher, monkeypatch): 162 | monkeypatch.setattr('slackbot.dispatcher.Message', FakeMessage) 163 | dispatcher.dispatch_msg( 164 | ['reply_to', {'text': 'okay', 'channel': FAKE_CHANNEL}]) 165 | assert dispatcher._client.rtm_messages == [(FAKE_CHANNEL, 'okay')] 166 | 167 | 168 | def test_dispatch_msg_exception(dispatcher, monkeypatch): 169 | monkeypatch.setattr('slackbot.dispatcher.Message', FakeMessage) 170 | dispatcher.dispatch_msg( 171 | ['reply_to', {'text': 'raising', 'channel': FAKE_CHANNEL}]) 172 | assert len(dispatcher._client.rtm_messages) == 1 173 | error = dispatcher._client.rtm_messages[0] 174 | assert error[0] == FAKE_CHANNEL 175 | assert 'RuntimeError' in error[1] 176 | 177 | 178 | def test_dispatch_msg_errors_to(dispatcher, monkeypatch): 179 | monkeypatch.setattr('slackbot.dispatcher.Message', FakeMessage) 180 | dispatcher._errors_to = 'D12345' 181 | dispatcher.dispatch_msg( 182 | ['reply_to', {'text': 'raising', 'channel': FAKE_CHANNEL}]) 183 | assert len(dispatcher._client.rtm_messages) == 2 184 | user_error = dispatcher._client.rtm_messages[0] 185 | assert user_error[0] == FAKE_CHANNEL 186 | error = dispatcher._client.rtm_messages[1] 187 | assert error[0] == 'D12345' 188 | assert 'RuntimeError' in error[1] 189 | 190 | 191 | def test_dispatch_default_msg(dispatcher, monkeypatch): 192 | monkeypatch.setattr('slackbot.dispatcher.Message', FakeMessage) 193 | dispatcher.dispatch_msg( 194 | ['respond_to', {'text': 'no_plugin_defined', 'channel': FAKE_CHANNEL}]) 195 | assert dispatcher._client.rtm_messages == [(FAKE_CHANNEL, 'sorry')] 196 | 197 | 198 | def test_dispatch_default_msg_plugin(dispatcher, monkeypatch): 199 | monkeypatch.setattr('slackbot.dispatcher.Message', FakeMessage) 200 | dispatcher.dispatch_msg( 201 | ['respond_to', {'text': 'default_okay', 'channel': FAKE_CHANNEL}]) 202 | assert dispatcher._client.rtm_messages == [(FAKE_CHANNEL, 'default_okay')] 203 | 204 | 205 | def test_none_text(dispatcher): 206 | # Test for #138: If new msg text is None, fallback to empty str 207 | msg = { 208 | 'text': None, 209 | 'channel': 'C99999' 210 | } 211 | # Should not raise a TypeError 212 | msg = dispatcher.filter_text(msg) 213 | assert msg is None 214 | -------------------------------------------------------------------------------- /tests/unit/slackclient_data.py: -------------------------------------------------------------------------------- 1 | USERS = { 2 | u'U0X642GBF': { 3 | u'status': None, u'profile': { 4 | u'fields': None, u'api_app_id': u'', 5 | u'image_1024': 6 | u'https://avatars.slack-edge.com/2016-04-01' 7 | u'/31208087621_5cdcc86ea7191a220468_512.png', 8 | u'real_name': u'', 9 | u'image_24': 10 | u'https://avatars.slack-edge.com/2016-04-01' 11 | u'/31208087621_5cdcc86ea7191a220468_24.png', 12 | u'image_original': 13 | u'https://avatars.slack-edge.com/2016-04-01' 14 | u'/31208087621_5cdcc86ea7191a220468_original.png', 15 | u'real_name_normalized': u'', 16 | u'image_512': 17 | u'https://avatars.slack-edge.com/2016-04-01' 18 | u'/31208087621_5cdcc86ea7191a220468_512.png', 19 | u'image_32': 20 | u'https://avatars.slack-edge.com/2016-04-01' 21 | u'/31208087621_5cdcc86ea7191a220468_32.png', 22 | u'image_48': 23 | u'https://avatars.slack-edge.com/2016-04-01' 24 | u'/31208087621_5cdcc86ea7191a220468_48.png', 25 | u'image_72': 26 | u'https://avatars.slack-edge.com/2016-04-01' 27 | u'/31208087621_5cdcc86ea7191a220468_72.png', 28 | u'avatar_hash': u'5cdcc86ea719', 29 | u'image_192': 30 | u'https://avatars.slack-edge.com/2016-04-01' 31 | u'/31208087621_5cdcc86ea7191a220468_192.png', 32 | u'bot_id': u'B0X5WKZGW' 33 | }, u'tz': None, u'name': u'testbot', u'presence': u'away', 34 | u'deleted': False, u'is_bot': True, 35 | u'tz_label': u'Pacific Daylight Time', u'real_name': u'', 36 | u'color': u'e7392d', u'team_id': u'T0X1LOL22', u'is_admin': False, 37 | u'is_ultra_restricted': False, u'is_restricted': False, 38 | u'is_owner': False, u'tz_offset': -25200, u'id': u'U0X642GBF', 39 | u'is_primary_owner': False 40 | }, u'U0X4QA7R7': { 41 | u'status': None, u'profile': { 42 | u'first_name': u'First', u'last_name': u'Last', 43 | u'fields': None, u'real_name': u'First Last', 44 | u'image_24': 45 | u'https://secure.gravatar.com/avatar/12345.jpg', 46 | u'real_name_normalized': u'First Last', 47 | u'image_512': 48 | u'https://secure.gravatar.com/avatar/12345.png', 49 | u'image_32': 50 | u'https://secure.gravatar.com/avatar/12345.png', 51 | u'image_48': 52 | u'https://secure.gravatar.com/avatar/12345.png', 53 | u'image_72': 54 | u'https://secure.gravatar.com/avatar/12345.png', 55 | u'avatar_hash': u'hw9avn3s9df', 56 | u'email': u'first.last@example.com', 57 | u'image_192': 58 | u'https://secure.gravatar.com/avatar/12345.png' 59 | }, u'tz': u'America/Los_Angeles', u'name': u'user', 60 | u'presence': u'active', u'deleted': False, u'is_bot': False, 61 | u'tz_label': u'Pacific Daylight Time', 62 | u'real_name': u'First Last', u'color': u'9f69e7', 63 | u'team_id': u'T0X1LOL22', u'is_admin': True, 64 | u'is_ultra_restricted': False, u'is_restricted': False, 65 | u'is_owner': True, u'tz_offset': -25200, u'id': u'U0X4QA7R7', 66 | u'is_primary_owner': True 67 | }, u'USLACKBOT': { 68 | u'status': None, u'profile': { 69 | u'first_name': u'slackbot', u'last_name': u'', u'fields': None, 70 | u'real_name': u'slackbot', 71 | u'image_24': u'https://a.slack-edge.com/0180/img/slackbot_24.png', 72 | u'real_name_normalized': u'slackbot', 73 | u'image_512': u'https://a.slack-edge.com/7fa9/img/slackbot_512' 74 | u'.png', 75 | u'image_32': u'https://a.slack-edge.com/2fac/plugins/slackbot' 76 | u'/assets/service_32.png', 77 | u'image_48': u'https://a.slack-edge.com/2fac/plugins/slackbot' 78 | u'/assets/service_48.png', 79 | u'avatar_hash': u'sv1444671949', 80 | u'image_72': u'https://a.slack-edge.com/0180/img/slackbot_72.png', 81 | u'email': None, 82 | u'image_192': u'https://a.slack-edge.com/66f9/img/slackbot_192.png' 83 | }, u'tz': None, u'name': u'slackbot', u'presence': u'active', 84 | u'deleted': False, u'is_bot': False, 85 | u'tz_label': u'Pacific Daylight Time', u'real_name': u'slackbot', 86 | u'color': u'757575', u'team_id': u'T0X1LOL22', u'is_admin': False, 87 | u'is_ultra_restricted': False, u'is_restricted': False, 88 | u'is_owner': False, u'tz_offset': -25200, u'id': u'USLACKBOT', 89 | u'is_primary_owner': False 90 | } 91 | } 92 | 93 | CHANNELS = { 94 | u'D0X6385P1': { 95 | u'last_read': u'0000000000.000000', u'created': 1459497074, 96 | u'unread_count': 1, u'is_open': True, u'user': u'USLACKBOT', 97 | u'unread_count_display': 1, u'latest': { 98 | u'text': u'message from slackbot', 99 | u'type': u'message', u'user': u'USLACKBOT', 100 | u'ts': u'1459502580.797060' 101 | }, u'is_im': True, u'id': u'D0X6385P1', u'has_pins': False 102 | }, u'C0X4HEKPA': { 103 | u'is_general': False, u'name': u'random', u'is_channel': True, 104 | u'created': 1459474652, u'is_member': False, u'is_archived': False, 105 | u'creator': u'U0X4QA7R7', u'id': u'C0X4HEKPA', u'has_pins': False 106 | }, u'G0X62KL92': { 107 | u'topic': {u'last_set': 0, u'value': u'', u'creator': u''}, 108 | u'name': u'testbot-test', u'last_read': u'1459498753.000002', 109 | u'creator': u'U0X4QA7R7', u'is_mpim': False, u'is_archived': False, 110 | u'created': 1459498753, u'is_group': True, 111 | u'members': [u'U0X4QA7R7', u'U0X642GBF', u'U0X6YHC02'], 112 | u'unread_count': 98, u'is_open': True, 113 | u'purpose': {u'last_set': 0, u'value': u'', u'creator': u''}, 114 | u'unread_count_display': 46, u'latest': { 115 | u'text': u'testbot: help', u'type': u'message', 116 | u'user': u'U0X4QA7R7', u'ts': u'1459642144.000004' 117 | }, u'id': u'G0X62KL92', u'has_pins': False 118 | }, u'D0X6EF55G': { 119 | u'last_read': u'0000000000.000000', u'created': 1459497074, 120 | u'unread_count': 594, u'is_open': True, u'user': u'U0X4QA7R7', 121 | u'unread_count_display': 248, u'latest': { 122 | u'text': u'Bad command ' 123 | u'"\u4f60\u4e0d\u660e\u767d\uff0c\u5bf9\u5417\uff1f' 124 | u'", You can ask me one of the following ' 125 | u'questions:\n\n \u2022 `\u4f60\u597d` \n ' 126 | u'\u2022 `hello$` \n \u2022 `hello_formatting` ' 127 | u'\n \u2022 `upload \\<?(.*)\\>?` \n ' 128 | u'\u2022 `hello_decorators` ', 129 | u'type': u'message', u'user': u'U0X642GBF', 130 | u'ts': u'1459620329.000350' 131 | }, u'is_im': True, u'id': u'D0X6EF55G', u'has_pins': False 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /slackbot/slackclient.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import print_function, absolute_import 4 | import os 5 | import json 6 | import logging 7 | import time 8 | from ssl import SSLError 9 | from copy import deepcopy 10 | 11 | import slacker 12 | from six import iteritems 13 | 14 | from websocket import ( 15 | create_connection, WebSocketException, WebSocketConnectionClosedException 16 | ) 17 | 18 | from slackbot.utils import to_utf8, get_http_proxy 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | def webapi_generic_list(webapi, resource_key, response_key, **kw): 23 | """Generic .list request, where could be users, chanels, 24 | etc.""" 25 | ret = [] 26 | next_cursor = None 27 | while True: 28 | args = deepcopy(kw) 29 | if resource_key == 'conversations': 30 | # Slack API says max limit is 1000 31 | args['limit'] = 800 32 | if next_cursor: 33 | args['cursor'] = next_cursor 34 | response = getattr(webapi, resource_key).list(**args) 35 | ret.extend(response.body.get(response_key)) 36 | 37 | next_cursor = response.body.get('response_metadata', {}).get('next_cursor') 38 | if not next_cursor: 39 | break 40 | logging.info('Getting next page for %s (%s collected)', resource_key, len(ret)) 41 | return ret 42 | 43 | class SlackClient(object): 44 | def __init__(self, token, timeout=None, bot_icon=None, bot_emoji=None, connect=True, 45 | rtm_start_args=None): 46 | self.token = token 47 | self.bot_icon = bot_icon 48 | self.bot_emoji = bot_emoji 49 | self.username = None 50 | self.domain = None 51 | self.login_data = None 52 | self.websocket = None 53 | self.users = {} 54 | self.channels = {} 55 | self.connected = False 56 | self.rtm_start_args = rtm_start_args 57 | 58 | if timeout is None: 59 | self.webapi = slacker.Slacker(self.token, rate_limit_retries=30) 60 | else: 61 | self.webapi = slacker.Slacker(self.token, rate_limit_retries=30, timeout=timeout) 62 | 63 | if connect: 64 | self.ensure_connection() 65 | 66 | def rtm_connect(self): 67 | reply = self.webapi.rtm.start(**(self.rtm_start_args or {})).body 68 | time.sleep(1) 69 | self.parse_slack_login_data(reply) 70 | 71 | def ensure_connection(self): 72 | while True: 73 | try: 74 | self.list_users_and_channels() 75 | self.rtm_connect() 76 | logger.warning('reconnected to slack rtm websocket') 77 | return 78 | except Exception as e: 79 | logger.exception('failed to reconnect: %s', e) 80 | time.sleep(5) 81 | 82 | def list_users(self): 83 | return webapi_generic_list(self.webapi, 'users', 'members') 84 | 85 | def list_channels(self): 86 | return webapi_generic_list(self.webapi, 'conversations', 'channels', types='public_channel,private_channel,mpim,im') 87 | 88 | def list_users_and_channels(self): 89 | logger.info('Loading all users') 90 | self.parse_user_data(self.list_users()) 91 | logger.info('Loaded all users') 92 | 93 | logger.info('Loading all channels') 94 | self.parse_channel_data(self.list_channels()) 95 | logger.info('Loaded all channels') 96 | 97 | def parse_slack_login_data(self, login_data): 98 | self.login_data = login_data 99 | self.domain = self.login_data['team']['domain'] 100 | self.username = self.login_data['self']['name'] 101 | 102 | 103 | proxy, proxy_port, no_proxy = get_http_proxy(os.environ) 104 | 105 | self.websocket = create_connection(self.login_data['url'], http_proxy_host=proxy, 106 | http_proxy_port=proxy_port, http_no_proxy=no_proxy) 107 | 108 | self.websocket.sock.setblocking(0) 109 | 110 | def parse_channel_data(self, channel_data): 111 | self.channels.update({c['id']: c for c in channel_data}) 112 | 113 | def parse_user_data(self, user_data): 114 | self.users.update({u['id']: u for u in user_data}) 115 | 116 | def send_to_websocket(self, data): 117 | """Send (data) directly to the websocket.""" 118 | data = json.dumps(data) 119 | self.websocket.send(data) 120 | 121 | def ping(self): 122 | return self.send_to_websocket({'type': 'ping'}) 123 | 124 | def websocket_safe_read(self): 125 | """Returns data if available, otherwise ''. Newlines indicate multiple messages """ 126 | data = '' 127 | while True: 128 | try: 129 | data += '{0}\n'.format(self.websocket.recv()) 130 | except WebSocketException as e: 131 | if isinstance(e, WebSocketConnectionClosedException): 132 | logger.warning('lost websocket connection, try to reconnect now') 133 | else: 134 | logger.warning('websocket exception: %s', e) 135 | self.ensure_connection() 136 | except Exception as e: 137 | if isinstance(e, SSLError) and e.errno == 2: 138 | pass 139 | else: 140 | logger.warning('Exception in websocket_safe_read: %s', e) 141 | return data.rstrip() 142 | 143 | def rtm_read(self): 144 | json_data = self.websocket_safe_read() 145 | data = [] 146 | if json_data != '': 147 | for d in json_data.split('\n'): 148 | data.append(json.loads(d)) 149 | return data 150 | 151 | def rtm_send_message(self, channel, message, attachments=None, thread_ts=None): 152 | message_json = { 153 | 'type': 'message', 154 | 'channel': channel, 155 | 'text': message, 156 | 'attachments': attachments, 157 | 'thread_ts': thread_ts, 158 | } 159 | self.send_to_websocket(message_json) 160 | 161 | def upload_file(self, channel, fname, fpath, comment): 162 | fname = fname or to_utf8(os.path.basename(fpath)) 163 | self.webapi.files.upload(fpath, 164 | channels=channel, 165 | filename=fname, 166 | initial_comment=comment) 167 | 168 | def upload_content(self, channel, fname, content, comment): 169 | self.webapi.files.upload(None, 170 | channels=channel, 171 | content=content, 172 | filename=fname, 173 | initial_comment=comment) 174 | 175 | def send_message(self, channel, message, attachments=None, as_user=True, thread_ts=None): 176 | self.webapi.chat.post_message( 177 | channel, 178 | message, 179 | username=self.login_data['self']['name'], 180 | icon_url=self.bot_icon, 181 | icon_emoji=self.bot_emoji, 182 | attachments=attachments, 183 | as_user=as_user, 184 | thread_ts=thread_ts) 185 | 186 | def get_channel(self, channel_id): 187 | return Channel(self, self.channels[channel_id]) 188 | 189 | def open_dm_channel(self, user_id): 190 | return self.webapi.conversations.open(users=user_id).body["channel"]["id"] 191 | 192 | def find_channel_by_name(self, channel_name): 193 | for channel_id, channel in iteritems(self.channels): 194 | try: 195 | name = channel['name'] 196 | except KeyError: 197 | name = self.users[channel['user']]['name'] 198 | if name == channel_name: 199 | return channel_id 200 | 201 | def get_user(self, user_id): 202 | return self.users.get(user_id) 203 | 204 | def find_user_by_name(self, username): 205 | for userid, user in iteritems(self.users): 206 | if user['name'] == username: 207 | return userid 208 | 209 | def react_to_message(self, emojiname, channel, timestamp): 210 | self.webapi.reactions.add( 211 | name=emojiname, 212 | channel=channel, 213 | timestamp=timestamp) 214 | 215 | 216 | class SlackConnectionError(Exception): 217 | pass 218 | 219 | 220 | class Channel(object): 221 | def __init__(self, slackclient, body): 222 | self._body = body 223 | self._client = slackclient 224 | 225 | def __eq__(self, compare_str): 226 | name = self._body['name'] 227 | cid = self._body['id'] 228 | return name == compare_str or "#" + name == compare_str or cid == compare_str 229 | 230 | def upload_file(self, fname, fpath, initial_comment=''): 231 | self._client.upload_file( 232 | self._body['id'], 233 | to_utf8(fname), 234 | to_utf8(fpath), 235 | to_utf8(initial_comment) 236 | ) 237 | 238 | def upload_content(self, fname, content, initial_comment=''): 239 | self._client.upload_content( 240 | self._body['id'], 241 | to_utf8(fname), 242 | to_utf8(content), 243 | to_utf8(initial_comment) 244 | ) 245 | -------------------------------------------------------------------------------- /tests/functional/test_functional.py: -------------------------------------------------------------------------------- 1 | #coding: UTF-8 2 | 3 | """ 4 | These functional tests would start a slackbot, and use the slack web api to 5 | drive the tests against the bot. 6 | """ 7 | 8 | import os 9 | from os.path import join, abspath, dirname, basename 10 | import subprocess 11 | import pytest 12 | from tests.functional.driver import Driver 13 | from tests.functional.slackbot_settings import ( 14 | testbot_apitoken, testbot_username, 15 | driver_apitoken, driver_username, test_channel, test_private_channel 16 | ) 17 | 18 | TRAVIS = 'TRAVIS' in os.environ 19 | 20 | def stop_proxy(): 21 | os.system('slackbot-test-ctl stopproxy') 22 | 23 | def start_proxy(): 24 | os.system('slackbot-test-ctl startproxy') 25 | 26 | def _start_bot_process(): 27 | args = [ 28 | 'python', 29 | 'tests/functional/run.py', 30 | ] 31 | if TRAVIS: 32 | args = ['slackbot-test-ctl', 'run'] + args 33 | env = dict(os.environ) 34 | env['SLACKBOT_API_TOKEN'] = testbot_apitoken 35 | env['SLACKBOT_TEST'] = 'true' 36 | env['PYTHONPATH'] = ':'.join( 37 | [join(dirname(abspath(__file__))), '../..', env.get('PYTHONPATH', '')]) 38 | return subprocess.Popen(args, env=env) 39 | 40 | @pytest.yield_fixture(scope='module') # pylint: disable=E1101 41 | def driver(): 42 | driver = Driver(driver_apitoken, 43 | driver_username, 44 | testbot_username, 45 | test_channel, 46 | test_private_channel) 47 | driver.start() 48 | p = _start_bot_process() 49 | driver.wait_for_bot_online() 50 | yield driver 51 | p.terminate() 52 | 53 | 54 | @pytest.fixture(autouse=True) # pylint: disable=E1101 55 | def clear_events(driver): 56 | driver.clear_events() 57 | 58 | 59 | def test_bot_get_online(driver): # pylint: disable=W0613 60 | pass 61 | 62 | 63 | def test_bot_respond_to_simple_message(driver): 64 | driver.send_direct_message('hello') 65 | driver.wait_for_bot_direct_message('hello sender!') 66 | 67 | 68 | def test_bot_respond_to_simple_message_with_webapi(driver): 69 | driver.send_direct_message('reply_webapi') 70 | driver.wait_for_bot_direct_message('hello there!') 71 | 72 | 73 | def test_bot_respond_to_simple_message_with_formatting(driver): 74 | driver.send_direct_message('hello_formatting') 75 | driver.wait_for_bot_direct_message('_hello_ sender!') 76 | 77 | 78 | def test_bot_respond_to_simple_message_case_insensitive(driver): 79 | driver.send_direct_message('hEllO') 80 | driver.wait_for_bot_direct_message('hello sender!') 81 | 82 | 83 | def test_bot_respond_to_simple_message_multiple_plugins(driver): 84 | driver.send_direct_message('hello_formatting hello') 85 | driver.wait_for_bot_direct_messages({'hello sender!', '_hello_ sender!'}) 86 | 87 | 88 | def test_bot_direct_message_with_at_prefix(driver): 89 | driver.send_direct_message('hello', tobot=True) 90 | driver.wait_for_bot_direct_message('hello sender!') 91 | driver.send_direct_message('hello', tobot=True, colon=False) 92 | driver.wait_for_bot_direct_message('hello sender!') 93 | 94 | 95 | def test_bot_default_reply(driver): 96 | driver.send_direct_message('youdontunderstandthiscommand do you') 97 | driver.wait_for_bot_direct_message('.*You can ask me.*') 98 | 99 | 100 | def test_bot_upload_file(driver): 101 | driver.send_direct_message('upload slack.png') 102 | driver.wait_for_bot_direct_message('uploading slack.png') 103 | driver.wait_for_file_uploaded('slack.png') 104 | 105 | 106 | def test_bot_upload_file_from_link(driver): 107 | url = 'https://slack.com/favicon.ico' 108 | fname = basename(url) 109 | driver.send_direct_message('upload favicon') 110 | driver.wait_for_bot_direct_message('uploading <%s>' % url) 111 | driver.wait_for_file_uploaded(fname) 112 | 113 | 114 | def test_bot_upload_file_from_content(driver): 115 | driver.send_direct_message('send_string_content') 116 | driver.wait_for_file_uploaded('content.txt') 117 | 118 | 119 | def test_bot_reply_to_channel_message(driver): 120 | driver.send_channel_message('hello') 121 | driver.wait_for_bot_channel_message('hello sender!') 122 | driver.send_channel_message('hello', colon=False) 123 | driver.wait_for_bot_channel_message('hello sender!') 124 | driver.send_channel_message('hello', space=False) 125 | driver.wait_for_bot_channel_message('hello sender!') 126 | # This is hard for a user to do, but why not test it? 127 | driver.send_channel_message('hello', colon=False, space=False) 128 | driver.wait_for_bot_channel_message('hello sender!') 129 | 130 | 131 | def test_bot_channel_reply_to_name_colon(driver): 132 | driver.send_channel_message('hello', tobot=False, toname=True) 133 | driver.wait_for_bot_channel_message('hello sender!') 134 | driver.send_channel_message('hello', tobot=False, toname=True, space=False) 135 | driver.wait_for_bot_channel_message('hello sender!') 136 | driver.send_channel_message('hello', tobot=False, toname=True, colon=False) 137 | driver.wait_for_bot_channel_message('hello channel!', tosender=False) 138 | driver.send_channel_message('hello', tobot=False, toname=True, colon=False, 139 | space=False) 140 | driver.wait_for_bot_channel_message('hello channel!', tosender=False) 141 | 142 | 143 | def test_bot_private_channel_reply_to_name_colon(driver): 144 | driver.send_private_channel_message('hello', tobot=False, toname=True) 145 | driver.wait_for_bot_private_channel_message('hello sender!') 146 | driver.send_private_channel_message('hello', tobot=False, toname=True, space=False) 147 | driver.wait_for_bot_private_channel_message('hello sender!') 148 | driver.send_private_channel_message('hello', tobot=False, toname=True, colon=False) 149 | driver.wait_for_bot_private_channel_message('hello channel!', tosender=False) 150 | driver.send_private_channel_message('hello', tobot=False, toname=True, colon=False, 151 | space=False) 152 | driver.wait_for_bot_private_channel_message('hello channel!', tosender=False) 153 | 154 | 155 | def test_bot_listen_to_channel_message(driver): 156 | driver.send_channel_message('hello', tobot=False) 157 | driver.wait_for_bot_channel_message('hello channel!', tosender=False) 158 | 159 | 160 | def test_bot_react_to_channel_message(driver): 161 | driver.send_channel_message('hey!', tobot=False) 162 | driver.ensure_reaction_posted('eggplant') 163 | 164 | 165 | def test_bot_reply_to_private_channel_message(driver): 166 | driver.send_private_channel_message('hello') 167 | driver.wait_for_bot_private_channel_message('hello sender!') 168 | driver.send_private_channel_message('hello', colon=False) 169 | driver.wait_for_bot_private_channel_message('hello sender!') 170 | 171 | 172 | def test_bot_ignores_non_related_message_response_tosender(driver): 173 | driver.send_channel_message('hello', tobot=True) 174 | driver.ensure_only_specificmessage_from_bot('hello sender!', tosender=True) 175 | 176 | 177 | def test_bot_ignores_non_related_message_response_tochannel(driver): 178 | driver.send_channel_message('hello', tobot=False) 179 | driver.ensure_only_specificmessage_from_bot('hello channel!', tosender=False) 180 | 181 | 182 | def test_bot_ignores_unknown_message_noresponse_tochannel(driver): 183 | driver.send_channel_message('unknown message', tobot=False) 184 | driver.ensure_no_channel_reply_from_bot() 185 | 186 | 187 | def test_bot_send_usage_unknown_message_response_tosender(driver): 188 | driver.send_channel_message('unknown message', tobot=True) 189 | driver.ensure_only_specificmessage_from_bot('Bad command "unknown message".+', tosender=True) 190 | 191 | 192 | def test_bot_reply_to_message_multiple_decorators(driver): 193 | driver.send_channel_message('hello_decorators') 194 | driver.wait_for_bot_channel_message('hello!', tosender=False) 195 | driver.send_channel_message('hello_decorators', tobot=False) 196 | driver.wait_for_bot_channel_message('hello!', tosender=False) 197 | driver.send_direct_message('hello_decorators') 198 | driver.wait_for_bot_direct_message('hello!') 199 | 200 | 201 | @pytest.mark.skipif(not TRAVIS, reason="only run reconnect tests on travis builds") 202 | def test_bot_reconnect(driver): 203 | driver.wait_for_bot_online() 204 | stop_proxy() 205 | driver.wait_for_bot_offline() 206 | start_proxy() 207 | driver.wait_for_bot_online() 208 | test_bot_respond_to_simple_message(driver) 209 | 210 | 211 | def test_bot_reply_with_unicode_message(driver): 212 | driver.send_direct_message(u'你好') 213 | driver.wait_for_bot_direct_message(u'你好') 214 | driver.send_direct_message(u'你不明白,对吗?') 215 | driver.wait_for_bot_direct_message('.*You can ask me.*') 216 | 217 | driver.send_channel_message(u'你好') 218 | driver.wait_for_bot_channel_message(u'你好!') 219 | driver.send_channel_message(u'你不明白,对吗?') 220 | driver.wait_for_bot_channel_message(u'.*You can ask me.*') 221 | 222 | 223 | def test_bot_reply_with_alias_message(driver): 224 | driver.send_channel_message("! hello", tobot=False, colon=False) 225 | driver.wait_for_bot_channel_message("hello sender!", tosender=True) 226 | driver.send_channel_message('!hello', tobot=False, colon=False) 227 | driver.wait_for_bot_channel_message("hello sender!", tosender=True) 228 | 229 | 230 | def test_bot_reply_thread_in_channel(driver): 231 | driver.send_channel_message('start a thread', tobot=False, colon=False) 232 | driver.wait_for_bot_channel_thread_message('I started a thread', tosender=False) 233 | 234 | 235 | def test_bot_reply_thread_in_private_channel(driver): 236 | driver.send_private_channel_message('start a thread', tobot=False, colon=False) 237 | driver.wait_for_bot_private_channel_thread_message('I started a thread', tosender=False) 238 | -------------------------------------------------------------------------------- /slackbot/dispatcher.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | import logging 5 | import re 6 | import time 7 | import traceback 8 | from functools import wraps 9 | 10 | import six 11 | from slackbot.manager import PluginsManager 12 | from slackbot.utils import WorkerPool 13 | from slackbot import settings 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class MessageDispatcher(object): 19 | def __init__(self, slackclient, plugins, errors_to): 20 | self._client = slackclient 21 | self._pool = WorkerPool(self.dispatch_msg) 22 | self._plugins = plugins 23 | self._errors_to = None 24 | if errors_to: 25 | self._errors_to = self._client.find_channel_by_name(errors_to) 26 | if not self._errors_to: 27 | raise ValueError( 28 | 'Could not find errors_to recipient {!r}'.format( 29 | errors_to)) 30 | 31 | alias_regex = '' 32 | if getattr(settings, 'ALIASES', None): 33 | logger.info('using aliases %s', settings.ALIASES) 34 | alias_regex = '|(?P{})'.format('|'.join([re.escape(s) for s in settings.ALIASES.split(',')])) 35 | 36 | self.AT_MESSAGE_MATCHER = re.compile(r'^(?:\<@(?P\w+)\>:?|(?P\w+):{}) ?(?P[\s\S]*)$'.format(alias_regex)) 37 | 38 | def start(self): 39 | self._pool.start() 40 | 41 | def dispatch_msg(self, msg): 42 | category = msg[0] 43 | msg = msg[1] 44 | if not self._dispatch_msg_handler(category, msg): 45 | if category == u'respond_to': 46 | if not self._dispatch_msg_handler('default_reply', msg): 47 | self._default_reply(msg) 48 | 49 | def _dispatch_msg_handler(self, category, msg): 50 | responded = False 51 | for func, args in self._plugins.get_plugins(category, msg.get('text', None)): 52 | if func: 53 | responded = True 54 | try: 55 | func(Message(self._client, msg), *args) 56 | except Exception: 57 | logger.exception( 58 | 'failed to handle message %s with plugin "%s"', 59 | msg['text'], func.__name__) 60 | reply = u'[{}] I had a problem handling "{}"\n'.format( 61 | func.__name__, msg['text']) 62 | tb = u'```\n{}\n```'.format(traceback.format_exc()) 63 | if self._errors_to: 64 | self._client.rtm_send_message(msg['channel'], reply) 65 | self._client.rtm_send_message(self._errors_to, 66 | '{}\n{}'.format(reply, 67 | tb)) 68 | else: 69 | self._client.rtm_send_message(msg['channel'], 70 | '{}\n{}'.format(reply, 71 | tb)) 72 | return responded 73 | 74 | def _on_new_message(self, msg): 75 | # ignore edits 76 | subtype = msg.get('subtype', '') 77 | if subtype == u'message_changed': 78 | return 79 | 80 | botname = self._get_bot_name() 81 | try: 82 | msguser = self._client.users.get(msg['user']) 83 | username = msguser['name'] 84 | except (KeyError, TypeError): 85 | if 'username' in msg: 86 | username = msg['username'] 87 | elif 'bot_profile' in msg and 'name' in msg['bot_profile']: 88 | username = msg['bot_profile']['name'] 89 | else: 90 | return 91 | 92 | if username == botname or username == u'slackbot': 93 | return 94 | 95 | msg_respond_to = self.filter_text(msg) 96 | if msg_respond_to: 97 | self._pool.add_task(('respond_to', msg_respond_to)) 98 | else: 99 | self._pool.add_task(('listen_to', msg)) 100 | 101 | def _get_bot_id(self): 102 | return self._client.login_data['self']['id'] 103 | 104 | def _get_bot_name(self): 105 | return self._client.login_data['self']['name'] 106 | 107 | def filter_text(self, msg): 108 | full_text = msg.get('text', '') or '' 109 | channel = msg['channel'] 110 | bot_name = self._get_bot_name() 111 | bot_id = self._get_bot_id() 112 | m = self.AT_MESSAGE_MATCHER.match(full_text) 113 | 114 | if channel[0] == 'C' or channel[0] == 'G': 115 | if not m: 116 | return 117 | 118 | matches = m.groupdict() 119 | 120 | atuser = matches.get('atuser') 121 | username = matches.get('username') 122 | text = matches.get('text') 123 | alias = matches.get('alias') 124 | 125 | if alias: 126 | atuser = bot_id 127 | 128 | if atuser != bot_id and username != bot_name: 129 | # a channel message at other user 130 | return 131 | 132 | logger.debug('got an AT message: %s', text) 133 | msg['text'] = text 134 | else: 135 | if m: 136 | msg['text'] = m.groupdict().get('text', None) 137 | return msg 138 | 139 | def loop(self): 140 | while True: 141 | events = self._client.rtm_read() 142 | for event in events: 143 | event_type = event.get('type') 144 | if event_type == 'message': 145 | self._on_new_message(event) 146 | elif event_type in ['channel_created', 'channel_rename', 147 | 'group_joined', 'group_rename', 148 | 'im_created']: 149 | channel = [event['channel']] 150 | self._client.parse_channel_data(channel) 151 | elif event_type in ['team_join', 'user_change']: 152 | user = [event['user']] 153 | self._client.parse_user_data(user) 154 | time.sleep(1) 155 | 156 | def _default_reply(self, msg): 157 | default_reply = settings.DEFAULT_REPLY 158 | if default_reply is None: 159 | default_reply = [ 160 | u'Bad command "{}", You can ask me one of the following ' 161 | u'questions:\n'.format( 162 | msg['text']), 163 | ] 164 | default_reply += [ 165 | u' • `{0}` {1}'.format(p.pattern, v.__doc__ or "") 166 | for p, v in 167 | six.iteritems(self._plugins.commands['respond_to'])] 168 | # pylint: disable=redefined-variable-type 169 | default_reply = u'\n'.join(default_reply) 170 | 171 | m = Message(self._client, msg) 172 | m.reply(default_reply) 173 | 174 | 175 | def unicode_compact(func): 176 | """ 177 | Make sure the first parameter of the decorated method to be a unicode 178 | object. 179 | """ 180 | 181 | @wraps(func) 182 | def wrapped(self, text, *a, **kw): 183 | if not isinstance(text, six.text_type): 184 | text = text.decode('utf-8') 185 | return func(self, text, *a, **kw) 186 | 187 | return wrapped 188 | 189 | 190 | class Message(object): 191 | def __init__(self, slackclient, body): 192 | self._client = slackclient 193 | self._body = body 194 | self._plugins = PluginsManager() 195 | 196 | def _get_user_id(self): 197 | if 'user' in self._body: 198 | return self._body['user'] 199 | 200 | return self._client.find_user_by_name(self._body['username']) 201 | 202 | @unicode_compact 203 | def _gen_at_message(self, text): 204 | text = u'<@{}>: {}'.format(self._get_user_id(), text) 205 | return text 206 | 207 | @unicode_compact 208 | def gen_reply(self, text): 209 | chan = self._body['channel'] 210 | if chan.startswith('C') or chan.startswith('G'): 211 | return self._gen_at_message(text) 212 | else: 213 | return text 214 | 215 | @unicode_compact 216 | def reply_webapi(self, text, attachments=None, as_user=True, in_thread=None): 217 | """ 218 | Send a reply to the sender using Web API 219 | 220 | (This function supports formatted message 221 | when using a bot integration) 222 | 223 | If the message was send in a thread, answer in a thread per default. 224 | """ 225 | if in_thread is None: 226 | in_thread = 'thread_ts' in self.body 227 | 228 | if in_thread: 229 | self.send_webapi(text, attachments=attachments, as_user=as_user, thread_ts=self.thread_ts) 230 | else: 231 | text = self.gen_reply(text) 232 | self.send_webapi(text, attachments=attachments, as_user=as_user) 233 | 234 | @unicode_compact 235 | def send_webapi(self, text, attachments=None, as_user=True, thread_ts=None): 236 | """ 237 | Send a reply using Web API 238 | 239 | (This function supports formatted message 240 | when using a bot integration) 241 | """ 242 | self._client.send_message( 243 | self._body['channel'], 244 | text, 245 | attachments=attachments, 246 | as_user=as_user, 247 | thread_ts=thread_ts) 248 | 249 | @unicode_compact 250 | def reply(self, text, in_thread=None): 251 | """ 252 | Send a reply to the sender using RTM API 253 | 254 | (This function doesn't supports formatted message 255 | when using a bot integration) 256 | 257 | If the message was send in a thread, answer in a thread per default. 258 | """ 259 | if in_thread is None: 260 | in_thread = 'thread_ts' in self.body 261 | 262 | if in_thread: 263 | self.send(text, thread_ts=self.thread_ts) 264 | else: 265 | text = self.gen_reply(text) 266 | self.send(text) 267 | 268 | @unicode_compact 269 | def direct_reply(self, text): 270 | """ 271 | Send a reply via direct message using RTM API 272 | 273 | """ 274 | channel_id = self._client.open_dm_channel(self._get_user_id()) 275 | self._client.rtm_send_message(channel_id, text) 276 | 277 | 278 | @unicode_compact 279 | def send(self, text, thread_ts=None): 280 | """ 281 | Send a reply using RTM API 282 | 283 | (This function doesn't supports formatted message 284 | when using a bot integration) 285 | """ 286 | self._client.rtm_send_message(self._body['channel'], text, thread_ts=thread_ts) 287 | 288 | def react(self, emojiname): 289 | """ 290 | React to a message using the web api 291 | """ 292 | self._client.react_to_message( 293 | emojiname=emojiname, 294 | channel=self._body['channel'], 295 | timestamp=self._body['ts']) 296 | 297 | @property 298 | def channel(self): 299 | return self._client.get_channel(self._body['channel']) 300 | 301 | @property 302 | def body(self): 303 | return self._body 304 | 305 | @property 306 | def user(self): 307 | return self._client.get_user(self._body['user']) 308 | 309 | @property 310 | def thread_ts(self): 311 | try: 312 | thread_ts = self.body['thread_ts'] 313 | except KeyError: 314 | thread_ts = self.body['ts'] 315 | 316 | return thread_ts 317 | 318 | def docs_reply(self): 319 | reply = [u' • `{0}` {1}'.format(v.__name__, v.__doc__ or '') 320 | for _, v in 321 | six.iteritems(self._plugins.commands['respond_to'])] 322 | return u'\n'.join(reply) 323 | -------------------------------------------------------------------------------- /tests/functional/driver.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import json 3 | import re 4 | import time 5 | import slacker 6 | import websocket 7 | import six 8 | from six.moves import _thread, range 9 | 10 | 11 | class Driver(object): 12 | """Functional tests driver. It handles the communication with slack api, so that 13 | the tests code can concentrate on higher level logic. 14 | """ 15 | def __init__(self, driver_apitoken, driver_username, testbot_username, channel, private_channel): 16 | self.slacker = slacker.Slacker(driver_apitoken) 17 | self.driver_username = driver_username 18 | self.driver_userid = None 19 | self.test_channel = channel 20 | self.test_private_channel = private_channel 21 | self.users = {} 22 | self.testbot_username = testbot_username 23 | self.testbot_userid = None 24 | # public channel 25 | self.cm_chan = None 26 | # direct message channel 27 | self.dm_chan = None 28 | # private private_channel channel 29 | self.gm_chan = None 30 | self._start_ts = time.time() 31 | self._websocket = None 32 | self.events = [] 33 | self._events_lock = threading.Lock() 34 | 35 | def start(self): 36 | self._rtm_connect() 37 | # self._fetch_users() 38 | self._start_dm_channel() 39 | self._join_test_channel() 40 | 41 | def wait_for_bot_online(self): 42 | self._wait_for_bot_presense(True) 43 | # sleep to allow bot connection to stabilize 44 | time.sleep(2) 45 | 46 | def wait_for_bot_offline(self): 47 | self._wait_for_bot_presense(False) 48 | 49 | def _wait_for_bot_presense(self, online): 50 | for _ in range(10): 51 | time.sleep(2) 52 | if online and self._is_testbot_online(): 53 | break 54 | if not online and not self._is_testbot_online(): 55 | break 56 | else: 57 | raise AssertionError('test bot is still {}'.format('offline' if online else 'online')) 58 | 59 | def _format_message(self, msg, tobot=True, toname=False, colon=True, 60 | space=True): 61 | colon = ':' if colon else '' 62 | space = ' ' if space else '' 63 | if tobot: 64 | msg = u'<@{}>{}{}{}'.format(self.testbot_userid, colon, space, msg) 65 | elif toname: 66 | msg = u'{}{}{}{}'.format(self.testbot_username, colon, space, msg) 67 | return msg 68 | 69 | def send_direct_message(self, msg, tobot=False, colon=True): 70 | msg = self._format_message(msg, tobot, colon) 71 | self._send_message_to_bot(self.dm_chan, msg) 72 | 73 | def _send_channel_message(self, chan, msg, **kwargs): 74 | msg = self._format_message(msg, **kwargs) 75 | self._send_message_to_bot(chan, msg) 76 | 77 | def send_channel_message(self, msg, **kwargs): 78 | self._send_channel_message(self.cm_chan, msg, **kwargs) 79 | 80 | def send_private_channel_message(self, msg, **kwargs): 81 | self._send_channel_message(self.gm_chan, msg, **kwargs) 82 | 83 | def wait_for_bot_direct_message(self, match): 84 | self._wait_for_bot_message(self.dm_chan, match, tosender=False) 85 | 86 | def wait_for_bot_direct_messages(self, matches): 87 | for match in matches: 88 | self._wait_for_bot_message(self.dm_chan, match, tosender=False) 89 | 90 | def wait_for_bot_channel_message(self, match, tosender=True): 91 | self._wait_for_bot_message(self.cm_chan, match, tosender=tosender) 92 | 93 | def wait_for_bot_private_channel_message(self, match, tosender=True): 94 | self._wait_for_bot_message(self.gm_chan, match, tosender=tosender) 95 | 96 | def wait_for_bot_channel_thread_message(self, match, tosender=False): 97 | self._wait_for_bot_message(self.gm_chan, match, tosender=tosender, thread=True) 98 | 99 | def wait_for_bot_private_channel_thread_message(self, match, tosender=False): 100 | self._wait_for_bot_message(self.gm_chan, match, tosender=tosender, 101 | thread=True) 102 | 103 | def ensure_only_specificmessage_from_bot(self, match, wait=5, tosender=False): 104 | if tosender is True: 105 | match = six.text_type(r'^\<@{}\>: {}$').format(self.driver_userid, match) 106 | else: 107 | match = u'^{}$'.format(match) 108 | 109 | for _ in range(wait): 110 | time.sleep(1) 111 | with self._events_lock: 112 | for event in self.events: 113 | if self._is_bot_message(event) and re.match(match, event['text'], re.DOTALL) is None: 114 | raise AssertionError( 115 | u'expected to get message matching "{}", but got message "{}"'.format(match, event['text'])) 116 | 117 | def ensure_no_channel_reply_from_bot(self, wait=5): 118 | for _ in range(wait): 119 | time.sleep(1) 120 | with self._events_lock: 121 | for event in self.events: 122 | if self._is_bot_message(event): 123 | raise AssertionError( 124 | 'expected to get nothing, but got message "{}"'.format(event['text'])) 125 | 126 | def wait_for_file_uploaded(self, name, maxwait=30): 127 | for _ in range(maxwait): 128 | time.sleep(1) 129 | if self._has_uploaded_file_rtm(name): 130 | break 131 | else: 132 | raise AssertionError('expected to get file "{}", but got nothing'.format(name)) 133 | 134 | def ensure_reaction_posted(self, emojiname, maxwait=5): 135 | for _ in range(maxwait): 136 | time.sleep(1) 137 | if self._has_reacted(emojiname): 138 | break 139 | else: 140 | raise AssertionError('expected to get reaction "{}", but got nothing'.format(emojiname)) 141 | 142 | def _send_message_to_bot(self, channel, msg): 143 | self.clear_events() 144 | self._start_ts = time.time() 145 | self.slacker.chat.post_message(channel, msg, username=self.driver_username) 146 | 147 | def _wait_for_bot_message(self, channel, match, maxwait=60, tosender=True, thread=False): 148 | for _ in range(maxwait): 149 | time.sleep(1) 150 | if self._has_got_message_rtm(channel, match, tosender, thread=thread): 151 | break 152 | else: 153 | raise AssertionError('expected to get message like "{}", but got nothing'.format(match)) 154 | 155 | def _has_got_message(self, channel, match, start=None, end=None): 156 | if channel.startswith('C'): 157 | match = six.text_type(r'\<@{}\>: {}').format(self.driver_userid, match) 158 | oldest = start or self._start_ts 159 | latest = end or time.time() 160 | response = self.slacker.conversations.history(channel=channel, oldest=oldest, latest=latest) 161 | for msg in response.body['messages']: 162 | if msg['type'] == 'message' and re.match(match, msg['text'], re.DOTALL): 163 | return True 164 | return False 165 | 166 | def _has_got_message_rtm(self, channel, match, tosender=True, thread=False): 167 | if tosender is True: 168 | match = six.text_type(r'\<@{}\>: {}').format(self.driver_userid, match) 169 | with self._events_lock: 170 | for event in self.events: 171 | if 'type' not in event or \ 172 | (event['type'] == 'message' and 'text' not in event): 173 | print('Unusual event received: ' + repr(event)) 174 | if (not thread or (thread and event.get('thread_ts', False))) \ 175 | and event['type'] == 'message' and re.match(match, event['text'], re.DOTALL): 176 | return True 177 | return False 178 | 179 | def _fetch_users(self): 180 | response = self.slacker.users.list() 181 | for user in response.body['members']: 182 | self.users[user['name']] = user['id'] 183 | 184 | self.testbot_userid = self.users[self.testbot_username] 185 | self.driver_userid = self.users[self.driver_username] 186 | 187 | def _rtm_connect(self): 188 | r = self.slacker.rtm.start().body 189 | self.driver_username = r['self']['name'] 190 | self.driver_userid = r['self']['id'] 191 | 192 | self.users = {u['name']: u['id'] for u in r['users']} 193 | self.testbot_userid = self.users[self.testbot_username] 194 | 195 | self._websocket = websocket.create_connection(r['url']) 196 | self._websocket.sock.setblocking(0) 197 | _thread.start_new_thread(self._rtm_read_forever, tuple()) 198 | 199 | def _websocket_safe_read(self): 200 | """Returns data if available, otherwise ''. Newlines indicate multiple messages """ 201 | data = '' 202 | while True: 203 | try: 204 | data += '{0}\n'.format(self._websocket.recv()) 205 | except Exception: 206 | return data.rstrip() 207 | 208 | def _rtm_read_forever(self): 209 | while True: 210 | json_data = self._websocket_safe_read() 211 | if json_data != '': 212 | with self._events_lock: 213 | self.events.extend([json.loads(d) for d in json_data.split('\n')]) 214 | time.sleep(1) 215 | 216 | def _start_dm_channel(self): 217 | """Start a slack direct messages channel with the test bot""" 218 | response = self.slacker.conversations.open(users=self.testbot_userid) 219 | self.dm_chan = response.body['channel']['id'] 220 | 221 | def _is_testbot_online(self): 222 | response = self.slacker.users.get_presence(self.testbot_userid) 223 | return response.body['presence'] == self.slacker.presence.ACTIVE 224 | 225 | def _has_uploaded_file(self, name, start=None, end=None): 226 | ts_from = start or self._start_ts 227 | ts_to = end or time.time() 228 | response = self.slacker.files.list(user=self.testbot_userid, ts_from=ts_from, ts_to=ts_to) 229 | for f in response.body['files']: 230 | if f['name'] == name: 231 | return True 232 | return False 233 | 234 | def _has_uploaded_file_rtm(self, name): 235 | with self._events_lock: 236 | for event in self.events: 237 | if event['type'] == 'message' \ 238 | and 'files' in event \ 239 | and event['files'][0]['name'] == name \ 240 | and event['files'][0]['user'] == self.testbot_userid: 241 | return True 242 | return False 243 | 244 | def _has_reacted(self, emojiname): 245 | with self._events_lock: 246 | for event in self.events: 247 | if event['type'] == 'reaction_added' \ 248 | and event['user'] == self.testbot_userid \ 249 | and (event.get('reaction', '') == emojiname \ 250 | or event.get('name', '') == emojiname): 251 | return True 252 | return False 253 | 254 | def _join_test_channel(self): 255 | response = self.slacker.channels.join(self.test_channel) 256 | self.cm_chan = response.body['channel']['id'] 257 | self._invite_testbot_to_channel() 258 | 259 | # Slacker/Slack API's still references to private_channels as 'groups' 260 | private_channels = self.slacker.groups.list(self.test_private_channel).body['groups'] 261 | for private_channel in private_channels: 262 | if self.test_private_channel == private_channel['name']: 263 | self.gm_chan = private_channel['id'] 264 | self._invite_testbot_to_private_channel(private_channel) 265 | break 266 | else: 267 | raise RuntimeError('Have you created the private channel {} for testing?'.format( 268 | self.test_private_channel)) 269 | 270 | def _invite_testbot_to_channel(self): 271 | if self.testbot_userid not in self.slacker.channels.info(self.cm_chan).body['channel']['members']: 272 | self.slacker.channels.invite(self.cm_chan, self.testbot_userid) 273 | 274 | def _invite_testbot_to_private_channel(self, private_channel): 275 | if self.testbot_userid not in private_channel['members']: 276 | self.slacker.groups.invite(self.gm_chan, self.testbot_userid) 277 | 278 | def _is_bot_message(self, msg): 279 | if msg['type'] != 'message': 280 | return False 281 | if not msg.get('channel', '').startswith('C'): 282 | return False 283 | return msg.get('user') == self.testbot_userid \ 284 | or msg.get('username') == self.testbot_username 285 | 286 | def clear_events(self): 287 | with self._events_lock: 288 | self.events = [] 289 | --------------------------------------------------------------------------------