diff --git a/.gitignore b/.gitignore index 6dd7dcd..3929762 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist build *.egg-info* .build +.pytest_cache \ No newline at end of file diff --git a/README.md b/README.md index dafacf6..7d4618f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![PyPI](https://badge.fury.io/py/mattermost_bot.svg)](https://pypi.python.org/pypi/mattermost_bot) -[![Codacy](https://api.codacy.com/project/badge/grade/b06f3af1d8a04c6faa9a76a4ae3cb483)](https://www.codacy.com/app/gotlium/mattermost_bot) +[![Codacy](https://api.codacy.com/project/badge/grade/b06f3af1d8a04c6faa9a76a4ae3cb483)](https://www.codacy.com/app/attzonko/mattermost_bot) [![Code Health](https://landscape.io/github/LPgenerator/mattermost_bot/master/landscape.svg?style=flat)](https://landscape.io/github/LPgenerator/mattermost_bot/master) [![Python Support](https://img.shields.io/badge/python-2.7,3.5-blue.svg)](https://pypi.python.org/pypi/mattermost_bot/) [![Mattermost](https://img.shields.io/badge/mattermost-1.4+-blue.svg)](http://www.mattermost.org) @@ -14,7 +14,7 @@ A chat bot for [Mattermost](http://www.mattermost.org). ## Features -* Based on Mattermost [WebSocket API](https://api.mattermost.com) +* Based on Mattermost [WebSocket API](https://api.mattermost.com) - Updated to work with APIv4.0 * Simple plugins mechanism * Messages can be handled concurrently * Automatically reconnect to Mattermost when connection is lost @@ -53,7 +53,7 @@ mattermost_bot_settings.py: ```python SSL_VERIFY = True # Whether to perform SSL cert verification -BOT_URL = 'http:///api/v3' # with 'http://' and with '/api/v3' path. without trailing slash. '/api/v1' - for version < 3.0 +BOT_URL = 'http:///api/v4' # with 'http://' and with '/api/v4' path. without trailing slash. '/api/v1' - for version < 3.0 BOT_LOGIN = '' BOT_PASSWORD = '' BOT_TEAM = '' # possible in lowercase @@ -193,3 +193,56 @@ PLUGINS = [ If you are migrating from `Slack` to the `Mattermost`, and previously you are used `SlackBot`, you can use this battery without any problem. On most cases your plugins will be working properly if you are used standard API or with minimal modifications. + +## Run the tests + +You will need a Mattermost server to run test cases. + + * Create two user accounts for bots to login, ex. `driverbot` and `testbot` + * Create a team, ex. `test-team`, and add `driverbot` and `testbot` into the team + * Make sure the default public channel `off-topic` exists + * Create a private channel (ex. `test`) in team `test-team`, and add `driverbot` and `testbot` into the private channel + +Install `PyTest` in development environment. + +``` +pip install -U pytest +``` + +There are two test categories in `mattermost_bot\tests`: __unit_tests__ and __behavior_tests__. The __behavior_tests__ is done by interactions between a __DriverBot__ and a __TestBot__. + +To run the __behavior_tests__, you have to configure `behavior_tests\bot_settings.py` and `behavior_tests\driver_settings.py`. Example configuration: + +__driver_settings.py__: +```python +PLUGINS = [ +] + +BOT_URL = 'http://mymattermost.server/api/v4' +BOT_LOGIN = 'driverbot@mymail' +BOT_NAME = 'driverbot' +BOT_PASSWORD = 'password' +BOT_TEAM = 'test-team' # this team name should be the same as in bot_settings +BOT_CHANNEL = 'off-topic' # default public channel name +BOT_PRIVATE_CHANNEL = 'test' # a private channel in BOT_TEAM +SSL_VERIFY = True +``` + +__bot_settings.py__: +```python +PLUGINS = [ +] + +BOT_URL = 'http://mymattermost.server/api/v4' +BOT_LOGIN = 'testbot@mymail' +BOT_NAME = 'testbot' +BOT_PASSWORD = 'password' +BOT_TEAM = 'test-team' # this team name should be the same as in driver_settings +BOT_CHANNEL = 'off-topic' # default public channel name +BOT_PRIVATE_CHANNEL = 'test' # a private channel in BOT_TEAM +SSL_VERIFY = True +``` + +Please notice that `BOT_URL`, `BOT_TEAM`, `BOT_CHANNEL`, and `BOT_PRIVATE_CHANNEL` must be the same in both setting files. + +After the settings files are done, switch to root dir of mattermost, and run `pytest` to execute test cases. \ No newline at end of file diff --git a/mattermost_bot/bot.py b/mattermost_bot/bot.py index 26b7d69..7e690f6 100644 --- a/mattermost_bot/bot.py +++ b/mattermost_bot/bot.py @@ -24,8 +24,7 @@ def __init__(self): self._client = MattermostClient( settings.BOT_URL, settings.BOT_TEAM, settings.BOT_LOGIN, settings.BOT_PASSWORD, - settings.SSL_VERIFY - ) + settings.SSL_VERIFY) logger.info('connected to mattermost') self._plugins = PluginsManager() self._dispatcher = MessageDispatcher(self._client, self._plugins) @@ -49,16 +48,17 @@ class PluginsManager(object): 'listen_to': {} } - def __init__(self): - pass + def __init__(self, plugins=[]): + self.plugins = plugins def init_plugins(self): - if hasattr(settings, 'PLUGINS'): - plugins = settings.PLUGINS - else: - plugins = 'mattermost_bot.plugins' + if self.plugins == []: + if hasattr(settings, 'PLUGINS'): + self.plugins = settings.PLUGINS + else: + self.plugins = 'mattermost_bot.plugins' - for plugin in plugins: + for plugin in self.plugins: self._load_plugins(plugin) @staticmethod @@ -92,7 +92,7 @@ def get_plugins(self, category, text): def respond_to(regexp, flags=0): def wrapper(func): - PluginsManager.commands['respond_to'][re.compile(regexp, flags)] = func + PluginsManager.commands['respond_to'][re.compile(regexp, flags | re.DEBUG)] = func logger.info( 'registered respond_to plugin "%s" to "%s"', func.__name__, regexp) return func @@ -102,7 +102,7 @@ def wrapper(func): def listen_to(regexp, flags=0): def wrapper(func): - PluginsManager.commands['listen_to'][re.compile(regexp, flags)] = func + PluginsManager.commands['listen_to'][re.compile(regexp, flags | re.DEBUG)] = func logger.info( 'registered listen_to plugin "%s" to "%s"', func.__name__, regexp) return func diff --git a/mattermost_bot/dispatcher.py b/mattermost_bot/dispatcher.py index 8e44e89..a24f09b 100644 --- a/mattermost_bot/dispatcher.py +++ b/mattermost_bot/dispatcher.py @@ -37,23 +37,27 @@ def get_message(msg): def ignore(self, _msg): msg = self.get_message(_msg) - for prefix in settings.IGNORE_NOTIFIES: - if msg.startswith(prefix): - return True + if any(item in msg for item in settings.IGNORE_NOTIFIES): + return True def is_mentioned(self, msg): mentions = msg.get('data', {}).get('mentions', []) return self._client.user['id'] in mentions def is_personal(self, msg): - channel_id = msg['data']['post']['channel_id'] - if channel_id in self._channel_info: - channel_type = self._channel_info[channel_id] - else: - channel = self._client.api.channel(channel_id) - channel_type = channel['channel']['type'] - self._channel_info[channel_id] = channel_type - return channel_type == 'D' + try: + channel_id = msg['data']['post']['channel_id'] + if channel_id in self._channel_info: + channel_type = self._channel_info[channel_id] + else: + channel = self._client.api.channel(channel_id) + channel_type = channel['channel']['type'] + self._channel_info[channel_id] = channel_type + return channel_type == 'D' + except KeyError as err: + logger.info('Once time workpool exception caused by \ + bot [added to/leave] [team/channel].') + return False def dispatch_msg(self, msg): category = msg[0] @@ -111,9 +115,12 @@ def load_json(self): self.event['data']['mentions']) def loop(self): - for self.event in self._client.messages(True, 'posted'): - self.load_json() - self._on_new_message(self.event) + for self.event in self._client.messages(True, + ['posted', 'added_to_team', 'leave_team', \ + 'user_added', 'user_removed']): + if self.event: + self.load_json() + self._on_new_message(self.event) def _default_reply(self, msg): if settings.DEFAULT_REPLY: @@ -121,14 +128,25 @@ def _default_reply(self, msg): msg['data']['post']['channel_id'], settings.DEFAULT_REPLY) default_reply = [ - u'Bad command "%s", You can ask me one of the ' - u'following questions:\n' % self.get_message(msg), + u'Bad command "%s", Here is what I currently know ' + u'how to do:\n' % self.get_message(msg), ] - docs_fmt = u'{1}' if settings.PLUGINS_ONLY_DOC_STRING else u'`{0}` {1}' - default_reply += [ - docs_fmt.format(p.pattern, v.__doc__ or "") - for p, v in iteritems(self._plugins.commands['respond_to'])] + # create dictionary organizing commands by plugin + modules = {} + for p, v in iteritems(self._plugins.commands['respond_to']): + key = v.__module__.title().split('.')[1] + if not key in modules: + modules[key] = [] + modules[key].append((p.pattern,v.__doc__)) + + docs_fmt = u'\t{1}' if settings.PLUGINS_ONLY_DOC_STRING else u'\t`{0}` - {1}' + + for module,commands in modules.items(): + default_reply += [u'Plugin: **{}**'.format(module)] + commands.sort(key=lambda x: x[0]) + for pattern,description in commands: + default_reply += [docs_fmt.format(pattern,description)] self._client.channel_msg( msg['data']['post']['channel_id'], '\n'.join(default_reply)) @@ -147,15 +165,9 @@ def __init__(self, client, body, pool): self._pool = pool def get_user_info(self, key, user_id=None): - if key == 'username': - sender_name = self._get_sender_name() - if sender_name: - return sender_name - user_id = user_id or self._body['data']['post']['user_id'] - if not Message.users or user_id not in Message.users: - Message.users = self._client.get_users() - return Message.users[user_id].get(key) + user_info = self._client.api.get_user_info(user_id) + return user_info[key] def get_username(self, user_id=None): return self.get_user_info('username', user_id) diff --git a/mattermost_bot/mattermost.py b/mattermost_bot/mattermost.py index a177daa..91bf9be 100644 --- a/mattermost_bot/mattermost.py +++ b/mattermost_bot/mattermost.py @@ -15,7 +15,8 @@ def __init__(self, url, ssl_verify): self.url = url self.token = "" self.initial = None - self.team_id = None + self.default_team_id = None # the first team in API returned value + self.teams_channels_ids = None # struct: {team_id:[channel_id,...], ...} self.ssl_verify = ssl_verify if not ssl_verify: requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning) @@ -39,82 +40,88 @@ def post(self, request, data=None): verify=self.ssl_verify ).text) - def login(self, name, email, password): - props = {'name': name, 'login_id': email, 'password': password} - p = requests.post( - self.url + '/users/login', data=json.dumps(props), - verify=self.ssl_verify - ) - if p.status_code == 200: - self.token = p.headers["Token"] + def login(self, team, account, password): + props = {'login_id': account, 'password': password} + response = requests.post( + self.url + '/users/login', + data = json.dumps(props), + verify=self.ssl_verify, + allow_redirects=False) + if response.status_code in [301, 302, 307]: + # reset self.url to the new URL + self.url = response.headers['Location'].replace('/users/login', '') + # re-try login if redirected + response = requests.post( + self.url + '/users/login', + data = json.dumps(props), + verify=self.ssl_verify, + allow_redirects=False) + if response.status_code == 200: + self.token = response.headers["Token"] self.load_initial_data() - return json.loads(p.text) + self.user = json.loads(response.text) + return self.user else: - p.raise_for_status() + response.raise_for_status() def load_initial_data(self): - self.initial = self.get('/users/initial_load') - self.team_id = self.initial['teams'][0]['id'] + self.teams = self.get('/users/me/teams') + self.default_team_id = self.teams[0]['id'] + self.teams_channels_ids = {} + for team in self.teams: + self.teams_channels_ids[team['id']] = [] + # get all channels belonging to each team + for channel in self.get_channels(team['id']): + self.teams_channels_ids[team['id']].append(channel['id']) def create_post(self, user_id, channel_id, message, files=None, pid=""): create_at = int(time.time() * 1000) return self.post( - '/teams/%s/channels/%s/posts/create' % (self.team_id, channel_id), - { - 'user_id': user_id, - 'channel_id': channel_id, - 'message': message, - 'create_at': create_at, - 'filenames': files or [], - 'pending_post_id': user_id + ':' + str(create_at), - 'state': "loading", - 'parent_id': pid, - 'root_id': pid, - }) + '/posts', + { + 'channel_id': channel_id, + 'message': message, + 'filenames': files or [], + 'root_id': pid, + }) def update_post(self, message_id, user_id, channel_id, message, files=None, pid=""): return self.post( - '/teams/%s/channels/%s/posts/update' % (self.team_id, channel_id), + '/posts/%s' % message_id, { - 'id': message_id, - 'channel_id': channel_id, 'message': message, }) def channel(self, channel_id): - return self.get('/teams/%s/channels/%s/' % (self.team_id, channel_id)) - - def get_channels(self): - return self.get('/teams/%s/channels/' % self.team_id) - - def get_profiles(self, pagination_size=100): - profiles = {} - start = 0 - end = start + pagination_size - - current_page = self.get('/teams/%s/users/0/%s' - % (self.team_id, pagination_size)) - profiles.update(current_page) - while len(current_page.keys()) == pagination_size: - start = end - end += pagination_size - current_page = self.get('/teams/%s/users/%s/%s' - % (self.team_id, start, end)) - profiles.update(current_page) - return profiles + channel = {'channel': self.get('/channels/%s' % channel_id)} + return channel + + def get_channels(self, team_id=None): + if team_id is None: + team_id = self.default_team_id + return self.get('/users/me/teams/%s/channels' % team_id) + + def get_team_id(self, channel_id): + for team_id, channels in self.teams_channels_ids.items(): + if channel_id in channels: + return team_id + return None + + def get_user_info(self, user_id): + return self.get('/users/{}'.format(user_id)) def me(self): return self.get('/users/me') def user(self, user_id): - return self.get_profiles()[user_id] + return self.get_user_info(user_id) def hooks_list(self): - return self.get('/teams/%s/hooks/incoming/list' % self.team_id) + return self.get('hooks/incoming', + {'team_id': self.default_team_id}) def hooks_create(self, **kwargs): - return self.post( - '/teams/%s/hooks/incoming/create' % self.team_id, kwargs) + return self.post('hooks/incoming', **kwargs) @staticmethod def in_webhook(url, channel, text, username=None, as_user=None, @@ -163,19 +170,16 @@ def login(self, team, email, password): def channel_msg(self, channel, message, pid=""): c_id = self.channels.get(channel, {}).get("id") or channel - return self.api.create_post(self.user["id"], c_id, message, pid=pid) + return self.api.create_post(self.user["id"], c_id, "{}".format(message), pid=pid) def update_msg(self, message_id, channel, message, pid=""): c_id = self.channels.get(channel, {}).get("id") or channel return self.api.update_post(message_id, self.user["id"], c_id, message, pid=pid) - def get_users(self): - return self.api.get_profiles() - def connect_websocket(self): host = self.api.url.replace('http', 'ws').replace('https', 'wss') - url = host + '/users/websocket' + url = host + '/websocket' self._connect_websocket(url, cookie_name='MMAUTHTOKEN') return self.websocket.getstatus() == 101 @@ -188,7 +192,7 @@ def _connect_websocket(self, url, cookie_name): else ssl.CERT_NONE }) - def messages(self, ignore_own_msg=False, filter_action=None): + def messages(self, ignore_own_msg=False, filter_actions=[]): if not self.connect_websocket(): return while True: @@ -201,17 +205,23 @@ def messages(self, ignore_own_msg=False, filter_action=None): if data: try: post = json.loads(data) - if filter_action and post.get('event') != filter_action: + event_action = post.get('event') + if event_action not in filter_actions: continue - if post.get('data', {}).get('post'): - dp = json.loads(post['data']['post']) - if ignore_own_msg is True and dp.get("user_id"): - if self.user["id"] == dp["user_id"]: - continue - yield post + if event_action == 'posted': + if post.get('data', {}).get('post'): + dp = json.loads(post['data']['post']) + if ignore_own_msg is True and dp.get("user_id"): + if self.user["id"] == dp["user_id"]: + continue + yield post + elif event_action in ['added_to_team', 'leave_team', + 'user_added', 'user_removed']: + self.api.load_initial_data() # reload teams & channels except ValueError: pass def ping(self): self.websocket.ping() + diff --git a/mattermost_bot/plugins/help.py b/mattermost_bot/plugins/help.py new file mode 100644 index 0000000..b9347a9 --- /dev/null +++ b/mattermost_bot/plugins/help.py @@ -0,0 +1,9 @@ +# -*- encoding: utf-8 -*- + +from mattermost_bot.bot import respond_to, listen_to + + +@respond_to('^\!help$') +@listen_to('^\!help$') +def help_request(message): + message.send(message.docs_reply()) diff --git a/mattermost_bot/plugins/info.py b/mattermost_bot/plugins/info.py new file mode 100644 index 0000000..a879ef2 --- /dev/null +++ b/mattermost_bot/plugins/info.py @@ -0,0 +1,15 @@ +# -*- encoding: utf-8 -*- + +from mattermost_bot.bot import respond_to, listen_to + + +@respond_to('^\!info$') +@listen_to('^\!info$') +def info_request(message): + message.send('TEAM-ID: `%s`' % message.get_team_id()) + message.send('USERNAME: `%s`' % message.get_username()) + message.send('EMAIL: `%s`' % message.get_user_mail()) + message.send('USER-ID: `%s`' % message.get_user_id()) + message.send('IS-DIRECT: %s' % repr(message.is_direct_message())) + message.send('MENTIONS: %s' % repr(message.get_mentions())) + message.send('MESSAGE: %s' % message.get_message()) diff --git a/mattermost_bot/plugins/sleep.py b/mattermost_bot/plugins/sleep.py new file mode 100644 index 0000000..915ef9a --- /dev/null +++ b/mattermost_bot/plugins/sleep.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +import time + +from mattermost_bot.bot import respond_to + + +@respond_to('sleep (.*)') +def sleep_reply(message, sec): + message.reply('Ok, I will be waiting %s sec' % sec) + time.sleep(int(sec)) + message.reply('done') + + +sleep_reply.__doc__ = "Sleep time" diff --git a/mattermost_bot/settings.py b/mattermost_bot/settings.py index 57eb224..ff41b53 100644 --- a/mattermost_bot/settings.py +++ b/mattermost_bot/settings.py @@ -9,13 +9,15 @@ ] PLUGINS_ONLY_DOC_STRING = False -BOT_URL = 'http://mm.example.com/api/v3' +# Default settings +MATTERMOST_API_VERSION = 4 +BOT_URL = 'http://mm.example.com/api/v4' BOT_LOGIN = 'bot@example.com' BOT_PASSWORD = None BOT_TEAM = 'devops' SSL_VERIFY = True -IGNORE_NOTIFIES = ['@channel', '@all'] +IGNORE_NOTIFIES = ['@here', '@channel', '@all'] WORKERS_NUM = 10 DEFAULT_REPLY_MODULE = None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/behavior_tests/bot_settings.py b/tests/behavior_tests/bot_settings.py new file mode 100644 index 0000000..65ebc4a --- /dev/null +++ b/tests/behavior_tests/bot_settings.py @@ -0,0 +1,11 @@ +PLUGINS = [ +] + +BOT_URL = 'http://SERVER_HOST_DN/api/v4' +BOT_LOGIN = 'testbot@mail' +BOT_NAME = 'testbot_name' +BOT_PASSWORD = 'testbot_password' +BOT_TEAM = 'default_team_name' # this team name should be the same as in driver_settings +BOT_CHANNEL = 'off-topic' # default public channel name +BOT_PRIVATE_CHANNEL = 'test' # a private channel in BOT_TEAM +SSL_VERIFY = True \ No newline at end of file diff --git a/tests/behavior_tests/driver.py b/tests/behavior_tests/driver.py new file mode 100644 index 0000000..5f4c0b4 --- /dev/null +++ b/tests/behavior_tests/driver.py @@ -0,0 +1,182 @@ +import time, re, six, threading, logging, sys, json +from six.moves import _thread +from websocket._exceptions import WebSocketConnectionClosedException, WebSocketTimeoutException +from mattermost_bot.bot import Bot, PluginsManager +from mattermost_bot.mattermost import MattermostClient +from mattermost_bot.dispatcher import MessageDispatcher +import driver_settings, bot_settings + +logger = logging.getLogger(__name__) +#logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) + +class DriverBot(Bot): + + def __init__(self): + self._client = MattermostClient( + driver_settings.BOT_URL, driver_settings.BOT_TEAM, + driver_settings.BOT_LOGIN, driver_settings.BOT_PASSWORD, + driver_settings.SSL_VERIFY + ) + self._plugins = PluginsManager() + self._plugins.init_plugins() + self._dispatcher = MessageDispatcher(self._client, self._plugins) + +class Driver(object): + + def __init__(self): + self.bot = DriverBot() + self.bot_username = driver_settings.BOT_NAME + self.bot_userid = None + self.testbot_username = bot_settings.BOT_NAME + self.testbot_userid = None + self.dm_chan = None # direct message channel + self.team_name = driver_settings.BOT_TEAM + self.cm_name = driver_settings.BOT_CHANNEL + self.cm_chan = None # common public channel + self.gm_name = driver_settings.BOT_PRIVATE_CHANNEL + self.gm_chan = None # private channel + self.events = [] + self._events_lock = threading.Lock() + + def start(self): + self._rtm_connect() + self._retrieve_bot_user_ids() + self._create_dm_channel() + self._retrieve_cm_channel() + self._retrieve_gm_channel() + + def _rtm_connect(self): + self.bot._client.connect_websocket() + self._websocket = self.bot._client.websocket + self._websocket.sock.setblocking(0) + _thread.start_new_thread(self._rtm_read_forever, tuple()) + + def _websocket_safe_read(self): + """Returns data if available, otherwise ''. Newlines indicate multiple messages """ + data = '' + while True: + # accumulated received data until no more events received + # then exception triggered, then returns the accumulated data + try: + data += '{0}\n'.format(self._websocket.recv()) + except WebSocketConnectionClosedException: + return data.rstrip() + except WebSocketTimeoutException: + return data.rstrip() + except Exception: + return data.rstrip() + + def _rtm_read_forever(self): + while True: + json_data = self._websocket_safe_read() + if json_data != '': + with self._events_lock: + self.events.extend([json.loads(d) for d in json_data.split('\n')]) + time.sleep(1) + + def _retrieve_bot_user_ids(self): + # get bot user info + self.users_info = self.bot._client.api.post('/users/usernames', \ + [driver_settings.BOT_NAME, bot_settings.BOT_NAME]) + # get user ids + for user in self.users_info: + if user['username'] == self.bot_username: + self.bot_userid = user['id'] + elif user['username'] == self.testbot_username: + self.testbot_userid = user['id'] + + def _create_dm_channel(self): + """create direct channel and get id""" + response = self.bot._client.api.post('/channels/direct', \ + [self.bot_userid, self.testbot_userid]) + self.dm_chan = response['id'] + + def _retrieve_cm_channel(self): + """create direct channel and get id""" + response = self.bot._client.api.get('/teams/name/%s/channels/name/%s' % (self.team_name, self.cm_name)) + self.cm_chan = response['id'] + + def _retrieve_gm_channel(self): + """create direct channel and get id""" + response = self.bot._client.api.get('/teams/name/%s/channels/name/%s' % (self.team_name, self.gm_name)) + self.gm_chan = response['id'] + + def _format_message(self, msg, tobot=True, colon=True, space=True): + colon = ':' if colon else '' + space = ' ' if space else '' + if tobot: + msg = u'@{}{}{}{}'.format(self.testbot_username, colon, space, msg) + return msg + + def _send_message_to_bot(self, channel, msg): + self.clear_events() + self._start_ts = time.time() + self.bot._client.channel_msg(channel, msg) + + def send_direct_message(self, msg, tobot=False, colon=True): + msg = self._format_message(msg, tobot=tobot, colon=colon) + self._send_message_to_bot(self.dm_chan, msg) + + def validate_bot_direct_message(self, match): + posts = self.bot._client.api.get('/channels/%s/posts' % self.dm_chan) + last_response = posts['posts'][posts['order'][0]] + if re.search(match, last_response['message']): + return + else: + raise AssertionError('expected to get message like "{}", but got nothing'.format(match)) + + def wait_for_bot_direct_message(self, match): + self._wait_for_bot_message(self.dm_chan, match, tosender=False) + + def _wait_for_bot_message(self, channel, match, maxwait=10, tosender=True, thread=False): + for _ in range(maxwait): + time.sleep(1) + if self._has_got_message_rtm(channel, match, tosender, thread=thread): + break + else: + raise AssertionError('expected to get message like "{}", but got nothing'.format(match)) + + def _has_got_message_rtm(self, channel, match, tosender=True, thread=False): + if tosender is True: + match = six.text_type(r'@{}: {}').format(self.bot_username, match) + with self._events_lock: + for event in self.events: + if 'event' not in event or (event['event'] == 'posted' and 'data' not in event): + print('Unusual event received: ' + repr(event)) + if event['event'] == 'posted': + post_data = json.loads(event['data']['post']) + if re.match(match, post_data['message'], re.DOTALL): + return True + return False + + def _send_channel_message(self, chan, msg, **kwargs): + msg = self._format_message(msg, **kwargs) + self._send_message_to_bot(chan, msg) + + def send_channel_message(self, msg, **kwargs): + self._send_channel_message(self.cm_chan, msg, **kwargs) + + def validate_bot_channel_message(self, match): + posts = self.bot._client.api.get('/channels/%s/posts' % self.cm_chan) + last_response = posts['posts'][posts['order'][0]] + if re.search(match, last_response['message']): + return + else: + raise AssertionError('expected to get message like "{}", but got nothing'.format(match)) + + def wait_for_bot_channel_message(self, match, tosender=True): + self._wait_for_bot_message(self.cm_chan, match, tosender=tosender) + + def send_private_channel_message(self, msg, **kwargs): + self._send_channel_message(self.gm_chan, msg, **kwargs) + + def wait_for_bot_private_channel_message(self, match, tosender=True): + self._wait_for_bot_message(self.gm_chan, match, tosender=tosender) + + @classmethod + def wait_for_bot_online(self): + time.sleep(4) + + def clear_events(self): + with self._events_lock: + self.events = [] diff --git a/tests/behavior_tests/driver_settings.py b/tests/behavior_tests/driver_settings.py new file mode 100644 index 0000000..0c7189d --- /dev/null +++ b/tests/behavior_tests/driver_settings.py @@ -0,0 +1,11 @@ +PLUGINS = [ +] + +BOT_URL = 'http://SERVER_HOST_DN/api/v4' +BOT_LOGIN = 'driverbot@mail' +BOT_NAME = 'driverbot_name' +BOT_PASSWORD = 'driverbot_password' +BOT_TEAM = 'default_team_name' # this team name should be the same as in bot_settings +BOT_CHANNEL = 'off-topic' # default public channel name +BOT_PRIVATE_CHANNEL = 'test' # a private channel in BOT_TEAM +SSL_VERIFY = True \ No newline at end of file diff --git a/tests/behavior_tests/run_bot.py b/tests/behavior_tests/run_bot.py new file mode 100644 index 0000000..4772fef --- /dev/null +++ b/tests/behavior_tests/run_bot.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +from mattermost_bot.bot import Bot, PluginsManager +from mattermost_bot.mattermost import MattermostClient +from mattermost_bot.dispatcher import MessageDispatcher +import bot_settings + +class LocalBot(Bot): + + def __init__(self): + self._client = MattermostClient( + bot_settings.BOT_URL, bot_settings.BOT_TEAM, + bot_settings.BOT_LOGIN, bot_settings.BOT_PASSWORD, + bot_settings.SSL_VERIFY + ) + self._plugins = PluginsManager() + self._plugins.init_plugins() + self._dispatcher = MessageDispatcher(self._client, self._plugins) + +def main(): + bot = LocalBot() + bot.run() + +if __name__ == '__main__': + main() diff --git a/tests/behavior_tests/test_behaviors.py b/tests/behavior_tests/test_behaviors.py new file mode 100644 index 0000000..c379494 --- /dev/null +++ b/tests/behavior_tests/test_behaviors.py @@ -0,0 +1,80 @@ +import subprocess, time +import pytest +from driver import Driver + +def _start_bot_process(): + """ + Function to run a bot for testing in subprocess + """ + args = ['python', 'tests/behavior_tests/run_bot.py',] + return subprocess.Popen(args) + +@pytest.fixture(scope='module') +def driver(): + driver = Driver() + driver.start() + p = _start_bot_process() + driver.wait_for_bot_online() + yield driver + p.terminate() + +######################################################### +# Actual test cases bellow +######################################################### + +def test_bot_respond_to_simple_message(driver): + driver.send_direct_message('hello') + driver.wait_for_bot_direct_message('hello sender!') + +def test_bot_respond_to_simple_message_with_formatting(driver): + driver.send_direct_message('hello_formatting') + driver.wait_for_bot_direct_message('_hello_ sender!') + +def test_bot_respond_to_simple_message_case_insensitive(driver): + driver.send_direct_message('hEllO') + driver.wait_for_bot_direct_message('hello sender!') + +def test_bot_direct_message_with_at_prefix(driver): + driver.send_direct_message('hello', tobot=True) + driver.wait_for_bot_direct_message('hello sender!') + driver.send_direct_message('hello', tobot=True, colon=False) + driver.wait_for_bot_direct_message('hello sender!') + +# [ToDo] Implement this test together with the file upload function +def test_bot_upload_file(driver): + pass + +# [ToDo] Needs to find a better way in validating file upload by URL +def test_bot_upload_file_from_link(driver): + #url = 'http://www.mattermost.org/wp-content/uploads/2016/03/logoHorizontal_WS.png' + #fname = basename(url) + #driver.send_direct_message('upload %s' % url) + pass + +def test_bot_reply_to_channel_message(driver): + driver.send_channel_message('hello') + driver.wait_for_bot_channel_message('hello sender!') + driver.send_channel_message('hello', colon=False) + driver.wait_for_bot_channel_message('hello sender!') + driver.send_channel_message('hello', space=False) + driver.wait_for_bot_channel_message('hello sender!') + driver.send_channel_message('hello', colon=False, space=False) + driver.wait_for_bot_channel_message('hello channel!', tosender=False) + +def test_bot_listen_to_channel_message(driver): + driver.send_channel_message('hello', tobot=False) + driver.wait_for_bot_channel_message('hello channel!', tosender=False) + +def test_bot_reply_to_message_multiple_decorators(driver): + driver.send_channel_message('hello_decorators') + driver.wait_for_bot_channel_message('hello!', tosender=False) + driver.send_channel_message('hello_decorators', tobot=False) + driver.wait_for_bot_channel_message('hello!', tosender=False) + driver.send_direct_message('hello_decorators') + driver.wait_for_bot_direct_message('hello!') + +def test_bot_reply_to_private_channel_message(driver): + driver.send_private_channel_message('hello') + driver.wait_for_bot_private_channel_message('hello sender!') + driver.send_private_channel_message('hello', colon=False) + driver.wait_for_bot_private_channel_message('hello sender!') \ No newline at end of file diff --git a/tests/unit_tests/local_plugins/__init__.py b/tests/unit_tests/local_plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/local_plugins/busy.py b/tests/unit_tests/local_plugins/busy.py new file mode 100644 index 0000000..8557d9e --- /dev/null +++ b/tests/unit_tests/local_plugins/busy.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- + +import re + +from mattermost_bot.bot import respond_to + + +@respond_to('^busy|jobs$', re.IGNORECASE) +def busy_reply(message): + busy = message.get_busy_workers() - 1 + message.reply('Num of busy workers is `%d`' % busy) + + +busy_reply.__doc__ = "Show num of busy workers" diff --git a/tests/unit_tests/local_plugins/hello.py b/tests/unit_tests/local_plugins/hello.py new file mode 100644 index 0000000..af8df77 --- /dev/null +++ b/tests/unit_tests/local_plugins/hello.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- + +import re + +from mattermost_bot.bot import listen_to +from mattermost_bot.bot import respond_to + + +@respond_to('hello$', re.IGNORECASE) +def hello_reply(message): + message.reply('hello sender!') + + +@respond_to('hello_formatting') +@listen_to('hello_formatting$') +def hello_reply_formatting(message): + # Format message with italic style + message.reply('_hello_ sender!') + + +@listen_to('hello$') +def hello_send(message): + message.send('hello channel!') + +@listen_to('hello$') +def hello_send_alternative(message): + message.send('hello channel!') + +@listen_to('hello_decorators') +@respond_to('hello_decorators') +def hello_decorators(message): + message.send('hello!') + + +@respond_to('hello_web_api', re.IGNORECASE) +def web_api_reply(message): + attachments = [{ + 'fallback': 'Fallback text', + 'author_name': 'Author', + 'author_link': 'http://www.github.com', + 'text': 'Some text here ...', + 'color': '#59afe1' + }] + message.reply_webapi( + 'Attachments example', attachments, + username='Mattermost-Bot', + icon_url='https://goo.gl/OF4DBq', + ) + + +@listen_to('hello_comment', re.IGNORECASE) +def hello_comment(message): + message.comment('some comments ...') + + +@listen_to('hello_react', re.IGNORECASE) +def hello_react(message): + message.react(':+1:') diff --git a/tests/unit_tests/single_plugin/__init__.py b/tests/unit_tests/single_plugin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit_tests/single_plugin/mock_plugin.py b/tests/unit_tests/single_plugin/mock_plugin.py new file mode 100644 index 0000000..ab7f7e9 --- /dev/null +++ b/tests/unit_tests/single_plugin/mock_plugin.py @@ -0,0 +1,9 @@ +from mattermost_bot.utils import allow_only_direct_message +from mattermost_bot.utils import allowed_users +from mattermost_bot.bot import respond_to + +@respond_to('^admin$') +@allow_only_direct_message() +@allowed_users('admin', 'root') +def mock_users_access(message): + message.reply('Access allowed!') \ No newline at end of file diff --git a/tests/unit_tests/test_pluginmanager.py b/tests/unit_tests/test_pluginmanager.py new file mode 100644 index 0000000..549cc4d --- /dev/null +++ b/tests/unit_tests/test_pluginmanager.py @@ -0,0 +1,54 @@ +import sys, logging +from importlib import reload +from mattermost_bot.bot import PluginsManager + +#logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) +logger = logging.getLogger(__name__) + +def test_load_single_plugin(): + reload(sys) + PluginsManager()._load_plugins('single_plugin') + if 'single_plugin' not in sys.modules: + raise AssertionError() + if 'single_plugin.mock_plugin' not in sys.modules: + raise AssertionError() + +def test_load_init_plugins(): + reload(sys) + PluginsManager().init_plugins() + if 'mattermost_bot.plugins' not in sys.modules: + raise AssertionError() + +def test_load_local_plugins(): + reload(sys) + PluginsManager(plugins=['local_plugins']).init_plugins() + if 'local_plugins' not in sys.modules: + raise AssertionError() + if 'local_plugins.hello' not in sys.modules: + raise AssertionError() + if 'local_plugins.busy' not in sys.modules: + raise AssertionError() + +def test_get_plugins(): + reload(sys) + manager = PluginsManager(plugins=['single_plugin', 'local_plugins']) + manager.init_plugins() + matched_func_names = set() + # test: has_matching_plugin + for func, args in manager.get_plugins('listen_to', 'hello'): + if func: + matched_func_names.add(func.__name__) + if 'hello_send' not in matched_func_names: + raise AssertionError() + if 'hello_send_alternative' not in matched_func_names: + raise AssertionError() + # test: not has_matching_plugin (there is no such plugin `hallo`) + reload(sys) + matched_func_names = set() + for func, args in manager.get_plugins('listen_to', 'hallo'): + if func: + matched_func_names.add(func.__name__) + if 'hello_send' in matched_func_names: + raise AssertionError() + if 'hello_send_alternative' in matched_func_names: + raise AssertionError()