diff --git a/zulip_bots/zulip_bots/bots/beeminder/__init__.py b/zulip_bots/zulip_bots/bots/beeminder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zulip_bots/zulip_bots/bots/beeminder/beeminder.conf b/zulip_bots/zulip_bots/bots/beeminder/beeminder.conf new file mode 100644 index 0000000..22d58a1 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/beeminder.conf @@ -0,0 +1,4 @@ +[beeminder] +auth_token=9sMQV47XXidu51UEDwgB +username=rr_0410 +goalname=gainweight diff --git a/zulip_bots/zulip_bots/bots/beeminder/beeminder.py b/zulip_bots/zulip_bots/bots/beeminder/beeminder.py new file mode 100644 index 0000000..605ce76 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/beeminder.py @@ -0,0 +1,101 @@ +import requests +import logging +import json +from typing import Dict, Any, List +from requests.exceptions import HTTPError, ConnectionError + +help_message = ''' +You can add datapoints towards your beeminder goals \ +following the syntax shown below :smile:.\n \ +\n**@mention-botname daystamp, value, comment**\ +\n* `daystamp`**:** *yyyymmdd* \ +[**NOTE:** Optional field, default is *current daystamp*],\ +\n* `value`**:** Enter a value [**NOTE:** Required field, can be any number],\ +\n* `comment`**:** Add a comment [**NOTE:** Optional field, default is *None*]\ +''' + +def get_beeminder_response(message_content: str, config_info: Dict[str, str]) -> str: + username = config_info['username'] + goalname = config_info['goalname'] + auth_token = config_info['auth_token'] + + message_content = message_content.strip() + if message_content == '' or message_content == 'help': + return help_message + + url = "https://www.beeminder.com/api/v1/users/{}/goals/{}/datapoints.json".format(username, goalname) + message_pieces = message_content.split(',') + for i in range(len(message_pieces)): + message_pieces[i] = message_pieces[i].strip() + + if (len(message_pieces) == 1): + payload = { + "value": message_pieces[0], + "auth_token": auth_token + } + elif (len(message_pieces) == 2): + if (message_pieces[1].isdigit()): + payload = { + "daystamp": message_pieces[0], + "value": message_pieces[1], + "auth_token": auth_token + } + else: + payload = { + "value": message_pieces[0], + "comment": message_pieces[1], + "auth_token": auth_token + } + elif (len(message_pieces) == 3): + payload = { + "daystamp": message_pieces[0], + "value": message_pieces[1], + "comment": message_pieces[2], + "auth_token": auth_token + } + elif (len(message_pieces) > 3): + return "Make sure you follow the syntax.\n You can take a look \ +at syntax by: @mention-botname help" + + try: + r = requests.post(url, json=payload) + + if r.status_code != 200: + if r.status_code == 401: # Handles case of invalid key and missing key + return "Error. Check your key!" + else: + return "Error occured : {}".format(r.status_code) # Occures in case of unprocessable entity + else: + datapoint_link = "https://www.beeminder.com/{}/{}".format(username, goalname) + return "[Datapoint]({}) created.".format(datapoint_link) # Handles the case of successful datapoint creation + except ConnectionError as e: + logging.exception(str(e)) + return "Uh-Oh, couldn't process the request \ +right now.\nPlease try again later" + + +class BeeminderHandler(object): + ''' + This plugin allows users to easily add datapoints + towards their beeminder goals via zulip + ''' + + def initialize(self, bot_handler: Any) -> None: + self.config_info = bot_handler.get_config_info('beeminder') + # Check for valid auth_token + try: + result = get_beeminder_response('5', self.config_info) + if result == "Error. Check your key!": + bot_handler.quit('Invalid key!') + except ConnectionError: + logging.warning('Bad connection') + raise + + def usage(self) -> str: + return "This plugin allows users to add datapoints towards their Beeminder goals" + + def handle_message(self, message: Dict[str, str], bot_handler: Any) -> None: + response = get_beeminder_response(message['content'], self.config_info) + bot_handler.send_reply(message, response) + +handler_class = BeeminderHandler diff --git a/zulip_bots/zulip_bots/bots/beeminder/doc.md b/zulip_bots/zulip_bots/bots/beeminder/doc.md new file mode 100644 index 0000000..2888cd2 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/doc.md @@ -0,0 +1,63 @@ +# Beeminder bot + +The Beeminder bot can help you adding datapoints towards +your Beeminder goal from Zulip. + +To use the Beeminder bot, you can simply call it with `@beeminder` +followed by a daystamp, value and an optional comment. + +Syntax is like: +``` +@beeminder daystamp, value, comment +``` + +**NOTE** : **Commas** between inputs are a must, otherwise, +you'll get an error. + +## Setup and Configuration + +Before running Beeminder bot you will need three things as follows : + +1. **auth_token** + - Go to your [Beeminder](https://www.beeminder.com/) **account settings**. +Under **APPS & API** section you will find your **auth token**. + +2. **username** + - Your Beeminder username. + +3. **Goalname** + - The name of your Beeminder goal for which you want to +add datapoints from [Zulip](https://zulipchat.com/) + +Once you have above information, you should supply +them in `beeminder.conf` file. + +Run this bot as described in +[here](https://zulipchat.com/api/running-bots#running-a-bot). + +## Usage + +You can give command to add datapoint in 4 ways: + +1. `@beeminder daystamp, value, comment` + - Example usage: `@beeminder 20180125, 15, Adding datapoint`. + - This will add a datapoint to your Beeminder goal having +**daystamp**: `20180125`, **value**: `15` with +**comment**: `Adding datapoint`. + +2. `@beeminder daystamp, value` + - Example usage: `@beeminder 20180125, 15`. + - This will add a datapoint in your Beeminder goal having +**daystamp**: `20180125`, **value**: `15` and **comment**: `None`. + +3. `@beeminder value, comment` + - Example usage: `@beeminder 15, Adding datapoint`. + - This will add a datapoint in your Beeminder goal having +**daystamp**: `current daystamp`, **value**: `15` and **comment**: `Adding datapoint`. + +4. `@beeminder value` + - Example usage: `@beeminder 15`. + - This will add a datapoint in your Beeminder goal having +**daystamp**: `current daystamp`, **value**: `15` and **comment**: `None`. + +5. `@beeminder ` or `@beeminder help` will fetch you the `help message`. diff --git a/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_blank_input.json b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_blank_input.json new file mode 100644 index 0000000..5414299 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_blank_input.json @@ -0,0 +1,17 @@ +{ + "request": { + "api_url": "https://www.beeminder.com/api/v1/users/aaron/goals/goal/datapoints.json", + "method": "POST", + "json": { + "auth_token": "XXXXXX", + "value": "5" + } + }, + "response": { + "help_message": "This is help message." + }, + "response-headers": { + "status": 200, + "content-type": "application/json; charset=utf-8" + } +} diff --git a/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_error.json b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_error.json new file mode 100644 index 0000000..b5d3813 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_error.json @@ -0,0 +1,21 @@ +{ + "request": { + "api_url": "https://www.beeminder.com/api/v1/users/aaron/goals/goal/datapoints.json", + "method": "POST", + "json": { + "auth_token": "XXXXXX", + "value": "notNumber" + } + }, + "response": { + "errors": { + "value": [ + "is not a number" + ] + } + }, + "response-headers": { + "status": 422, + "content-type": "application/json; charset=utf-8" + } +} diff --git a/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_help_message.json b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_help_message.json new file mode 100644 index 0000000..5414299 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_help_message.json @@ -0,0 +1,17 @@ +{ + "request": { + "api_url": "https://www.beeminder.com/api/v1/users/aaron/goals/goal/datapoints.json", + "method": "POST", + "json": { + "auth_token": "XXXXXX", + "value": "5" + } + }, + "response": { + "help_message": "This is help message." + }, + "response-headers": { + "status": 200, + "content-type": "application/json; charset=utf-8" + } +} diff --git a/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_invalid.json b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_invalid.json new file mode 100644 index 0000000..2d0fd22 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_invalid.json @@ -0,0 +1,20 @@ +{ + "request": { + "api_url": "https://www.beeminder.com/api/v1/users/aaron/goals/goal/datapoints.json", + "method": "POST", + "json": { + "auth_token": "someInvalidKey", + "value": "5" + } + }, + "response": { + "errors": { + "auth_token": "bad_token", + "message": "No such auth_token found. (Did you mix up auth_token and access_token?)" + } + }, + "response-headers": { + "status": 401, + "content-type": "application/json; charset=utf-8" + } +} diff --git a/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_normal.json b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_normal.json new file mode 100644 index 0000000..eee1880 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_normal.json @@ -0,0 +1,28 @@ +{ + "request": { + "api_url": "https://www.beeminder.com/api/v1/users/aaron/goals/goal/datapoints.json", + "method": "POST", + "json": { + "auth_token": "XXXXXX", + "value": "2", + "comment": "hi there!" + } + }, + "response": { + "timestamp": 1517908892, + "value": 2, + "comment": "hi there!", + "id": "5a79739cbfec03345f011e6c", + "updated_at": 1517908892, + "requestid": null, + "canonical": "06 2 \"hi there!\"", + "fulltext": "2018-Feb-06 entered at 14:51 via api", + "origin": "api", + "daystamp": "20180206", + "status": "created" + }, + "response-headers": { + "status": 200, + "content-type": "application/json; charset=utf-8" + } +} diff --git a/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_syntax_error.json b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_syntax_error.json new file mode 100644 index 0000000..4b48528 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/fixtures/test_syntax_error.json @@ -0,0 +1,21 @@ +{ + "request": { + "api_url": "https://www.beeminder.com/api/v1/users/aaron/goals/goal/datapoints.json", + "method": "POST", + "json": { + "auth_token": "XXXXXX", + "value": "5" + } + }, + "response": { + "errors": { + "value": [ + "syntax error" + ] + } + }, + "response-headers": { + "status": 422, + "content-type": "application/json; charset=utf-8" + } +} diff --git a/zulip_bots/zulip_bots/bots/beeminder/requirements.txt b/zulip_bots/zulip_bots/bots/beeminder/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py b/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py new file mode 100644 index 0000000..4877fd6 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/beeminder/test_beeminder.py @@ -0,0 +1,70 @@ +from unittest.mock import patch, Mock +from zulip_bots.test_lib import StubBotHandler, BotTestCase, get_bot_message_handler +from requests.exceptions import ConnectionError + +class TestBeeminderBot(BotTestCase): + bot_name = "beeminder" + normal_config = { + "auth_token": "XXXXXX", + "username": "aaron", + "goalname": "goal" + } + + help_message = ''' +You can add datapoints towards your beeminder goals \ +following the syntax shown below :smile:.\n \ +\n**@mention-botname daystamp, value, comment**\ +\n* `daystamp`**:** *yyyymmdd* \ +[**NOTE:** Optional field, default is *current daystamp*],\ +\n* `value`**:** Enter a value [**NOTE:** Required field, can be any number],\ +\n* `comment`**:** Add a comment [**NOTE:** Optional field, default is *None*]\ +''' + + def test_bot_responds_to_empty_message(self) -> None: + with self.mock_config_info(self.normal_config), \ + self.mock_http_conversation('test_blank_input'): + self.verify_reply('', self.help_message) + + def test_help_message(self) -> None: + with self.mock_config_info(self.normal_config), \ + self.mock_http_conversation('test_help_message'): + self.verify_reply('help', self.help_message) + + def test_normal(self) -> None: + bot_response = '[Datapoint](https://www.beeminder.com/aaron/goal) created.' + with self.mock_config_info(self.normal_config), \ + self.mock_http_conversation('test_normal'): + self.verify_reply('2, hi there!', bot_response) + + def test_syntax_error(self) -> None: + with self.mock_config_info(self.normal_config), \ + self.mock_http_conversation('test_syntax_error'): + bot_response = "Make sure you follow the syntax.\n You can take a look \ +at syntax by: @mention-botname help" + self.verify_reply("20180303, 50, comment, redundant comment", bot_response) + + def test_connection_error(self) -> None: + with self.mock_config_info(self.normal_config), \ + patch('requests.post', side_effect=ConnectionError()), \ + patch('logging.exception'): + self.verify_reply('?$!', 'Uh-Oh, couldn\'t process the request \ +right now.\nPlease try again later') + + def test_error(self) -> None: + bot_request = 'notNumber' + bot_response = "Error occured : 422" + with self.mock_config_info(self.normal_config), \ + self.mock_http_conversation('test_error'): + self.verify_reply(bot_request, bot_response) + + def test_invalid(self) -> None: + bot = get_bot_message_handler(self.bot_name) + bot_handler = StubBotHandler() + + with self.mock_config_info({'auth_token': 'someInvalidKey', + 'username': 'aaron', + 'goalname': 'goal', + "value": "5"}), \ + self.mock_http_conversation('test_invalid'), \ + self.assertRaises(bot_handler.BotQuitException): + bot.initialize(bot_handler)