Skip to content

Commit

Permalink
add exotel call provider (#4433)
Browse files Browse the repository at this point in the history
# What this PR does

Added support for [Exotel](https://exotel.com/) call provider. 

Features:

- Sending verification code through SMS
- Making test call
- Making notification call


## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [x] Documentation added (or `pr:no public docs` PR label added if not
required)
- [x] Added the relevant release notes label (see labels prefixed w/
`release:`). These labels dictate how your PR will
    show up in the autogenerated release notes.
  • Loading branch information
clemthom authored Jun 6, 2024
1 parent 2e10215 commit 28190fe
Show file tree
Hide file tree
Showing 15 changed files with 478 additions and 1 deletion.
17 changes: 17 additions & 0 deletions docs/sources/set-up/open-source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions engine/apps/base/models/live_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = (
Expand All @@ -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):
Expand Down
Empty file added engine/apps/exotel/__init__.py
Empty file.
29 changes: 29 additions & 0 deletions engine/apps/exotel/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
Empty file.
1 change: 1 addition & 0 deletions engine/apps/exotel/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .phone_call import ExotelCallStatuses, ExotelPhoneCall # noqa: F401
45 changes: 45 additions & 0 deletions engine/apps/exotel/models/phone_call.py
Original file line number Diff line number Diff line change
@@ -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,
)
175 changes: 175 additions & 0 deletions engine/apps/exotel/phone_provider.py
Original file line number Diff line number Diff line change
@@ -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,
)
78 changes: 78 additions & 0 deletions engine/apps/exotel/status_callback.py
Original file line number Diff line number Diff line change
@@ -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)
Empty file.
Loading

0 comments on commit 28190fe

Please sign in to comment.