diff --git a/tools/run-mypy b/tools/run-mypy index eaf70a8..1305ac5 100755 --- a/tools/run-mypy +++ b/tools/run-mypy @@ -93,6 +93,8 @@ force_include = [ "zulip_bots/zulip_bots/bots/trello/test_trello.py", "zulip_bots/zulip_bots/bots/susi/susi.py", "zulip_bots/zulip_bots/bots/susi/test_susi.py", + "zulip_bots/zulip_bots/bots/front/front.py", + "zulip_bots/zulip_bots/bots/front/test_front.py" ] parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.") diff --git a/zulip_bots/zulip_bots/bots/front/__init__.py b/zulip_bots/zulip_bots/bots/front/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zulip_bots/zulip_bots/bots/front/assets/usage.png b/zulip_bots/zulip_bots/bots/front/assets/usage.png new file mode 100644 index 0000000..828ebee Binary files /dev/null and b/zulip_bots/zulip_bots/bots/front/assets/usage.png differ diff --git a/zulip_bots/zulip_bots/bots/front/doc.md b/zulip_bots/zulip_bots/bots/front/doc.md new file mode 100644 index 0000000..d0c2443 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/doc.md @@ -0,0 +1,17 @@ +# Front Bot + +## Setup + +1. Go to the `Setting` of your Front app. +2. Copy the `JSON Web Token` from `Plugins & API` → `API`. +3. Replace `` in `zulip_bots/bots/front/front.conf` with `JSON +Web Token`. + +## Usage + +![](assets/usage.png) + +The name of the topic, from which you call the bot, must contain the ID of +the corresponding Front conversation. If you have received notifications +from this conversation using Front incoming webhook, you can use the topic +it has created. diff --git a/zulip_bots/zulip_bots/bots/front/fixtures/archive.json b/zulip_bots/zulip_bots/bots/front/fixtures/archive.json new file mode 100644 index 0000000..1ccfae8 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/fixtures/archive.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "PATCH", + "api_url": "https://api2.frontapp.com/conversations/cnv_kqatm2", + "headers": { + "Authorization": "Bearer TEST" + }, + "json": { + "status": "archived" + } + }, + "response": { + "error": { + "title": "Unauthenticated", + "message": "Provided token is not a JSON Web Token" + } + }, + "response-headers": { + "Status": "401 Unauthorized" + } +} diff --git a/zulip_bots/zulip_bots/bots/front/fixtures/comment.json b/zulip_bots/zulip_bots/bots/front/fixtures/comment.json new file mode 100644 index 0000000..f4caa0f --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/fixtures/comment.json @@ -0,0 +1,22 @@ +{ + "request": { + "method": "POST", + "api_url": "https://api2.frontapp.com/conversations/cnv_kqatm2/comments", + "headers": { + "Authorization": "Bearer TEST" + }, + "json": { + "author_id": "alt:email:leela@planet-express.com", + "body": "@bender, I thought you were supposed to be cooking for this party." + } + }, + "response": { + "error": { + "title": "Unauthenticated", + "message": "Provided token is not a JSON Web Token" + } + }, + "response-headers": { + "Status": "401 Unauthorized" + } +} diff --git a/zulip_bots/zulip_bots/bots/front/fixtures/delete.json b/zulip_bots/zulip_bots/bots/front/fixtures/delete.json new file mode 100644 index 0000000..89a4a77 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/fixtures/delete.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "PATCH", + "api_url": "https://api2.frontapp.com/conversations/cnv_kqatm2", + "headers": { + "Authorization": "Bearer TEST" + }, + "json": { + "status": "deleted" + } + }, + "response": { + "error": { + "title": "Unauthenticated", + "message": "Provided token is not a JSON Web Token" + } + }, + "response-headers": { + "Status": "401 Unauthorized" + } +} diff --git a/zulip_bots/zulip_bots/bots/front/fixtures/open.json b/zulip_bots/zulip_bots/bots/front/fixtures/open.json new file mode 100644 index 0000000..4415959 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/fixtures/open.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "PATCH", + "api_url": "https://api2.frontapp.com/conversations/cnv_kqatm2", + "headers": { + "Authorization": "Bearer TEST" + }, + "json": { + "status": "open" + } + }, + "response": { + "error": { + "title": "Unauthenticated", + "message": "Provided token is not a JSON Web Token" + } + }, + "response-headers": { + "Status": "401 Unauthorized" + } +} diff --git a/zulip_bots/zulip_bots/bots/front/fixtures/spam.json b/zulip_bots/zulip_bots/bots/front/fixtures/spam.json new file mode 100644 index 0000000..29e55bf --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/fixtures/spam.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "PATCH", + "api_url": "https://api2.frontapp.com/conversations/cnv_kqatm2", + "headers": { + "Authorization": "Bearer TEST" + }, + "json": { + "status": "spam" + } + }, + "response": { + "error": { + "title": "Unauthenticated", + "message": "Provided token is not a JSON Web Token" + } + }, + "response-headers": { + "Status": "401 Unauthorized" + } +} diff --git a/zulip_bots/zulip_bots/bots/front/front.conf b/zulip_bots/zulip_bots/bots/front/front.conf new file mode 100644 index 0000000..3d26247 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/front.conf @@ -0,0 +1,2 @@ +[front] +api_key = diff --git a/zulip_bots/zulip_bots/bots/front/front.py b/zulip_bots/zulip_bots/bots/front/front.py new file mode 100644 index 0000000..51e5214 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/front.py @@ -0,0 +1,123 @@ +import requests +import re +from typing import Any, Dict, Optional + +class FrontHandler(object): + FRONT_API = "https://api2.frontapp.com/conversations/{}" + COMMANDS = [ + ('archive', "Archive a conversation."), + ('delete', "Delete a conversation."), + ('spam', "Mark a conversation as spam."), + ('open', "Restore a conversation."), + ('comment ', "Leave a comment.") + ] + CNV_ID_REGEXP = 'cnv_(?P[0-9a-z]+)' + COMMENT_PREFIX = "comment " + + def usage(self) -> str: + return ''' + Front Bot uses the Front REST API to interact with Front. In order to use + Front Bot, `front.conf` must be set up. See `doc.md` for more details. + ''' + + def initialize(self, bot_handler: Any) -> None: + config = bot_handler.get_config_info('front') + api_key = config.get('api_key') + if not api_key: + raise KeyError("No API key specified.") + + self.auth = "Bearer " + api_key + + def help(self, bot_handler: Any) -> str: + response = "" + for command, description in self.COMMANDS: + response += "`{}` {}\n".format(command, description) + + return response + + def archive(self, bot_handler: Any) -> str: + response = requests.patch(self.FRONT_API.format(self.conversation_id), + headers={"Authorization": self.auth}, + json={"status": "archived"}) + + if response.status_code not in (200, 204): + return "Something went wrong." + + return "Conversation was archived." + + def delete(self, bot_handler: Any) -> str: + response = requests.patch(self.FRONT_API.format(self.conversation_id), + headers={"Authorization": self.auth}, + json={"status": "deleted"}) + + if response.status_code not in (200, 204): + return "Something went wrong." + + return "Conversation was deleted." + + def spam(self, bot_handler: Any) -> str: + response = requests.patch(self.FRONT_API.format(self.conversation_id), + headers={"Authorization": self.auth}, + json={"status": "spam"}) + + if response.status_code not in (200, 204): + return "Something went wrong." + + return "Conversation was marked as spam." + + def restore(self, bot_handler: Any) -> str: + response = requests.patch(self.FRONT_API.format(self.conversation_id), + headers={"Authorization": self.auth}, + json={"status": "open"}) + + if response.status_code not in (200, 204): + return "Something went wrong." + + return "Conversation was restored." + + def comment(self, bot_handler: Any, **kwargs: Any) -> str: + response = requests.post(self.FRONT_API.format(self.conversation_id) + "/comments", + headers={"Authorization": self.auth}, json=kwargs) + + if response.status_code not in (200, 201): + return "Something went wrong." + + return "Comment was sent." + + def handle_message(self, message: Dict[str, str], bot_handler: Any) -> None: + command = message['content'] + + result = re.search(self.CNV_ID_REGEXP, message['subject']) + if not result: + bot_handler.send_reply(message, "No coversation ID found. Please make " + "sure that the name of the topic " + "contains a valid coversation ID.") + return None + + self.conversation_id = result.group() + + if command == 'help': + bot_handler.send_reply(message, self.help(bot_handler)) + + elif command == 'archive': + bot_handler.send_reply(message, self.archive(bot_handler)) + + elif command == 'delete': + bot_handler.send_reply(message, self.delete(bot_handler)) + + elif command == 'spam': + bot_handler.send_reply(message, self.spam(bot_handler)) + + elif command == 'open': + bot_handler.send_reply(message, self.restore(bot_handler)) + + elif command.startswith(self.COMMENT_PREFIX): + kwargs = { + 'author_id': "alt:email:" + message['sender_email'], + 'body': command[len(self.COMMENT_PREFIX):] + } + bot_handler.send_reply(message, self.comment(bot_handler, **kwargs)) + else: + bot_handler.send_reply(message, "Unknown command. Use `help` for instructions.") + +handler_class = FrontHandler diff --git a/zulip_bots/zulip_bots/bots/front/test_front.py b/zulip_bots/zulip_bots/bots/front/test_front.py new file mode 100644 index 0000000..8474bf1 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/front/test_front.py @@ -0,0 +1,67 @@ +from typing import Any, Dict + +from zulip_bots.test_lib import BotTestCase + +class TestFrontBot(BotTestCase): + bot_name = 'front' + + def make_request_message(self, content: str) -> Dict[str, Any]: + message = super().make_request_message(content) + message['subject'] = "cnv_kqatm2" + message['sender_email'] = "leela@planet-express.com" + return message + + def test_bot_responds_to_empty_message(self) -> None: + with self.mock_config_info({'api_key': "TEST"}): + self.verify_reply("", "Unknown command. Use `help` for instructions.") + + def test_help(self) -> None: + with self.mock_config_info({'api_key': "TEST"}): + self.verify_reply('help', "`archive` Archive a conversation.\n" + "`delete` Delete a conversation.\n" + "`spam` Mark a conversation as spam.\n" + "`open` Restore a conversation.\n" + "`comment ` Leave a comment.\n") + + def test_archive(self) -> None: + with self.mock_config_info({'api_key': "TEST"}): + with self.mock_http_conversation('archive'): + self.verify_reply('archive', "Conversation was archived.") + + def test_delete(self) -> None: + with self.mock_config_info({'api_key': "TEST"}): + with self.mock_http_conversation('delete'): + self.verify_reply('delete', "Conversation was deleted.") + + def test_spam(self) -> None: + with self.mock_config_info({'api_key': "TEST"}): + with self.mock_http_conversation('spam'): + self.verify_reply('spam', "Conversation was marked as spam.") + + def test_restore(self) -> None: + with self.mock_config_info({'api_key': "TEST"}): + with self.mock_http_conversation('open'): + self.verify_reply('open', "Conversation was restored.") + + def test_comment(self) -> None: + body = "@bender, I thought you were supposed to be cooking for this party." + with self.mock_config_info({'api_key': "TEST"}): + with self.mock_http_conversation('comment'): + self.verify_reply("comment " + body, "Comment was sent.") + +class TestFrontBotWrongTopic(BotTestCase): + bot_name = 'front' + + def make_request_message(self, content: str) -> Dict[str, Any]: + message = super().make_request_message(content) + message['subject'] = "kqatm2" + return message + + def test_bot_responds_to_empty_message(self) -> None: + pass + + def test_no_conversation_id(self) -> None: + with self.mock_config_info({'api_key': "TEST"}): + self.verify_reply('archive', "No coversation ID found. Please make " + "sure that the name of the topic " + "contains a valid coversation ID.")