diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 087d0de..194f33b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ repos: - repo: https://github.com/ambv/black - rev: stable + rev: 21.9b0 hooks: - id: black diff --git a/README.md b/README.md index 9784df2..156a37d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ Here is an example of a conversation you can have with this bot: - [Rasa Helpdesk Assistant Example](#rasa-helpdesk-assistant-example) - [Setup](#setup) - [Install the dependencies](#install-the-dependencies) + - [Configure endpoints](#configure-endpoints) - [Optional: Connect to a ServiceNow instance](#optional-connect-to-a-servicenow-instance) + - [Optional: Connect to a Jira Instance](#optional-connect-to-a-jira-instance) - [Running the bot](#running-the-bot) - [Things you can ask the bot](#things-you-can-ask-the-bot) - [Example conversations](#example-conversations) @@ -49,13 +51,15 @@ pre-commit install > With pre-commit installed, the `black` and `doctoc` hooks will run on every `git commit`. > If any changes are made by the hooks, you will need to re-add changed files and re-commit your changes. -### Optional: Connect to a ServiceNow instance +### Configure endpoints -You can run this bot without connecting to a ServiceNow instance, in which case it will +You can run this bot without connecting to a ServiceNow or Jira instance, in which case it will send responses without creating an incident or checking the actual status of one. -To run the bot without connecting ServiceNow, -you don't need to change anything in `actions/snow_credentials.yml`; `localmode` should already be set -to `true` +To run the bot without connecting to ServiceNow or Jira you don't need to change anything +in `endpoints.yml`; `helpdesk: mode:` should already be set to `local`. + +When set to `local` (default in the code), it will just take all the data in and message out the information that would normally be sent. +### Optional: Connect to a ServiceNow instance If you do want to connect to ServiceNow, you can get your own free Developer instance to test this with [here](https://developer.servicenow.com/app.do#!/home) @@ -68,7 +72,22 @@ To connect to your ServiceNow developer instance, configure the following in `ac - `snow_pw` - The password of the service account for the ServiceNow developer instance -- `localmode` - Whether the action server should **not** try to reach out to a `snow_instance` based on the credentials in `actions/snow_credentials.yml`. When set to `True` (default in the code), it will just take all the data in and message out the information that would normally be sent. +In `endpoints.yml` configure `helpdesk: mode:` to `snow`. + +### Optional: Connect to a Jira Instance + +If you want to connect to Jira Service Management, you can setup a free limited instance [here](https://www.atlassian.com/software/jira/service-management/free) + +To connect to your Jira instance, configure the following in `actions/jira_credentials.yml`: +- `jira_user` - This is the email of the admin account for the Jira instance + +- `jira_token` - This is the API Token for your Jira instance. Get it [here](https://id.atlassian.com/manage-profile/security/api-tokens) + +- `jira_url` - The url for the Jira instance. Should be in the form https://your-domain.atlassian.net + +- `project: id` - The id for your project. Can be found using the API https://your-domain.atlassian.net/rest/api/3/project + +In `endpoints.yml` configure `helpdesk: mode:` to `jira`. ## Running the bot @@ -80,7 +99,7 @@ Then, to run, first set up your action server in one terminal window: rasa run actions ``` -In another window, run the duckling server (for entity extraction): +In another window, run the duckling server (for netentity extraction): ```bash docker run -p 8000:8000 rasa/duckling @@ -122,7 +141,7 @@ If configured, the bot can also hand off to another bot in response to the user ## Example conversations -With `localmode=true`: +With `helpdesk: mode: local`: ``` Bot loaded. Type a message and press enter (use '/stop' to exit): @@ -158,7 +177,7 @@ Your input -> Yes please The most recent incident for anything@example.com is currently awaiting triage ``` -With `localmode=false`: +With `helpdesk: mode: snow`: With a Service Now instance connected, it will check if the email address is in the instance database and provide an incident number for the final response: diff --git a/actions/actions.py b/actions/actions.py index 948a015..f9d7686 100644 --- a/actions/actions.py +++ b/actions/actions.py @@ -5,16 +5,29 @@ from rasa_sdk.forms import FormValidationAction from rasa_sdk.events import AllSlotsReset, SlotSet from actions.snow import SnowAPI +from actions.jira_actions import JiraAPI import random - +import ruamel.yaml +import pathlib logger = logging.getLogger(__name__) vers = "vers: 0.1.0, date: Apr 2, 2020" logger.debug(vers) -snow = SnowAPI() -localmode = snow.localmode -logger.debug(f"Local mode: {snow.localmode}") +here = pathlib.Path(__file__).parent.absolute() +endpoint_config = ( + ruamel.yaml.safe_load(open(f"{here.parent}/endpoints.yml", "r")) or {} +) +mode = endpoint_config.get("helpdesk").get("mode") +if mode == "jira": + jira = JiraAPI() + print(mode) + +if mode == "snow": + snow = SnowAPI() + print(mode) + +logger.debug(f"mode: {mode}") class ActionAskEmail(Action): @@ -28,7 +41,9 @@ def run( domain: Dict[Text, Any], ) -> List[Dict]: if tracker.get_slot("previous_email"): - dispatcher.utter_message(template=f"utter_ask_use_previous_email",) + dispatcher.utter_message( + template=f"utter_ask_use_previous_email", + ) else: dispatcher.utter_message(template=f"utter_ask_email") return [] @@ -46,11 +61,16 @@ def _validate_email( elif isinstance(value, bool): value = tracker.get_slot("previous_email") - if localmode: - return {"email": value} + if mode == "snow": + results = snow.email_to_sysid(value) + caller_id = results.get("caller_id") + + elif mode == "jira": + results = jira.email_to_sysid(value) + caller_id = results.get("account_id") - results = snow.email_to_sysid(value) - caller_id = results.get("caller_id") + else: # localmode + return {"email": value} if caller_id: return {"email": value, "caller_id": caller_id} @@ -85,11 +105,22 @@ def validate_priority( ) -> Dict[Text, Any]: """Validate priority is a valid value.""" - if value.lower() in snow.priority_db(): - return {"priority": value} + if mode == "jira": + if value.lower() in jira.priority_db(): + return {"priority": value} + else: + dispatcher.utter_message(template="utter_no_priority") + return {"priority": None} + + elif mode == "snow": # handles local mode and snow + if value.lower() in snow.priority_db(): + return {"priority": value} + else: + dispatcher.utter_message(template="utter_no_priority") + return {"priority": None} + else: - dispatcher.utter_message(template="utter_no_priority") - return {"priority": None} + return {"priority": value} class ActionOpenIncident(Action): @@ -118,7 +149,7 @@ def run( ) return [AllSlotsReset(), SlotSet("previous_email", email)] - if localmode: + if mode == "local": message = ( f"An incident with the following details would be opened " f"if ServiceNow was connected:\n" @@ -126,7 +157,7 @@ def run( f"problem description: {problem_description}\n" f"title: {incident_title}\npriority: {priority}" ) - else: + elif mode == "snow": snow_priority = snow.priority_db().get(priority) response = snow.create_incident( description=problem_description, @@ -147,6 +178,28 @@ def run( f"Something went wrong while opening an incident for you. " f"{response.get('error')}" ) + elif mode == "jira": + jira_priority = jira.priority_db().get(priority) + response = jira.create_incident( + description=problem_description, + short_description=incident_title, + priority=jira_priority, + email=email, + ) + # TODO Need to test. Might need a try catch around this. If the email returns an error, the response won't be an object with id function. + incident_number = response.id + print(incident_number) + if incident_number: + message = ( + f"Successfully opened up incident {incident_number} " + f"for you. Someone will reach out soon." + ) + else: + message = ( + f"Something went wrong while opening an incident for you. " + f"{response.get('error')}" + ) + dispatcher.utter_message(message) return [AllSlotsReset(), SlotSet("previous_email", email)] @@ -177,7 +230,7 @@ def run( domain: Dict[Text, Any], ) -> List[Dict]: """Look up all incidents associated with email address - and return status of each""" + and return status of each""" email = tracker.get_slot("email") @@ -187,13 +240,13 @@ def run( "On Hold": "has been put on hold", "Closed": "has been closed", } - if localmode: + if mode == "local": status = random.choice(list(incident_states.values())) message = ( f"Since ServiceNow isn't connected, I'm making this up!\n" f"The most recent incident for {email} {status}" ) - else: + elif mode == "snow": incidents_result = snow.retrieve_incidents(email) incidents = incidents_result.get("incidents") if incidents: @@ -209,6 +262,18 @@ def run( else: message = f"{incidents_result.get('error')}" + elif mode == "jira": + incidents_result = jira.retrieve_incidents(email) + if incidents_result: + message = "\n".join( + [ + f"Incident {i}: " + f'{incidents_result["incidents"][i]["summary"]} ' + f'opened on {incidents_result["incidents"][i]["created_on"]} ' + f'Status: {incidents_result["incidents"][i]["status"]} ' + for i in incidents_result["incidents"] + ] + ) dispatcher.utter_message(message) return [AllSlotsReset(), SlotSet("previous_email", email)] diff --git a/actions/jira_actions.py b/actions/jira_actions.py new file mode 100644 index 0000000..5b12751 --- /dev/null +++ b/actions/jira_actions.py @@ -0,0 +1,110 @@ +import logging +import pathlib +import ruamel.yaml +from typing import Dict, Text, Any +from jira import ( + JIRA, +) +from jira.resources import Customer, Priority, User + +logger = logging.getLogger(__name__) + +cred_path = str(pathlib.Path(__file__).parent.absolute()) + + +class JiraAPI(object): + def __init__(self): + jira_config = ( + ruamel.yaml.safe_load( + open(f"{cred_path}/jira_credentials.yml", "r") + ) + or {} + ) + + self.jira_user = jira_config.get("jira_user") + self.jira_token = jira_config.get("jira_token") + self.jira_url = jira_config.get("jira_url") + self.project = jira_config.get("project") + self.jira_obj = JIRA( + self.jira_url, basic_auth=(self.jira_user, self.jira_token) + ) + + def email_to_sysid(self, email) -> Dict[Text, Any]: + result = {} + email_result = self.jira_obj.search_users( + None, 0, 10, True, False, email + ) + num_records = len(email_result) + if num_records == 1: + result["account_id"] = vars(email_result[0]).get("accountId") + else: + result["account_id"] = [] + result["error"] = ( + f"Could not retreive account id; " + f"{num_records} records found for email {email}" + ) + + return result + + def retrieve_incidents(self, email) -> Dict[Text, Any]: + result = {} + incidents = {} + email_result = self.email_to_sysid(email) + account_id = email_result.get("account_id") + issues = self.jira_obj.search_issues( + f"reporter in ({account_id}) order by created DESC" + ) + if account_id: + for issue in issues: + incidents[issue.key] = { + "summary": issue.fields.summary, + "created_on": issue.fields.created, + "status": issue.fields.status.name, + } + elif isinstance(issues, list): + result["error"] = f"No incidents on record for {email}" + + result["account_id"] = account_id + result["incidents"] = incidents + return result + + def create_incident( + self, description, short_description, priority, email + ) -> Dict[Text, Any]: + project = self.project + email_result = self.email_to_sysid(email) + account_id = email_result.get("account_id") + if account_id: + result = self.jira_obj.create_issue( + project=project, + summary=short_description, + description=description, + issuetype={"id": "10002"}, + priority={"id": priority}, + reporter={"accountId": account_id}, + ) + else: + result = email_result.get("error") + + return result + + def delete_issue(self, issue_id): + issue = self.jira_obj.issue(issue_id) + issue.delete() + return + + def change_priority(self, issue_id, priority): + issue = self.jira_obj.issue(issue_id) + issue.update(priority={"id": priority}) + + def assigned_issues(self, account_id): + issues = self.jira_obj.search_issues( + f"assignee = {account_id} ORDER BY priority" + ) + return issues + + @staticmethod + def priority_db() -> Dict[str, int]: + """Database of supported priorities""" + priorities = {"low": "4", "medium": "3", "high": "2"} + return priorities diff --git a/actions/jira_credentials.yml b/actions/jira_credentials.yml new file mode 100644 index 0000000..c78e6fa --- /dev/null +++ b/actions/jira_credentials.yml @@ -0,0 +1,6 @@ +#jira_user: YOUR_EMAIL +#jira_token: YOUR_API_TOKEN +#jira_url: YOUR_URL +#project: +# id : 'PROJECT_ID' + diff --git a/actions/snow_credentials.yml b/actions/snow_credentials.yml index ee9aacd..c9e8d24 100644 --- a/actions/snow_credentials.yml +++ b/actions/snow_credentials.yml @@ -1,4 +1,3 @@ # snow_instance: "dev97377.service-now.com" # snow_user: "admin" # snow_pw: "mySnowinstance1" -# localmode: false \ No newline at end of file diff --git a/endpoints.yml b/endpoints.yml index 7912200..4f9afc3 100644 --- a/endpoints.yml +++ b/endpoints.yml @@ -40,3 +40,6 @@ action_endpoint: # username: username # password: password # queue: queue + +helpdesk: + mode: local #local, snow, jira diff --git a/requirements.txt b/requirements.txt index d1318af..2c0860a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -rasa~=2.8.0 -rasa-sdk~=2.8.0 # if you change this, make sure to change the Dockerfile to match +rasa~=2.8.12 +rasa-sdk~=2.8.2 # if you change this, make sure to change the Dockerfile to match -r actions/requirements-actions.txt +jira~=3.1.1 \ No newline at end of file