diff --git a/docs/sources/set-up/open-source/index.md b/docs/sources/set-up/open-source/index.md index 734ec8fc..1fc1ef0d 100644 --- a/docs/sources/set-up/open-source/index.md +++ b/docs/sources/set-up/open-source/index.md @@ -213,6 +213,23 @@ To connect to Grafana Cloud OnCall, refer to the **Cloud** page in your OSS Graf ## Supported Phone Providers +### Exotel + +Grafana OnCall supports Exotel phone call notifications delivery. To configure phone call notifications using Exotel, +complete the following steps: + +1. Set `GRAFANA_CLOUD_NOTIFICATIONS_ENABLED` as **False** to ensure the Grafana OSS <-> Cloud connector is disabled. +2. Change `PHONE_PROVIDER` value to `exotel`. +3. `EXOTEL_ACCOUNT_SID` can be found under DEVELOPER SETTINGS->API Settings +4. `EXOTEL_API_KEY` and `EXOTEL_API_TOKEN` can also be found under DEVELOPER SETTINGS->API Settings +5. `EXOTEL_APP_ID` is the identifier of the flow (or applet) which can be found under MANAGE->App Bazaar (Installed apps) +6. `EXOTEL_CALLER_ID` is the Exophone / Exotel virtual number. +7. `EXOTEL_SMS_SENDER_ID` is the SMS Sender ID to use for sending verification SMS, which can be found under + SMS SETTINGS->Sender ID. +8. `EXOTEL_SMS_VERIFICATION_TEMPLATE` is the SMS text template to be used for sending verification SMS, add + $verification_code as a placeholder. +9. `EXOTEL_SMS_DLT_ENTITY_ID` is the DLT Entity ID registered with TRAI. + ### Twilio Grafana OnCall supports Twilio SMS and phone call notifications delivery. If you prefer to configure SMS and phone call diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index e4c4a2ab..3c4f1e56 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -72,6 +72,14 @@ class LiveSetting(models.Model): "ZVONOK_POSTBACK_USER_CHOICE", "ZVONOK_POSTBACK_USER_CHOICE_ACK", "ZVONOK_VERIFICATION_CAMPAIGN_ID", + "EXOTEL_ACCOUNT_SID", + "EXOTEL_API_KEY", + "EXOTEL_API_TOKEN", + "EXOTEL_APP_ID", + "EXOTEL_CALLER_ID", + "EXOTEL_SMS_SENDER_ID", + "EXOTEL_SMS_VERIFICATION_TEMPLATE", + "EXOTEL_SMS_DLT_ENTITY_ID", ) DESCRIPTIONS = { @@ -171,6 +179,14 @@ class LiveSetting(models.Model): "ZVONOK_POSTBACK_USER_CHOICE": "'Postback' user choice (ct_user_choice) query parameter name (optional).", "ZVONOK_POSTBACK_USER_CHOICE_ACK": "'Postback' user choice (ct_user_choice) query parameter value for acknowledge alert group (optional).", "ZVONOK_VERIFICATION_CAMPAIGN_ID": "The phone number verification campaign ID. You can get it after verification campaign creation.", + "EXOTEL_ACCOUNT_SID": "Exotel account SID. You can get it in DEVELOPER SETTINGS -> API Settings", + "EXOTEL_API_KEY": "API Key (username)", + "EXOTEL_API_TOKEN": "API Token (password)", + "EXOTEL_APP_ID": "Identifier of the flow (or applet)", + "EXOTEL_CALLER_ID": "Exophone / Exotel virtual number", + "EXOTEL_SMS_SENDER_ID": "Exotel SMS Sender ID to use for verification SMS", + "EXOTEL_SMS_VERIFICATION_TEMPLATE": "SMS text template to be used for sending SMS, add $verification_code as a placeholder for the verification code", + "EXOTEL_SMS_DLT_ENTITY_ID": "DLT Entity ID registered with TRAI.", } SECRET_SETTING_NAMES = ( @@ -187,6 +203,8 @@ class LiveSetting(models.Model): "TELEGRAM_TOKEN", "GRAFANA_CLOUD_ONCALL_TOKEN", "ZVONOK_API_KEY", + "EXOTEL_ACCOUNT_SID", + "EXOTEL_API_TOKEN", ) def __str__(self): diff --git a/engine/apps/exotel/__init__.py b/engine/apps/exotel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/exotel/migrations/0001_initial.py b/engine/apps/exotel/migrations/0001_initial.py new file mode 100644 index 00000000..4c328c8b --- /dev/null +++ b/engine/apps/exotel/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.11 on 2024-05-25 14:59 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('phone_notifications', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ExotelPhoneCall', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.PositiveSmallIntegerField(blank=True, choices=[(10, 'queued'), (20, 'in-progress'), (30, 'completed'), (40, 'failed'), (50, 'busy'), (60, 'no-answer')], null=True)), + ('call_id', models.CharField(blank=True, max_length=50)), + ('phone_call_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', related_query_name='%(app_label)s_%(class)ss', to='phone_notifications.phonecallrecord')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/engine/apps/exotel/migrations/__init__.py b/engine/apps/exotel/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/exotel/models/__init__.py b/engine/apps/exotel/models/__init__.py new file mode 100644 index 00000000..279355fd --- /dev/null +++ b/engine/apps/exotel/models/__init__.py @@ -0,0 +1 @@ +from .phone_call import ExotelCallStatuses, ExotelPhoneCall # noqa: F401 diff --git a/engine/apps/exotel/models/phone_call.py b/engine/apps/exotel/models/phone_call.py new file mode 100644 index 00000000..4683b4c0 --- /dev/null +++ b/engine/apps/exotel/models/phone_call.py @@ -0,0 +1,45 @@ +from django.db import models + +from apps.phone_notifications.phone_provider import ProviderPhoneCall + + +class ExotelCallStatuses: + QUEUED = 10 + IN_PROGRESS = 20 + COMPLETED = 30 + FAILED = 40 + BUSY = 50 + NO_ANSWER = 60 + + CHOICES = ( + (QUEUED, "queued"), + (IN_PROGRESS, "in-progress"), + (COMPLETED, "completed"), + (FAILED, "failed"), + (BUSY, "busy"), + (NO_ANSWER, "no-answer"), + ) + + DETERMINANT = { + "queued": QUEUED, + "in-progress": IN_PROGRESS, + "completed": COMPLETED, + "failed": FAILED, + "busy": BUSY, + "no-answer": NO_ANSWER, + } + + +class ExotelPhoneCall(ProviderPhoneCall, models.Model): + created_at = models.DateTimeField(auto_now_add=True) + + status = models.PositiveSmallIntegerField( + blank=True, + null=True, + choices=ExotelCallStatuses.CHOICES, + ) + + call_id = models.CharField( + blank=True, + max_length=50, + ) diff --git a/engine/apps/exotel/phone_provider.py b/engine/apps/exotel/phone_provider.py new file mode 100644 index 00000000..16b151f4 --- /dev/null +++ b/engine/apps/exotel/phone_provider.py @@ -0,0 +1,175 @@ +import logging +from random import randint +from string import Template + +import requests +from django.core.cache import cache +from requests.auth import HTTPBasicAuth + +from apps.base.models import LiveSetting +from apps.base.utils import live_settings +from apps.exotel.models.phone_call import ExotelCallStatuses, ExotelPhoneCall +from apps.exotel.status_callback import get_call_status_callback_url +from apps.phone_notifications.exceptions import FailedToMakeCall, FailedToStartVerification +from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags + +EXOTEL_ENDPOINT = "https://twilix.exotel.com/v1/Accounts/" +EXOTEL_SMS_API = "/Sms/send.json" +EXOTEL_CALL_API = "/Calls/connect.json" + +logger = logging.getLogger(__name__) + + +class ExotelPhoneProvider(PhoneProvider): + """ + ExotelPhoneProvider is an implementation of phone provider (exotel.com). + """ + + def make_notification_call(self, number: str, message: str) -> ExotelPhoneCall: + body = None + try: + response = self._call_create(number) + response.raise_for_status() + body = response.json() + if not body: + logger.error("ExotelPhoneProvider.make_notification_call: failed, empty body") + raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}, empty body") + + sid = body.get("Call").get("Sid") + + if not sid: + logger.error("ExotelPhoneProvider.make_notification_call: failed, missing sid") + raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number} missing sid") + + logger.info(f"ExotelPhoneProvider.make_notification_call: success, sid {sid}") + + return ExotelPhoneCall( + status=ExotelCallStatuses.IN_PROGRESS, + call_id=sid, + ) + + except requests.exceptions.HTTPError as http_err: + logger.error(f"ExotelPhoneProvider.make_notification_call: failed {http_err}") + raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number} http error") + except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err: + logger.error(f"ExotelPhoneProvider.make_notification_call: failed {err}") + raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}") + + def make_call(self, number: str, message: str): + body = None + + try: + response = self._call_create(number, False) + response.raise_for_status() + body = response.json() + if not body: + logger.error("ExotelPhoneProvider.make_call: failed, empty body") + raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}, empty body") + + sid = body.get("Call").get("Sid") + + if not sid: + logger.error("ExotelPhoneProvider.make_call: failed, missing sid") + raise FailedToMakeCall(graceful_msg=f"Failed make call to {number} missing sid") + + logger.info(f"ExotelPhoneProvider.make_call: success, sid {sid}") + + except requests.exceptions.HTTPError as http_err: + logger.error(f"ExotelPhoneProvider.make_call: failed {http_err}") + raise FailedToMakeCall(graceful_msg=f"Failed make call to {number} http error") + except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err: + logger.error(f"ExotelPhoneProvider.make_call: failed {err}") + raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}") + + def _call_create(self, number: str, with_callback: bool = True): + params = { + "From": number, + "CallerId": live_settings.EXOTEL_CALLER_ID, + "Url": f"http://my.exotel.in/exoml/start/{live_settings.EXOTEL_APP_ID}", + } + + if with_callback: + params.update( + { + "StatusCallback": get_call_status_callback_url(), + "StatusCallbackContentType": "application/json", + } + ) + + auth = HTTPBasicAuth(live_settings.EXOTEL_API_KEY, live_settings.EXOTEL_API_TOKEN) + + exotel_call_url = f"{EXOTEL_ENDPOINT}{live_settings.EXOTEL_ACCOUNT_SID}{EXOTEL_CALL_API}" + + return requests.post(exotel_call_url, auth=auth, params=params) + + def _get_graceful_msg(self, body, number): + if body: + status = body.get("SMSMessage").get("Status") + data = body.get("SMSMessage").get("DetailedStatus") + if status == "failed" and data: + return f"Failed sending sms to {number} with error: {data}" + return f"Failed sending sms to {number}" + + def send_verification_sms(self, number: str): + code = self._generate_verification_code() + cache.set(self._cache_key(number), code, timeout=10 * 60) + + body = None + message = Template(live_settings.EXOTEL_SMS_VERIFICATION_TEMPLATE).safe_substitute(verification_code=code) + try: + response = self._send_verification_code( + number, + message, + ) + response.raise_for_status() + body = response.json() + if not body: + logger.error("ExotelPhoneProvider.send_verification_sms: failed, empty body") + raise FailedToStartVerification(graceful_msg=f"Failed sending verification sms to {number}, empty body") + + sid = body.get("SMSMessage").get("Sid") + if not sid: + raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number)) + except requests.exceptions.HTTPError as http_err: + logger.error(f"ExotelPhoneProvider.send_verification_sms: failed {http_err}") + raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number)) + except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err: + logger.error(f"ExotelPhoneProvider.send_verification_sms: failed {err}") + raise FailedToStartVerification(graceful_msg=f"Failed sending verification SMS to {number}") + + def _send_verification_code(self, number: str, body: str): + params = { + "From": live_settings.EXOTEL_SMS_SENDER_ID, + "DltEntityId": live_settings.EXOTEL_SMS_DLT_ENTITY_ID, + "To": number, + "Body": body, + } + + auth = HTTPBasicAuth(live_settings.EXOTEL_API_KEY, live_settings.EXOTEL_API_TOKEN) + + exotel_sms_url = f"{EXOTEL_ENDPOINT}{live_settings.EXOTEL_ACCOUNT_SID}{EXOTEL_SMS_API}" + + return requests.post(exotel_sms_url, auth=auth, params=params) + + def finish_verification(self, number, code): + has = cache.get(self._cache_key(number)) + if has is not None and has == code: + return number + else: + return None + + def _cache_key(self, number): + return f"exotel_provider_{number}" + + def _generate_verification_code(self): + return str(randint(100000, 999999)) + + @property + def flags(self) -> ProviderFlags: + return ProviderFlags( + configured=not LiveSetting.objects.filter(name__startswith="EXOTEL", error__isnull=False).exists(), + test_sms=False, + test_call=True, + verification_call=False, + verification_sms=True, + ) diff --git a/engine/apps/exotel/status_callback.py b/engine/apps/exotel/status_callback.py new file mode 100644 index 00000000..4132075c --- /dev/null +++ b/engine/apps/exotel/status_callback.py @@ -0,0 +1,78 @@ +import logging +from typing import Optional + +from django.urls import reverse + +from apps.alerts.signals import user_notification_action_triggered_signal +from apps.exotel.models.phone_call import ExotelCallStatuses, ExotelPhoneCall +from common.api_helpers.utils import create_engine_url + +logger = logging.getLogger(__name__) + + +def get_call_status_callback_url(): + return create_engine_url(reverse("exotel:call_status_events")) + + +def update_exotel_call_status(call_id: str, call_status: str, user_choice: Optional[str] = None): + from apps.base.models import UserNotificationPolicyLogRecord + + status_code = ExotelCallStatuses.DETERMINANT.get(call_status) + if status_code is None: + logger.warning(f"exotel.update_exotel_call_status: unexpected status call_id={call_id} status={call_status}") + return + + exotel_phone_call = ExotelPhoneCall.objects.filter(call_id=call_id).first() + if exotel_phone_call is None: + logger.warning(f"exotel.update_exotel_call_status: exotel_phone_call not found call_id={call_id}") + return + + logger.info(f"exotel.update_exotel_call_status: found exotel_phone_call call_id={call_id}") + + exotel_phone_call.status = status_code + exotel_phone_call.save(update_fields=["status"]) + phone_call_record = exotel_phone_call.phone_call_record + + if phone_call_record is None: + logger.warning( + f"exotel.update_exotel_call_status: exotel_phone_call has no phone_call record call_id={call_id} " + f"status={call_status}" + ) + return + + logger.info( + f"exotel.update_exotel_call_status: found phone_call_record id={phone_call_record.id} " + f"call_id={call_id} status={call_status}" + ) + log_record_type = None + log_record_error_code = None + + success_statuses = [ExotelCallStatuses.COMPLETED] + + if status_code in success_statuses: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_SUCCESS + else: + log_record_type = UserNotificationPolicyLogRecord.TYPE_PERSONAL_NOTIFICATION_FAILED + log_record_error_code = UserNotificationPolicyLogRecord.ERROR_NOTIFICATION_PHONE_CALL_FAILED + + if log_record_type is not None: + log_record = UserNotificationPolicyLogRecord( + type=log_record_type, + notification_error_code=log_record_error_code, + author=phone_call_record.receiver, + notification_policy=phone_call_record.notification_policy, + alert_group=phone_call_record.represents_alert_group, + notification_step=phone_call_record.notification_policy.step + if phone_call_record.notification_policy + else None, + notification_channel=phone_call_record.notification_policy.notify_by + if phone_call_record.notification_policy + else None, + ) + log_record.save() + logger.info( + f"exotel.update_exotel_call_status: created log_record log_record_id={log_record.id} " + f"type={log_record_type}" + ) + + user_notification_action_triggered_signal.send(sender=update_exotel_call_status, log_record=log_record) diff --git a/engine/apps/exotel/tests/__init__.py b/engine/apps/exotel/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engine/apps/exotel/tests/test_exotel_provider.py b/engine/apps/exotel/tests/test_exotel_provider.py new file mode 100644 index 00000000..bc666f50 --- /dev/null +++ b/engine/apps/exotel/tests/test_exotel_provider.py @@ -0,0 +1,48 @@ +from unittest.mock import MagicMock, patch + +import pytest +from django.test import override_settings + +from apps.exotel.phone_provider import ExotelPhoneProvider + + +@pytest.fixture +def provider(): + return ExotelPhoneProvider() + + +@pytest.mark.django_db +def test_make_notification_call(provider): + number = "1234567890" + message = "dummy message" + + provider._call_create = MagicMock(return_value=MagicMock(json=lambda: {"Call": {"Sid": "12345"}})) + provider.make_notification_call(number, message) + provider._call_create.assert_called_once_with(number) + + +@pytest.mark.django_db +def test_make_call(provider): + number = "1234567890" + message = "dummy message" + + provider._call_create = MagicMock(return_value=MagicMock(json=lambda: {"Call": {"Sid": "12345"}})) + provider.make_call(number, message) + provider._call_create.assert_called_once_with(number, False) + + +@pytest.mark.django_db +def test_send_verification_sms(provider): + verification_code = "123456" + sms_template = "Your verification code for grafana oncall is $verification_code" + message = "Your verification code for grafana oncall is 123456" + number = "1234567890" + + with override_settings(EXOTEL_SMS_VERIFICATION_TEMPLATE=sms_template): + with patch("django.core.cache.cache.set"): + provider._generate_verification_code = MagicMock(return_value=verification_code) + provider._send_verification_code = MagicMock( + return_value=MagicMock(json=lambda: {"SMSMessage": {"Sid": "12345"}}) + ) + provider.send_verification_sms(number) + provider._send_verification_code.assert_called_once_with(number, message) diff --git a/engine/apps/exotel/urls.py b/engine/apps/exotel/urls.py new file mode 100644 index 00000000..e7b0bc93 --- /dev/null +++ b/engine/apps/exotel/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from .views import CallStatusCallback + +app_name = "exotel" + +urlpatterns = [ + path("call_status_events/", CallStatusCallback.as_view(), name="call_status_events"), +] diff --git a/engine/apps/exotel/views.py b/engine/apps/exotel/views.py new file mode 100644 index 00000000..bc03e2b0 --- /dev/null +++ b/engine/apps/exotel/views.py @@ -0,0 +1,46 @@ +from rest_framework import status +from rest_framework.permissions import BasePermission +from rest_framework.response import Response +from rest_framework.views import APIView + +from .status_callback import update_exotel_call_status + + +class AllowOnlyExotel(BasePermission): + def has_permission(self, request, view): + call_id = request.data.get("CallSid") + if not call_id: + return False + + status = request.data.get("Status") + if not status: + return False + + from apps.exotel.models import ExotelPhoneCall + + call = ExotelPhoneCall.objects.filter(call_id=call_id).first() + if call: + return self.validate_request(request) + return False + + def validate_request(self, request): + # No reliable way to validate an exotel status callback as of now + # this is confirmed by exotel customer support too + # It is better to allow only exotel server IPs to this endpoint through firewall or similar means + if request.META.get("HTTP_USER_AGENT") == "Exotel Servers": + return True + return False + + +# Receive Call Status from Exotel +class CallStatusCallback(APIView): + permission_classes = [AllowOnlyExotel] + + def post(self, request): + self._handle_call_status(request) + return Response(data="", status=status.HTTP_204_NO_CONTENT) + + def _handle_call_status(self, request): + call_id = request.data.get("CallSid") + call_status = request.data.get("Status") + update_exotel_call_status(call_id=call_id, call_status=call_status) diff --git a/engine/engine/urls.py b/engine/engine/urls.py index ddcfb51e..36cbffbe 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -67,6 +67,7 @@ if settings.IS_OPEN_SOURCE: urlpatterns += [ path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")), path("zvonok/", include("apps.zvonok.urls")), + path("exotel/", include("apps.exotel.urls")), ] if settings.DEBUG: diff --git a/engine/settings/base.py b/engine/settings/base.py index 8e261465..66e6860b 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -854,7 +854,7 @@ INSTALLED_WEBHOOK_PRESETS = [ ] if IS_OPEN_SOURCE: - INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa + INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok", "apps.exotel"] # noqa CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa "task": "apps.oss_installation.tasks.send_usage_stats_report", @@ -901,6 +901,7 @@ PHONE_PROVIDERS = { if IS_OPEN_SOURCE: PHONE_PROVIDERS["zvonok"] = "apps.zvonok.phone_provider.ZvonokPhoneProvider" + PHONE_PROVIDERS["exotel"] = "apps.exotel.phone_provider.ExotelPhoneProvider" PHONE_PROVIDER = os.environ.get("PHONE_PROVIDER", default=DEFAULT_PHONE_PROVIDER) @@ -915,6 +916,15 @@ ZVONOK_POSTBACK_USER_CHOICE = os.getenv("ZVONOK_POSTBACK_USER_CHOICE", None) ZVONOK_POSTBACK_USER_CHOICE_ACK = os.getenv("ZVONOK_POSTBACK_USER_CHOICE_ACK", None) ZVONOK_VERIFICATION_CAMPAIGN_ID = os.getenv("ZVONOK_VERIFICATION_CAMPAIGN_ID", None) +EXOTEL_ACCOUNT_SID = os.getenv("EXOTEL_ACCOUNT_SID", None) +EXOTEL_API_KEY = os.getenv("EXOTEL_API_KEY", None) +EXOTEL_API_TOKEN = os.getenv("EXOTEL_API_TOKEN", None) +EXOTEL_APP_ID = os.getenv("EXOTEL_APP_ID", None) +EXOTEL_CALLER_ID = os.getenv("EXOTEL_CALLER_ID", None) +EXOTEL_SMS_SENDER_ID = os.getenv("EXOTEL_SMS_SENDER_ID", None) +EXOTEL_SMS_VERIFICATION_TEMPLATE = os.getenv("EXOTEL_SMS_VERIFICATION_TEMPLATE", None) +EXOTEL_SMS_DLT_ENTITY_ID = os.getenv("EXOTEL_SMS_DLT_ENTITY_ID", None) + DETACHED_INTEGRATIONS_SERVER = getenv_boolean("DETACHED_INTEGRATIONS_SERVER", default=False) ACKNOWLEDGE_REMINDER_TASK_EXPIRY_DAYS = os.environ.get("ACKNOWLEDGE_REMINDER_TASK_EXPIRY_DAYS", default=14)