interactive bots: Create idonethis bot.

This commit is contained in:
Xavier Cooney 2018-01-05 22:09:21 +11:00 committed by Robert Hönig
parent 3030c73060
commit 1de704394a
21 changed files with 582 additions and 1 deletions

View file

@ -80,7 +80,9 @@ force_include = [
"zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py", "zulip_bots/zulip_bots/bots/baremetrics/baremetrics.py",
"zulip_bots/zulip_bots/bots/baremetrics/test_baremetrics.py", "zulip_bots/zulip_bots/bots/baremetrics/test_baremetrics.py",
"zulip_bots/zulip_bots/bots/salesforce/salesforce.py", "zulip_bots/zulip_bots/bots/salesforce/salesforce.py",
"zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py" "zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py",
"zulip_bots/zulip_bots/bots/idonethis/idonethis.py",
"zulip_bots/zulip_bots/bots/idonethis/test_idonethis.py"
] ]
parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.") parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.")

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -0,0 +1,46 @@
# idonethis bot
The idonethis bot is a Zulip bot which allows interaction with [idonethis](https://idonethis.com/)
through Zulip. It can peform actions such as viewing teams, list entries and creating entries.
To use the bot simply @-mention the bot followed by a specific command. See the usage section
below for a list of available commands.
## Setup
Before proceeding further, ensure you have an idonethis account.
1. Go to [your idonethis settings](https://beta.idonethis.com/u/settings), scroll down
and copy your API token.
2. Open up `zulip_bots/bots/idonethis/idonethis.conf` in your favorite editor, and change
`api_key` to your API token.
3. Optionally, change the `default_team` value to your default team for creating new messages.
If this is not specified, a team will be required to be manually specified every time an entry is created.
Run this bot as described [here](https://zulipchat.com/api/running-bots#running-a-bot).
## Usage
`<team>` can either be the name or ID of a team.
* `@mention help` view this help message.
![](/static/generated/bots/idonethis/assets/idonethis-help.png)
* `@mention teams list` or `@mention list teams`
List all the teams.
![](/static/generated/bots/idonethis/assets/idonethis-list-teams.png)
* `@mention team info <team>`.
Show information about one `<team>`.
![](/static/generated/bots/idonethis/assets/idonethis-team-info.png)
* `@mention entries list` or `@mention list entries`.
List entries from any team
![](/static/generated/bots/idonethis/assets/idonethis-entries-all-teams.png)
* `@mention entries list <team>` or `@mention list entries <team>`
List all entries from `<team>`.
![](/static/generated/bots/idonethis/assets/idonethis-list-entries-specific-team.png)
* `@mention entries create` or `@mention new entry` or `@mention create entry`
or `@mention new entry` or `@mention i did`
Create a new entry. Optionally supply `--team=<team>` for teams with no spaces or `"--team=<team>"`
for teams with spaces. For example `@mention i did "--team=product team" something` will create a
new entry `something` for the product team.
![](/static/generated/bots/idonethis/assets/idonethis-new-entry.png)
![](/static/generated/bots/idonethis/assets/idonethis-new-entry-specific-team.png)

View file

@ -0,0 +1,13 @@
{
"request": {
"api_url": "https://beta.idonethis.com/api/v2/noop",
"headers": {
"Authorization": "Token 12345678"
}
},
"response": {"doesnt": "matter"},
"response-headers": {
"status": 200,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,26 @@
{
"request": {
"api_url": "https://beta.idonethis.com/api/v2/teams",
"headers": {
"Authorization": "Token 12345678"
}
},
"response": [
{
"name": "testing team 1",
"created_at": "2017-12-28T19:12:24.977+11:00",
"updated_at": "2017-12-28T19:12:24.977+11:00",
"hash_id": "31415926535"
},
{
"name": "test_team_2",
"created_at": "2017-12-28T19:12:55.121+11:00",
"updated_at": "2017-12-28T19:12:55.121+11:00",
"hash_id": "8979deadbeef"
}
],
"response-headers": {
"status": 200,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,15 @@
{
"request": {
"api_url": "https://beta.idonethis.com/api/v2/teams",
"headers": {
"Authorization": "Token 87654321"
}
},
"response": {
"error": "Invalid API Authentication"
},
"response-headers": {
"status": 401,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,37 @@
{
"request": {
"api_url": "https://beta.idonethis.com/api/v2/entries",
"method": "POST",
"json": {
"body": "something and something else",
"team_id": "31415926535"
},
"headers": {
"Authorization": "Token 12345678"
}
},
"response": {
"body": "something and something else",
"created_at": "2018-01-04T20:07:58.078+11:00",
"updated_at": "2018-01-04T20:07:58.078+11:00",
"occurred_on": "2018-01-04",
"status": "done",
"hash_id": "fa974ad8c1acb9e81361a051a697f9dae22908d6",
"completed_on": null,
"archived_at": null,
"body_formatted": "something and something else",
"team": {
"name": "testing team 1",
"hash_id": "31415926535"
},
"user": {
"email_address": "xavier.cooney03@gmail.com",
"full_name": "Benji Franklin",
"hash_id": "f22a944f"
}
},
"response-headers": {
"status": 200,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,37 @@
{
"request": {
"api_url": "https://beta.idonethis.com/api/v2/entries",
"method": "POST",
"json": {
"body": "something and something else",
"team_id": "8979deadbeef"
},
"headers": {
"Authorization": "Token 12345678"
}
},
"response": {
"body": "something and something else",
"created_at": "2018-01-04T20:07:58.078+11:00",
"updated_at": "2018-01-04T20:07:58.078+11:00",
"occurred_on": "2018-01-04",
"status": "done",
"hash_id": "fa974ad8c1acb9e81361a051a697f9dae22908d6",
"completed_on": null,
"archived_at": null,
"body_formatted": "something and something else",
"team": {
"name": "test_team_2",
"hash_id": "8979deadbeef"
},
"user": {
"email_address": "xavier.cooney03@gmail.com",
"full_name": "Benji Franklin",
"hash_id": "f22a944f"
}
},
"response-headers": {
"status": 200,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,74 @@
{
"request": {
"api_url": "https://beta.idonethis.com/api/v2/entries?team_id=31415926535",
"headers": {
"Authorization": "Token 12345678"
}
},
"response": [
{
"body": "TESTING<>",
"created_at": "2018-01-04T21:10:13.084+11:00",
"updated_at": "2018-01-04T21:10:13.084+11:00",
"occurred_on": "2018-01-04",
"status": "done",
"hash_id": "65e1b21fd8f63adede1daae0bdf28c0e47b84923",
"completed_on": null,
"archived_at": null,
"body_formatted": "TESTING",
"team": {
"name": "testing team 1",
"hash_id": "31415926535"
},
"user": {
"email_address": "john.doe@generic.name",
"full_name": "John Doe",
"hash_id": "deadbeef"
}
},
{
"body": "Grabbing some more data...",
"created_at": "2018-01-04T20:07:58.078+11:00",
"updated_at": "2018-01-04T20:07:58.078+11:00",
"occurred_on": "2018-01-04",
"status": "done",
"hash_id": "fa974ad8c1acb9e81361a051a697f9dae22908d6",
"completed_on": null,
"archived_at": null,
"body_formatted": "Grabbing some more data...",
"team": {
"name": "testing team 1",
"hash_id": "31415926535"
},
"user": {
"email_address": "john.doe@generic.name",
"full_name": "John Doe",
"hash_id": "deadbeef"
}
},
{
"body": "GRABBING HTTP DATA",
"created_at": "2018-01-04T19:07:17.214+11:00",
"updated_at": "2018-01-04T19:07:17.214+11:00",
"occurred_on": "2018-01-04",
"status": "done",
"hash_id": "72c8241d2218464433268c5abd6625ac104e3d8f",
"completed_on": null,
"archived_at": null,
"body_formatted": "GRABBING HTTP DATA",
"team": {
"name": "testing team 1",
"hash_id": "31415926535"
},
"user": {
"email_address": "john.doe@generic.name",
"full_name": "John Doe",
"hash_id": "deadbeef"
}
}
],
"response-headers": {
"status": 200,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,18 @@
{
"request": {
"api_url": "https://beta.idonethis.com/api/v2/teams/31415926535",
"headers": {
"Authorization": "Token 12345678"
}
},
"response": {
"hash_id": "31415926535",
"name": "testing team 1",
"created_at": "2017-12-28T19:12:55.121+11:00",
"updated_at": "2017-12-28T19:12:55.121+11:00"
},
"response-headers": {
"status": 200,
"content-type": "application/json; charset=utf-8"
}
}

View file

@ -0,0 +1,3 @@
[idonethis]
api_key = 45ba63047f8edbd0ddb9531bfa0971dd1e575313
default_team = product team

View file

@ -0,0 +1,217 @@
import sys
import requests
import logging
import re
from typing import Any, Dict
API_BASE_URL = "https://beta.idonethis.com/api/v2"
api_key = ""
default_team = ""
class AuthenticationException(Exception):
pass
class TeamNotFoundException(Exception):
def __init__(self, team: str) -> None:
self.team = team
class UnknownCommandSyntax(Exception):
def __init__(self, detail: str) -> None:
self.detail = detail
pass
class UnspecifiedProblemException(Exception):
pass
def make_API_request(endpoint: str, method: str="GET", body: Dict[str, str]=None) -> Any:
headers = {'Authorization': 'Token ' + api_key}
if method == "GET":
r = requests.get(API_BASE_URL + endpoint, headers=headers)
elif method == "POST":
r = requests.post(API_BASE_URL + endpoint, headers=headers, json=body)
if r.status_code == 200:
return r.json()
elif r.status_code == 401 and 'error' in r.json() and r.json()['error'] == "Invalid API Authentication":
logging.error('Error authenticating, please check key ' + str(r.url))
raise AuthenticationException()
else:
logging.error('Error make API request, code ' + str(r.status_code) + '. json: ' + r.json())
raise UnspecifiedProblemException()
def api_noop() -> Any:
return make_API_request("/noop")
def api_list_team() -> Any:
return make_API_request("/teams")
def api_show_team(hash_id: str) -> Any:
return make_API_request("/teams/{}".format(hash_id))
def api_show_users(hash_id: str) -> Any:
return make_API_request("/teams/{}/members".format(hash_id))
def api_list_entries(team_id: str=None) -> Any:
if team_id:
return make_API_request("/entries?team_id={}".format(team_id))
else:
return make_API_request("/entries".format(team_id))
def api_create_entry(body: str, team_id: str) -> Any:
return make_API_request("/entries", "POST", {"body": body, "team_id": team_id})
def list_steams() -> str:
data = api_list_team()
response = "Teams:\n"
for team in data:
response += " * " + team['name'] + "\n"
return response
def get_team_hash(team_name: str) -> str:
data = api_list_team()
for team in data:
if team['name'].lower() == team_name.lower() or team['hash_id'] == team_name:
return team['hash_id']
raise TeamNotFoundException(team_name)
def team_info(team_name: str) -> str:
data = api_show_team(get_team_hash(team_name))
response = "Team Name: " + data['name'] + "\n"
response += "ID: `" + data['hash_id'] + "`\n"
response += "Created at: " + data['created_at'] + "\n"
return response
def entries_list(team_name: str) -> str:
if team_name:
data = api_list_entries(get_team_hash(team_name))
response = "Entries for " + team_name + ":\n"
else:
data = api_list_entries()
response = "Entries for all teams:\n"
for entry in data:
response += " * " + entry['body_formatted'] + "\n"
response += " * Created at: " + entry['created_at'] + "\n"
response += " * Status: " + entry['status'] + "\n"
response += " * User: " + entry['user']['full_name'] + "\n"
response += " * Team: " + entry['team']['name'] + "\n"
response += " * ID: " + entry['hash_id'] + "\n"
return response
def create_entry(message: str) -> str:
SINGLE_WORD_REGEX = re.compile("--team=([a-zA-Z0-9_]*)")
MULTIWORD_REGEX = re.compile('"--team=([^"]*)"')
team = ""
new_message = ""
single_word_match = SINGLE_WORD_REGEX.search(message)
multiword_match = MULTIWORD_REGEX.search(message)
if multiword_match is not None:
team = multiword_match.group(1)
new_message = MULTIWORD_REGEX.sub("", message).strip()
elif single_word_match is not None:
team = single_word_match.group(1)
new_message = SINGLE_WORD_REGEX.sub("", message).strip()
elif default_team:
team = default_team
new_message = message
else:
raise UnknownCommandSyntax("""I don't know which team you meant for me to create an entry under.
Either set a default team or pass the `--team` flag.
More information in my help""")
team_id = get_team_hash(team)
data = api_create_entry(new_message, team_id)
return "Great work :thumbs_up:. New entry `{}` created!".format(data['body_formatted'])
class IDoneThisHandler(object):
def initialize(self, bot_handler: Any) -> None:
global api_key, default_team
self.config_info = bot_handler.get_config_info('idonethis')
if 'api_key' in self.config_info:
api_key = self.config_info['api_key']
else:
logging.error("An API key must be specified for this bot to run.")
logging.error("Have a look at the Setup section of my documenation for more information.")
bot_handler.quit()
if 'default_team' in self.config_info:
default_team = self.config_info['default_team']
else:
logging.error("Cannot find default team. Users will need to manually specify a team each time an entry is created.")
try:
api_noop()
except AuthenticationException:
logging.error("Authentication exception with idonethis. Can you check that your API keys are correct? ")
bot_handler.quit()
except UnspecifiedProblemException:
logging.error("Problem connecting to idonethis. Please check connection")
bot_handler.quit()
def usage(self) -> str:
default_team_message = ""
if default_team:
default_team_message = "The default team is currently set as `" + default_team + "`."
else:
default_team_message = "There is currently no default team set up :frowning:."
return '''
This bot allows for interaction with idonethis, a collaboration tool to increase a team's productivity.
Below are some of the commands you can use, and what they do.
`<team>` can either be the name or ID of a team.
* `@mention help` view this help message
* `@mention list teams`
List all the teams
* `@mention team info <team>`
Show information about one `<team>`
* `@mention list entries`
List entries from any team
* `@mention list entries <team>`
List all entries from `<team>`
* `@mention new entry` or `@mention i did`
Create a new entry. Optionally supply `--team=<team>` for teams with no spaces or `"--team=<team>"`
for teams with spaces. For example `@mention i did "--team=product team" something` will create a
new entry `something` for the product team.
''' + default_team_message
def handle_message(self, message: Any, bot_handler: Any) -> None:
bot_handler.send_reply(message, self.get_response(message))
def get_response(self, message: Any) -> str:
message_content = message['content'].strip().split()
if message_content == "":
return ""
reply = ""
try:
command = " ".join(message_content[:2])
if command in ["teams list", "list teams"]:
reply = list_steams()
elif command in ["teams info", "team info"]:
if len(message_content) > 2:
reply = team_info(" ".join(message_content[2:]))
else:
raise UnknownCommandSyntax("You must specify the team in which you request information from.")
elif command in ["entries list", "list entries"]:
reply = entries_list(" ".join(message_content[2:]))
elif command in ["entries create", "create entry", "new entry", "i did"]:
reply = create_entry(" ".join(message_content[2:]))
elif command in ["help"]:
reply = self.usage()
else:
raise UnknownCommandSyntax("I can't understand the command you sent me :confused: ")
except TeamNotFoundException as e:
reply = "Sorry, it doesn't seem as if I can find a team named `" + e.team + "` :frowning:."
except AuthenticationException:
reply = "I can't currently authenticate with idonethis. "
reply += "Can you check that your API key is correct? For more information see my documentation."
except UnknownCommandSyntax as e:
reply = "Sorry, I don't understand what your trying to say. Use `@mention help` to see my help. " + e.detail
except Exception as e: # catches UnspecifiedProblemException, and other problems
reply = "Oh dear, I'm having problems processing your request right now. Perhaps you could try again later :grinning:"
logging.error("Exception caught: " + str(e))
return reply
handler_class = IDoneThisHandler

View file

@ -0,0 +1,93 @@
from unittest.mock import patch
from zulip_bots.test_lib import BotTestCase
import requests
class TestIDoneThisBot(BotTestCase):
bot_name = "idonethis" # type: str
def test_create_entry_default_team(self) -> None:
with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \
self.mock_http_conversation('test_create_entry'), \
self.mock_http_conversation('team_list'):
self.verify_reply('i did something and something else',
'Great work :thumbs_up:. New entry `something and something else` created!')
def test_create_entry_quoted_team(self) -> None:
with self.mock_config_info({'api_key': '12345678', 'default_team': 'test_team_2'}), \
self.mock_http_conversation('test_create_entry'), \
self.mock_http_conversation('team_list'):
self.verify_reply('i did something and something else "--team=testing team 1"',
'Great work :thumbs_up:. New entry `something and something else` created!')
def test_create_entry_single_word_team(self) -> None:
with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \
self.mock_http_conversation('test_create_entry_team_2'), \
self.mock_http_conversation('team_list'):
self.verify_reply('i did something and something else --team=test_team_2',
'Great work :thumbs_up:. New entry `something and something else` created!')
def test_bad_key(self) -> None:
with self.mock_config_info({'api_key': '87654321', 'default_team': 'testing team 1'}), \
self.mock_http_conversation('test_401'), \
patch('zulip_bots.bots.idonethis.idonethis.api_noop'), \
patch('logging.error'):
self.verify_reply('list teams',
'I can\'t currently authenticate with idonethis. Can you check that your API key is correct? '
'For more information see my documentation.')
def test_list_team(self) -> None:
with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \
self.mock_http_conversation('team_list'):
self.verify_reply('list teams',
'Teams:\n * testing team 1\n * test_team_2\n')
def test_show_team_no_team(self) -> None:
with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \
self.mock_http_conversation('api_noop'):
self.verify_reply('team info',
'Sorry, I don\'t understand what your trying to say. Use `@mention help` to see my help. '
'You must specify the team in which you request information from.')
def test_show_team(self) -> None:
with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \
self.mock_http_conversation('test_show_team'), \
patch('zulip_bots.bots.idonethis.idonethis.get_team_hash', return_value='31415926535') as get_team_hashFunction:
self.verify_reply('team info testing team 1',
'Team Name: testing team 1\n'
'ID: `31415926535`\n'
'Created at: 2017-12-28T19:12:55.121+11:00\n')
get_team_hashFunction.assert_called_with('testing team 1')
def test_entries_list(self) -> None:
with self.mock_config_info({'api_key': '12345678', 'default_team': 'testing team 1'}), \
self.mock_http_conversation('test_entries_list'), \
patch('zulip_bots.bots.idonethis.idonethis.get_team_hash', return_value='31415926535') as get_team_hashFunction:
self.verify_reply('entries list testing team 1',
'Entries for testing team 1:\n'
' * TESTING\n'
' * Created at: 2018-01-04T21:10:13.084+11:00\n'
' * Status: done\n'
' * User: John Doe\n'
' * Team: testing team 1\n'
' * ID: 65e1b21fd8f63adede1daae0bdf28c0e47b84923\n'
' * Grabbing some more data...\n'
' * Created at: 2018-01-04T20:07:58.078+11:00\n'
' * Status: done\n'
' * User: John Doe\n'
' * Team: testing team 1\n'
' * ID: fa974ad8c1acb9e81361a051a697f9dae22908d6\n'
' * GRABBING HTTP DATA\n'
' * Created at: 2018-01-04T19:07:17.214+11:00\n'
' * Status: done\n'
' * User: John Doe\n'
' * Team: testing team 1\n'
' * ID: 72c8241d2218464433268c5abd6625ac104e3d8f\n')
def test_bot_responds_to_empty_message(self) -> None:
with self.mock_config_info({'api_key': '12345678', 'bot_info': 'team'}), \
self.mock_http_conversation('api_noop'):
self.verify_reply('',
'Sorry, I don\'t understand what your trying to say. Use `@mention help` to see my help. '
'I can\'t understand the command you sent me :confused: ')