diff --git a/tools/run-mypy b/tools/run-mypy index 958354c..ebe9003 100755 --- a/tools/run-mypy +++ b/tools/run-mypy @@ -78,7 +78,9 @@ force_include = [ "zulip_bots/zulip_bots/bots/mention/mention.py", "zulip_bots/zulip_bots/bots/mention/test_mention.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/test_salesforce.py" ] parser = argparse.ArgumentParser(description="Run mypy on files tracked by git.") diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py index 7279bc3..2beccde 100755 --- a/zulip_bots/setup.py +++ b/zulip_bots/setup.py @@ -55,7 +55,8 @@ setuptools_info = dict( 'requests', # for bots/link_shortener and bots/jira 'python-chess[engine,gaviota]', # for bots/chess 'wit', # for bots/witai - 'apiai' # for bots/dialogflow + 'apiai', # for bots/dialogflow + 'simple_salesforce' # for bots/salesforce ], ) diff --git a/zulip_bots/zulip_bots/bots/salesforce/__init__.py b/zulip_bots/zulip_bots/bots/salesforce/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/zulip_bots/zulip_bots/bots/salesforce/assets/link_details_example.png b/zulip_bots/zulip_bots/bots/salesforce/assets/link_details_example.png new file mode 100644 index 0000000..ad1f262 Binary files /dev/null and b/zulip_bots/zulip_bots/bots/salesforce/assets/link_details_example.png differ diff --git a/zulip_bots/zulip_bots/bots/salesforce/assets/query_example.png b/zulip_bots/zulip_bots/bots/salesforce/assets/query_example.png new file mode 100644 index 0000000..1fff98c Binary files /dev/null and b/zulip_bots/zulip_bots/bots/salesforce/assets/query_example.png differ diff --git a/zulip_bots/zulip_bots/bots/salesforce/assets/top_opportunities_example.png b/zulip_bots/zulip_bots/bots/salesforce/assets/top_opportunities_example.png new file mode 100644 index 0000000..9f8926c Binary files /dev/null and b/zulip_bots/zulip_bots/bots/salesforce/assets/top_opportunities_example.png differ diff --git a/zulip_bots/zulip_bots/bots/salesforce/doc.md b/zulip_bots/zulip_bots/bots/salesforce/doc.md new file mode 100644 index 0000000..a71d7c3 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/doc.md @@ -0,0 +1,80 @@ +# Salesforce bot + +The Salesforce bot can get records from your Salesforce database. +It can also show details about any Salesforce links that you post. + +## Setup + +1. Create a user in Salesforce that the bot can use to access Salesforce. +Make sure it has the appropriate permissions to access records. +2. In `salesforce.conf` paste the Salesforce `username`, `password` and +`security_token`. +3. Run the bot as explained [here](https://zulipchat.com/api/running-bots#running-a-bot) + +## Examples + +### Standard query +![Standard query](assets/query_example.png) + +### Custom query +![Custom query](assets/top_opportunities_example.png) + +### Link details +![Link details](assets/link_details_example.png) + +## Optional Configuration (Advanced) + +The bot has been designed to be able to configure custom commands and objects. + +If you wanted to find a custom object type, or an object type not included with the bot, +like `Event`, you can add these by adding to the Commands and Object Types in `utils.py`. + +A Command is a phrase that the User asks the bot. For example `find contact bob`. To make a Command, +the corresponding object type must be made. + +Object types are Salesforce objects, like `Event`, and are used to tell the bot which fields of the object the bot +should ask for and display. + +To show details about a link posted, only the Object Type for the object needs to be present. + +Please read the +[SOQL reference](https://goo.gl/6VwBV3) +to make custom queries, and the [simple_salesforce documentation](https://pypi.python.org/pypi/simple-salesforce) +to make custom callbacks. + +### Commands + +For example: "find contact tim" + +In `utils.py`, the commands are stored in the list `commands`. + +Parameter | Required? | Type | Description | Default +--------- | --------- | ---- | ----------- | ------- +commands | [x] | list[str] | What the user should start their command with | `None` +object | [x] | str | The Salesforce object type in `object_types` | `None` +query | [ ] | str | The SOQL query to access this object* | `'SELECT {} FROM {} WHERE Name LIKE %\'{}\'% LIMIT {}'` +description | [x] | str | What does the command do? | `None` +template | [x] | str | Example of the command | `None` +rank_output | [ ] | boolean | Should the output be ranked? (1., 2., 3. etc.) | `False` +force_keys | [ ] | list[str] | Values which should always be shown in the output | `[]` +callback | [ ] | callable** | Custom handling behaviour | `None` + +**Note**: *`query` must have `LIMIT {}` at the end, and the 4 parameters are `fields`, `table` (from `object_types`), +`args` (the search term), `limit` (the maximum number of terms) + +**`callback` must be a function which accepts `args: str`(arguments passed in by the user, including search term), +`sf: simple_salesforce.api.Salesforce` (the Salesforce handler object, `self.sf`), `command: Dict[str, Any]` +(the command used from `commands`) + +### Object Types +In `utils.py` the object types are stored in the dictionary `object_types`. + +The name of each object type corresponds to the `object` referenced in `commands`. + +Parameter | Required? | Type | Description +--------- | --------- | ---- | ----------- +fields* | [x] | str | The Salesforce fields to fetch from the database. +name | [x] | str | The API name of the object**. + +**Note**: * This must contain Name and Id, however Id is not displayed. +** Found in the salesforce object manager. diff --git a/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_multiple_results.json b/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_multiple_results.json new file mode 100644 index 0000000..b364418 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_multiple_results.json @@ -0,0 +1,26 @@ +{ + "response": { + "totalSize": 2, + "done": true, + "records": [ + { + "attributes": { + "type": "Contact", + "url": "" + }, + "Id": "foo_id", + "Name": "foo", + "Phone": "020 1234 5678" + }, + { + "attributes": { + "type": "Contact", + "url": "" + }, + "Id": "bar_id", + "Name": "bar", + "Phone": "020 5678 1234" + } + ] + } +} diff --git a/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_no_results.json b/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_no_results.json new file mode 100644 index 0000000..d7b0714 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_no_results.json @@ -0,0 +1,8 @@ +{ + "response": { + "totalSize": 0, + "done": true, + "records": [ + ] + } +} diff --git a/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_one_result.json b/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_one_result.json new file mode 100644 index 0000000..f6453fc --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_one_result.json @@ -0,0 +1,17 @@ +{ + "response": { + "totalSize": 1, + "done": true, + "records": [ + { + "attributes": { + "type": "Contact", + "url": "" + }, + "Id": "foo_id", + "Name": "foo", + "Phone": "020 1234 5678" + } + ] + } +} diff --git a/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_top_opportunities.json b/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_top_opportunities.json new file mode 100644 index 0000000..d5fe7be --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/fixtures/test_top_opportunities.json @@ -0,0 +1,26 @@ +{ + "response": { + "totalSize": 2, + "done": true, + "records": [ + { + "attributes": { + "type": "Opportunity", + "url": "" + }, + "Id": "foo_id", + "Name": "foo", + "Amount": 2 + }, + { + "attributes": { + "type": "Opportunity", + "url": "" + }, + "Id": "bar_id", + "Name": "bar", + "Amount": 1 + } + ] + } +} diff --git a/zulip_bots/zulip_bots/bots/salesforce/requirements.txt b/zulip_bots/zulip_bots/bots/salesforce/requirements.txt new file mode 100644 index 0000000..91cdb7f --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/requirements.txt @@ -0,0 +1 @@ +simple_salesforce diff --git a/zulip_bots/zulip_bots/bots/salesforce/salesforce.conf b/zulip_bots/zulip_bots/bots/salesforce/salesforce.conf new file mode 100644 index 0000000..ca7b606 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/salesforce.conf @@ -0,0 +1,4 @@ +[salesforce] +username=foo +security_token=bar +password=baz diff --git a/zulip_bots/zulip_bots/bots/salesforce/salesforce.py b/zulip_bots/zulip_bots/bots/salesforce/salesforce.py new file mode 100644 index 0000000..da42f65 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/salesforce.py @@ -0,0 +1,178 @@ +# See readme.md for instructions on running this code. + +from typing import Any +import simple_salesforce +from typing import Dict, Any, List +import getpass +import re +import logging +import json +from zulip_bots.bots.salesforce.utils import * + +base_help_text = '''Salesforce bot +This bot can do simple salesforce query requests +**All commands must be @-mentioned to the bot.** +Commands: +{} +Arguments: +**-limit **: the maximum number of entries sent (default: 5) +**-show**: show all the properties of each entry (default: false) + +This bot can also show details about any Salesforce links sent to it. + +Supported Object types: +These are the types of Salesforce object supported by this bot. +The bot cannot show the details of any other object types. +{}''' + +login_url = 'https://login.salesforce.com/' + + +def get_help_text() -> str: + command_text = '' + for command in commands: + if 'template' in command.keys() and 'description' in command.keys(): + command_text += '**{}**: {}\n'.format('{} [arguments]'.format( + command['template']), command['description']) + object_type_text = '' + for object_type in object_types.values(): + object_type_text += '{}\n'.format(object_type['table']) + return base_help_text.format(command_text, object_type_text) + + +def format_result( + result: Dict[str, Any], + exclude_keys: List[str]=[], + force_keys: List[str]=[], + rank_output: bool=False, + show_all_keys: bool=False +) -> str: + exclude_keys += ['Name', 'attributes', 'Id'] + output = '' + if result['totalSize'] == 0: + return 'No records found.' + if result['totalSize'] == 1: + record = result['records'][0] + output += '**[{}]({}{})**\n'.format(record['Name'], + login_url, record['Id']) + for key, value in record.items(): + if key not in exclude_keys: + output += '>**{}**: {}\n'.format(key, value) + else: + for i, record in enumerate(result['records']): + if rank_output: + output += '{}) '.format(i + 1) + output += '**[{}]({}{})**\n'.format(record['Name'], + login_url, record['Id']) + added_keys = False + for key, value in record.items(): + if key in force_keys or (show_all_keys and key not in exclude_keys): + added_keys = True + output += '>**{}**: {}\n'.format(key, value) + if added_keys: + output += '\n' + return output + + +def query_salesforce(arg: str, sf: Any, command: Dict[str, Any]) -> str: + arg = arg.strip() + qarg = arg.split(' -', 1)[0] + split_args = [] # type: List[str] + raw_arg = '' + if len(arg.split(' -', 1)) > 1: + raw_arg = ' -' + arg.split(' -', 1)[1] + split_args = raw_arg.split(' -') + limit_num = 5 + re_limit = re.compile('-limit \d+') + limit = re_limit.search(raw_arg) + if limit: + limit_num = int(limit.group().rsplit(' ', 1)[1]) + logging.info('Searching with limit {}'.format(limit_num)) + query = default_query + if 'query' in command.keys(): + query = command['query'] + object_type = object_types[command['object']] + res = sf.query(query.format( + object_type['fields'], object_type['table'], qarg, limit_num)) + exclude_keys = [] # type: List[str] + if 'exclude_keys' in command.keys(): + exclude_keys = command['exclude_keys'] + force_keys = [] # type: List[str] + if 'force_keys' in command.keys(): + force_keys = command['force_keys'] + rank_output = False + if 'rank_output' in command.keys(): + rank_output = command['rank_output'] + show_all_keys = 'show' in split_args + if 'show_all_keys' in command.keys(): + show_all_keys = command['show_all_keys'] or 'show' in split_args + return format_result(res, exclude_keys=exclude_keys, force_keys=force_keys, rank_output=rank_output, show_all_keys=show_all_keys) + + +def get_salesforce_link_details(link: str, sf: Any) -> str: + re_id = re.compile('/[A-Za-z0-9]{18}') + re_id_res = re_id.search(link) + if re_id_res is None: + return 'Invalid salesforce link' + id = re_id_res.group().strip('/') + for object_type in object_types.values(): + res = sf.query(link_query.format( + object_type['fields'], object_type['table'], id)) + if res['totalSize'] == 1: + return format_result(res) + return 'No object found. Make sure it is of the supported types. Type `help` for more info.' + + +class SalesforceHandler(object): + def usage(self) -> str: + return ''' + This is a Salesforce bot, which can search for Contacts, + Accounts and Opportunities. And can be configured for any + other object types. + + It will also show details of any Salesforce links posted. + + @-mention the bot with 'help' to see available commands. + ''' + + def get_salesforce_response(self, content: str) -> str: + content = content.strip() + if content is None or content == 'help': + return get_help_text() + if content.startswith('http') and 'force' in content: + return get_salesforce_link_details(content, self.sf) + for command in commands: + for command_keyword in command['commands']: + if content.startswith(command_keyword): + args = content.replace(command_keyword, '').strip() + if args is not None and args != '': + if 'callback' in command.keys(): + return command['callback'](args, self.sf, command) + else: + return query_salesforce(args, self.sf, command) + else: + return 'Usage: {} [arguments]'.format(command['template']) + return get_help_text() + + def initialize(self, bot_handler: Any) -> None: + self.config_info = bot_handler.get_config_info('salesforce') + try: + self.sf = simple_salesforce.Salesforce( + username=self.config_info['username'], + password=self.config_info['password'], + security_token=self.config_info['security_token'] + ) + except simple_salesforce.exceptions.SalesforceAuthenticationFailed as err: + logging.error( + 'Failed to log in to Salesforce. {} {}'.format(err.code, err.message)) + quit() + + def handle_message(self, message: Any, bot_handler: Any) -> None: + try: + bot_response = self.get_salesforce_response(message['content']) + bot_handler.send_reply(message, bot_response) + except Exception as e: + bot_handler.send_reply('Error. {}.'.format(e), bot_response) + + +handler_class = SalesforceHandler diff --git a/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py b/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py new file mode 100644 index 0000000..7f8ac14 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/test_salesforce.py @@ -0,0 +1,201 @@ +from zulip_bots.test_lib import BotTestCase, read_bot_fixture_data +import simple_salesforce +from simple_salesforce.exceptions import SalesforceAuthenticationFailed +from contextlib import contextmanager +from unittest.mock import patch +from typing import Any, Dict +import logging + + +@contextmanager +def mock_salesforce_query(test_name: str, bot_name: str) -> Any: + response_data = read_bot_fixture_data(bot_name, test_name) + sf_response = response_data.get('response') + + with patch('simple_salesforce.api.Salesforce.query') as mock_query: + mock_query.return_value = sf_response + yield + + +@contextmanager +def mock_salesforce_auth(is_success: bool) -> Any: + if is_success: + with patch('simple_salesforce.api.Salesforce.__init__') as mock_sf_init: + mock_sf_init.return_value = None + yield + else: + with patch( + 'simple_salesforce.api.Salesforce.__init__', + side_effect=SalesforceAuthenticationFailed(403, 'auth failed') + ) as mock_sf_init: + mock_sf_init.return_value = None + yield + + +@contextmanager +def mock_salesforce_commands_types() -> Any: + with patch('zulip_bots.bots.salesforce.utils.commands', mock_commands), \ + patch('zulip_bots.bots.salesforce.utils.object_types', mock_object_types): + yield + + +mock_config = { + 'username': 'name@example.com', + 'password': 'foo', + 'security_token': 'abcdefg' +} + +help_text = '''Salesforce bot +This bot can do simple salesforce query requests +**All commands must be @-mentioned to the bot.** +Commands: +**find contact [arguments]**: finds contacts +**find top opportunities [arguments]**: finds opportunities + +Arguments: +**-limit **: the maximum number of entries sent (default: 5) +**-show**: show all the properties of each entry (default: false) + +This bot can also show details about any Salesforce links sent to it. + +Supported Object types: +These are the types of Salesforce object supported by this bot. +The bot cannot show the details of any other object types. +Table +Table +''' + + +def echo(arg: str, sf: Any, command: Dict[str, Any]) -> str: + return arg + + +mock_commands = [ + { + 'commands': ['find contact'], + 'object': 'contact', + 'description': 'finds contacts', + 'template': 'find contact ', + }, + { + 'commands': ['find top opportunities'], + 'object': 'opportunity', + 'query': 'SELECT {} FROM {} WHERE isClosed=false ORDER BY amount DESC LIMIT {}', + 'description': 'finds opportunities', + 'template': 'find top opportunities ', + 'rank_output': True, + 'force_keys': ['Amount'], + 'exclude_keys': ['Status'], + 'show_all_keys': True + }, + { + 'commands': ['echo'], + 'callback': echo + } +] + + +mock_object_types = { + 'contact': { + 'fields': 'Id, Name, Phone', + 'table': 'Table' + }, + 'opportunity': { + 'fields': 'Id, Name, Amount, Status', + 'table': 'Table' + } +} + + +class TestSalesforceBot(BotTestCase): + bot_name = "salesforce" # type: str + + def _test(self, test_name: str, message: str, response: str, auth_success: bool=True) -> None: + with self.mock_config_info(mock_config), \ + mock_salesforce_auth(auth_success), \ + mock_salesforce_query(test_name, 'salesforce'), \ + mock_salesforce_commands_types(): + self.verify_reply(message, response) + + def _test_initialize(self, auth_success: bool=True) -> None: + with self.mock_config_info(mock_config), \ + mock_salesforce_auth(auth_success), \ + mock_salesforce_commands_types(): + bot, bot_handler = self._get_handlers() + + def test_bot_responds_to_empty_message(self) -> None: + self._test('test_one_result', '', help_text) + + def test_one_result(self) -> None: + res = '''**[foo](https://login.salesforce.com/foo_id)** +>**Phone**: 020 1234 5678 +''' + self._test('test_one_result', 'find contact foo', res) + + def test_multiple_results(self) -> None: + res = '**[foo](https://login.salesforce.com/foo_id)**\n**[bar](https://login.salesforce.com/bar_id)**\n' + self._test('test_multiple_results', 'find contact foo', res) + + def test_arg_show(self) -> None: + res = '''**[foo](https://login.salesforce.com/foo_id)** +>**Phone**: 020 1234 5678 + +**[bar](https://login.salesforce.com/bar_id)** +>**Phone**: 020 5678 1234 + +''' + self._test('test_multiple_results', 'find contact foo -show', res) + + def test_no_results(self) -> None: + self._test('test_no_results', 'find contact foo', 'No records found.') + + def test_rank_and_force_keys(self) -> None: + res = '''1) **[foo](https://login.salesforce.com/foo_id)** +>**Amount**: 2 + +2) **[bar](https://login.salesforce.com/bar_id)** +>**Amount**: 1 + +''' + self._test('test_top_opportunities', 'find top opportunities 2', res) + + def test_limit_arg(self) -> None: + res = '''**[foo](https://login.salesforce.com/foo_id)** +>**Phone**: 020 1234 5678 +''' + with self.assertLogs(level='INFO') as log: + self._test('test_one_result', 'find contact foo -limit 1', res) + self.assertIn('INFO:root:Searching with limit 1', log.output) + + def test_help(self) -> None: + self._test('test_one_result', 'help', help_text) + self._test('test_one_result', 'foo bar baz', help_text) + self._test('test_one_result', 'find contact', + 'Usage: find contact [arguments]') + + def test_bad_auth(self) -> None: + with self.assertLogs(level='ERROR') as log, \ + patch('builtins.quit'): + self._test_initialize(auth_success=False) + self.assertIn( + 'ERROR:root:Failed to log in to Salesforce. 403 auth failed', log.output) + + def test_callback(self) -> None: + self._test('test_one_result', 'echo hello', 'hello') + + def test_link_normal(self) -> None: + res = '''**[foo](https://login.salesforce.com/foo_id)** +>**Phone**: 020 1234 5678 +''' + self._test('test_one_result', + 'https://login.salesforce.com/1c3e5g7i9k1m3o5q7s', res) + + def test_link_invalid(self) -> None: + self._test('test_one_result', + 'https://login.salesforce.com/foo/bar/1c3e5g7$i9k1m3o5q7', + 'Invalid salesforce link') + + def test_link_no_results(self) -> None: + res = 'No object found. Make sure it is of the supported types. Type `help` for more info.' + self._test('test_no_results', + 'https://login.salesforce.com/1c3e5g7i9k1m3o5q7s', res) diff --git a/zulip_bots/zulip_bots/bots/salesforce/utils.py b/zulip_bots/zulip_bots/bots/salesforce/utils.py new file mode 100644 index 0000000..89bf4a1 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/salesforce/utils.py @@ -0,0 +1,47 @@ +link_query = 'SELECT {} FROM {} WHERE Id=\'{}\'' +default_query = 'SELECT {} FROM {} WHERE Name LIKE \'%{}%\' LIMIT {}' + +commands = [ + { + 'commands': ['search account', 'find account', 'search accounts', 'find accounts'], + 'object': 'account', + 'description': 'Returns a list of accounts of the name specified', + 'template': 'search account ' + }, + { + 'commands': ['search contact', 'find contact', 'search contacts', 'find contacts'], + 'object': 'contact', + 'description': 'Returns a list of contacts of the name specified', + 'template': 'search contact ' + }, + { + 'commands': ['search opportunity', 'find opportunity', 'search opportunities', 'find opportunities'], + 'object': 'opportunity', + 'description': 'Returns a list of opportunities of the name specified', + 'template': 'search opportunity ' + }, + { + 'commands': ['search top opportunity', 'find top opportunity', 'search top opportunities', 'find top opportunities'], + 'object': 'opportunity', + 'query': 'SELECT {} FROM {} WHERE isClosed=false ORDER BY amount DESC LIMIT {}', + 'description': 'Returns a list of opportunities organised by amount', + 'template': 'search top opportunities ', + 'rank_output': True, + 'force_keys': ['Amount'] + } +] # type: List[Dict[str, Any]] + +object_types = { + 'account': { + 'fields': 'Id, Name, Phone, BillingStreet, BillingCity, BillingState', + 'table': 'Account' + }, + 'contact': { + 'fields': 'Id, Name, Phone, MobilePhone, Email', + 'table': 'Contact' + }, + 'opportunity': { + 'fields': 'Id, Name, Amount, Probability, StageName, CloseDate', + 'table': 'Opportunity' + } +} # type: Dict[str, Dict[str, str]]