Skip to content

Commit

Permalink
Parse and dispatch bot commands
Browse files Browse the repository at this point in the history
  • Loading branch information
joshblum committed Apr 29, 2016
1 parent e11c4bd commit a4fd10b
Show file tree
Hide file tree
Showing 4 changed files with 177 additions and 12 deletions.
4 changes: 4 additions & 0 deletions orchestra/bots/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ class SlackConnectionError(Exception):

class SlackCommandInvalidRequest(Exception):
pass


class BotError(Exception):
pass
83 changes: 77 additions & 6 deletions orchestra/bots/staffbot.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import re

from django.conf import settings
from django.core.urlresolvers import reverse
from django.template import Context
from django.template.loader import render_to_string

from orchestra.bots.errors import SlackCommandInvalidRequest
from orchestra.bots.errors import BotError
from orchestra.communication.mail import send_mail
from orchestra.models import StaffingRequest
from orchestra.models import Worker
from orchestra.utils.task_lifecycle import is_worker_certified_for_task
from orchestra.utils.task_lifecycle import check_worker_allowed_new_assignment

# Types of responses we can send to slack
VALID_RESPONSE_TYPES = {'ephemeral', 'in_channel'}


class Bot(object):

# Tuple of (pattern, command_function_string) to match commands to
commands = ()
default_error_text = 'Sorry! We couldn\'t process your command'

def __init__(self, token,
allowed_team_ids=None,
allowed_domains=None,
Expand All @@ -36,6 +46,10 @@ def __init__(self, token,
'user_name': allowed_user_names,
'command': allowed_commands,
}
self.command_matchers = [
(re.compile(pattern, re.IGNORECASE), command)
for pattern, command in self.commands
]

def validate(self, data):
"""
Expand All @@ -44,8 +58,7 @@ def validate(self, data):
"""
token = data.get('token')
if token != self.token:
raise SlackCommandInvalidRequest(
'Token mismatch {} != {}'.format(token, self.token))
raise SlackCommandInvalidRequest('Invalid token.')

for fieldname, whitelist in self.whitelists.items():
if whitelist is None:
Expand All @@ -63,25 +76,83 @@ def validate(self, data):
fieldname, value))
return data

def format_slack_message(self, text,
attachments=None,
response_type='ephemeral'):
"""
Helper method to send back a response in response to the slack
user. text is plain text to be sent, attachments is a dictionary
of further data to attach to the message. See
https://api.slack.com/docs/attachments
"""
if response_type not in VALID_RESPONSE_TYPES:
raise BotError(
'Response type {} is invalid'.format(response_type))

return {
'response_type': response_type,
'text': text,
'attachments': attachments,
}

def no_command_found(self, text):
"""
If we are unable to parse the command, we return this helpful error
message.
"""
return self.format_slack_message(
'{}: {}'.format(self.default_error_text, text)
)

def _find_command(self, text):
for matcher, command_fn in self.command_matchers:
match = matcher.match(text)
if match is not None:
return getattr(self, command_fn), match.groupdict()

# If we don't match anything, at least let the user know.
return self.no_command_found, {'text': text}

def dispatch(self, data):
"""
Method to pass data for processing. Should return a dictionary of
data to return to the user.
"""
raise NotImplementedError
data = self.validate(data)
text = data.get('text')

command_fn, kwargs = self._find_command(text)
return command_fn(**kwargs)


class StaffBot(Bot):

commands = (
(r'staff (?P<task_id>[0-9]+)', 'staff'),
(r'restaff (?P<task_id>[0-9]+) (?P<username>[\w.@+-]+)', 'restaff'),
)

def __init__(self, **kwargs):
default_config = getattr(settings, 'STAFFBOT_CONFIG', {})
default_config.update(kwargs)
token = settings.SLACK_STAFFBOT_TOKEN
super().__init__(token, **kwargs)

def dispatch(self, data):
data = self.validate(data)
return {'text': data.get('text')}
def staff(self, task_id):
"""
This function handles staffing a request for the given task_id.
"""
return self.format_slack_message('Staffed task {}!'.format(task_id))

def restaff(self, task_id, username):
"""
This function handles restaffing a request for the given task_id.
The current user for the given username is removed, and a new user
is found.
"""
return self.format_slack_message(
'Restaffed task {} for {}!'.format(task_id, username)
)

