diff --git a/client/app/assets/images/destinations/webex.png b/client/app/assets/images/destinations/webex.png new file mode 100644 index 0000000000..bea8fd1cad Binary files /dev/null and b/client/app/assets/images/destinations/webex.png differ diff --git a/redash/destinations/webex.py b/redash/destinations/webex.py new file mode 100644 index 0000000000..16d8ed05a6 --- /dev/null +++ b/redash/destinations/webex.py @@ -0,0 +1,138 @@ +import logging +from copy import deepcopy + +import requests + +from redash.destinations import BaseDestination, register +from redash.models import Alert + + +class Webex(BaseDestination): + @classmethod + def configuration_schema(cls): + return { + "type": "object", + "properties": { + "webex_bot_token": {"type": "string", "title": "Webex Bot Token"}, + "to_person_emails": { + "type": "string", + "title": "People (comma-separated)", + }, + "to_room_ids": { + "type": "string", + "title": "Rooms (comma-separated)", + }, + }, + "secret": ["webex_bot_token"], + "required": ["webex_bot_token"], + } + + @classmethod + def icon(cls): + return "fa-webex" + + @property + def api_base_url(self): + return "https://webexapis.com/v1/messages" + + @staticmethod + def formatted_attachments_template(subject, description, query_link, alert_link): + return [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": 4, + "items": [ + { + "type": "TextBlock", + "text": {subject}, + "weight": "bolder", + "size": "medium", + "wrap": True, + }, + { + "type": "TextBlock", + "text": {description}, + "isSubtle": True, + "wrap": True, + }, + { + "type": "TextBlock", + "text": f"Click [here]({query_link}) to check your query!", + "wrap": True, + "isSubtle": True, + }, + { + "type": "TextBlock", + "text": f"Click [here]({alert_link}) to check your alert!", + "wrap": True, + "isSubtle": True, + }, + ], + }, + ], + } + ], + }, + } + ] + + def notify(self, alert, query, user, new_state, app, host, metadata, options): + # Documentation: https://developer.webex.com/docs/api/guides/cards + + query_link = f"{host}/queries/{query.id}" + alert_link = f"{host}/alerts/{alert.id}" + + if new_state == Alert.TRIGGERED_STATE: + subject = alert.custom_subject or f"{alert.name} just triggered" + else: + subject = f"{alert.name} went back to normal" + + attachments = self.formatted_attachments_template( + subject=subject, description=alert.custom_body, query_link=query_link, alert_link=alert_link + ) + + template_payload = {"markdown": subject + "\n" + alert.custom_body, "attachments": attachments} + + headers = {"Authorization": f"Bearer {options['webex_bot_token']}"} + + api_destinations = { + "toPersonEmail": options.get("to_person_emails"), + "roomId": options.get("to_room_ids"), + } + + for payload_tag, destinations in api_destinations.items(): + if destinations is None: + continue + + # destinations is guaranteed to be a comma-separated string + for destination_id in destinations.split(","): + payload = deepcopy(template_payload) + payload[payload_tag] = destination_id + self.post_message(payload, headers) + + def post_message(self, payload, headers): + try: + resp = requests.post( + self.api_base_url, + json=payload, + headers=headers, + timeout=5.0, + ) + logging.warning(resp.text) + if resp.status_code != 200: + logging.error("Webex send ERROR. status_code => {status}".format(status=resp.status_code)) + except Exception as e: + logging.exception(f"Webex send ERROR: {e}") + + +register(Webex) diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index 5c4ba4b734..2e2a3fbca9 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -364,6 +364,7 @@ def email_server_is_configured(): "redash.destinations.hangoutschat", "redash.destinations.microsoft_teams_webhook", "redash.destinations.asana", + "redash.destinations.webex", ] enabled_destinations = array_from_string(os.environ.get("REDASH_ENABLED_DESTINATIONS", ",".join(default_destinations))) diff --git a/tests/handlers/test_destinations.py b/tests/handlers/test_destinations.py index abd090a153..aa0e865d6c 100644 --- a/tests/handlers/test_destinations.py +++ b/tests/handlers/test_destinations.py @@ -4,6 +4,7 @@ from redash.destinations.asana import Asana from redash.destinations.discord import Discord +from redash.destinations.webex import Webex from redash.models import Alert, NotificationDestination from tests import BaseTestCase @@ -196,3 +197,53 @@ def test_asana_notify_calls_requests_post(): ) assert mock_response.status_code == 204 + + +def test_webex_notify_calls_requests_post(): + alert = mock.Mock(spec_set=["id", "name", "custom_subject", "custom_body", "render_template"]) + alert.id = 1 + alert.name = "Test Alert" + alert.custom_subject = "Test custom subject" + alert.custom_body = "Test custom body" + + alert.render_template = mock.Mock(return_value={"Rendered": "template"}) + query = mock.Mock() + query.id = 1 + + user = mock.Mock() + app = mock.Mock() + host = "https://localhost:5000" + options = {"webex_bot_token": "abcd", "to_room_ids": "1234"} + metadata = {"Scheduled": False} + + new_state = Alert.TRIGGERED_STATE + destination = Webex(options) + + with mock.patch("redash.destinations.webex.requests.post") as mock_post: + mock_response = mock.Mock() + mock_response.status_code = 204 + mock_post.return_value = mock_response + + destination.notify(alert, query, user, new_state, app, host, metadata, options) + + query_link = f"{host}/queries/{query.id}" + alert_link = f"{host}/alerts/{alert.id}" + + formatted_attachments = Webex.formatted_attachments_template( + alert.custom_subject, alert.custom_body, query_link, alert_link + ) + + expected_payload = { + "markdown": alert.custom_subject + "\n" + alert.custom_body, + "attachments": formatted_attachments, + "roomId": "1234", + } + + mock_post.assert_called_once_with( + destination.api_base_url, + json=expected_payload, + headers={"Authorization": "Bearer abcd"}, + timeout=5.0, + ) + + assert mock_response.status_code == 204