def _send_task_to_workers(self, task, required_role):
# get all the workers that are certified to complete the task.
Expand Down
63 changes: 63 additions & 0 deletions orchestra/bots/tests/test_staffbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,66 @@ def test_request_validation(self):
bot = StaffBot(**config)
with self.assertRaises(SlackCommandInvalidRequest):
bot.validate(mock_slack_data)

def test_commands(self):
"""
Ensure that the bot can handle the following commands:
/staffbot staff <task_id>
/staffbot restaff <task_id> <username>
This test only validates that the commands are processed, other
tests verify the functionality of the command execution.
"""
bot = StaffBot()

# Test staff command
mock_slack_data = get_mock_slack_data()
mock_slack_data['text'] = 'staff 5'
response = bot.dispatch(mock_slack_data)
self.assertFalse(bot.default_error_text in response.get('text', ''))

# Test the restaff command
mock_slack_data['text'] = 'restaff 5 username'
response = bot.dispatch(mock_slack_data)
self.assertFalse(bot.default_error_text in response.get('text', ''))

# Test we fail gracefully
mock_slack_data['text'] = 'invalid command'
response = bot.dispatch(mock_slack_data)
self.assertTrue(bot.default_error_text in response.get('text', ''))

def test_staff_command(self):
"""
Test that the staffing logic is properly executed for the
staff command.
"""
bot = StaffBot()

# Test staff command
mock_slack_data = get_mock_slack_data()
mock_slack_data['text'] = 'staff 5'
response = bot.dispatch(mock_slack_data)
self.assertEqual(response.get('text'), 'Staffed task 5!')

mock_slack_data['text'] = 'staff'
response = bot.dispatch(mock_slack_data)
self.assertTrue(bot.default_error_text in response.get('text'))

def test_restaff_command(self):
"""
Test that the restaffing logic is properly executed for the
restaff command.
"""
bot = StaffBot()

# Test staff command
mock_slack_data = get_mock_slack_data()
mock_slack_data['text'] = 'restaff 5 username'
response = bot.dispatch(mock_slack_data)
self.assertEqual(
response.get('text'), 'Restaffed task 5 for username!'
)

mock_slack_data['text'] = 'restaff 5'
response = bot.dispatch(mock_slack_data)
self.assertTrue(bot.default_error_text in response.get('text'))
39 changes: 33 additions & 6 deletions orchestra/bots/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.test import override_settings
from django.test import Client as RequestClient

from orchestra.bots.staffbot import Bot
from orchestra.bots.tests.fixtures import get_mock_slack_data
from orchestra.tests.helpers import OrchestraTestCase
from orchestra.utils.load_json import load_encoded_json
Expand All @@ -14,20 +15,46 @@ def setUp(self):
self.request_client = RequestClient()
self.url = reverse('orchestra:staffbot')

def assert_response(self, response, error=False, default_error_text=None):
self.assertEqual(response.status_code, 200)
data = load_encoded_json(response.content)
self.assertEqual('error' in data, error)
if default_error_text is not None:
self.assertTrue(default_error_text in data.get('text', ''))

def test_get_not_allowed(self):
response = self.request_client.get(self.url)
self.assertEqual(response.status_code, 405)

def test_post_valid_data(self):
data = get_mock_slack_data()
response = self.request_client.post(self.url, data)
self.assertEqual(response.status_code, 200)
load_encoded_json(response.content)
self.assert_response(response)

@override_settings(SLACK_STAFFBOT_TOKEN='')
def test_invalid_request(self):
def test_post_invalid_data(self):
data = get_mock_slack_data()
response = self.request_client.post(self.url, data)
self.assertEqual(response.status_code, 200)
data = load_encoded_json(response.content)
self.assertTrue('error' in data)
self.assert_response(response, error=True)

def test_staff_command(self):
data = get_mock_slack_data()
data['text'] = 'staff 5'
response = self.request_client.post(self.url, data)
self.assert_response(response)

data['text'] = 'staff'
response = self.request_client.post(self.url, data)
self.assert_response(
response, default_error_text=Bot.default_error_text)

def test_restaff_command(self):
data = get_mock_slack_data()
data['text'] = 'restaff 5 username'
response = self.request_client.post(self.url, data)
self.assert_response(response)

data['text'] = 'restaff 5'
response = self.request_client.post(self.url, data)
self.assert_response(
response, default_error_text=Bot.default_error_text)

0 comments on commit a4fd10b

Please sign in to comment.