Skip to content

Commit

Permalink
add zvonok integration (#2339)
Browse files Browse the repository at this point in the history
Added integration with [zvonok.com](https://zvonok.com) service.

Features:
- Phone number validation
- Test calls
- Selection of pre-recorded audio
- Making calls
- Processing call status
- Acknowledgment alert group (optional)

To process the call status, it is required to add a postback with the
GET method on the side of the zvonok.com service with the following
format ([more info
here](https://zvonok.com/ru-ru/guide/guide_postback/)):

```${ONCALL_BASE_URL}/zvonok/call_status_events?campaign_id={ct_campaign_id}&call_id={ct_call_id}&status={ct_status}&user_choice={ct_user_choice}```

The names of the transmitted parameters can be redefined through environment variables.

---------

Co-authored-by: Innokentii Konstantinov <innokenty.konstantinov@grafana.com>
  • Loading branch information
sreway and Konstantinov-Innokentii authored Jul 5, 2023
1 parent 5cc9d54 commit aeb3500
Show file tree
Hide file tree
Showing 14 changed files with 461 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Add full avatar URL for on-call users in schedule internal API by @vadimkerr ([#2414](https://github.com/grafana/oncall/pull/2414))
- Add phone call using the zvonok.com service by @sreway ([#2339](https://github.com/grafana/oncall/pull/2339))

## v1.3.3 (2023-06-29)

Expand Down
28 changes: 27 additions & 1 deletion docs/sources/open-source/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,40 @@ The benefits of connecting to Grafana Cloud include:

To connect to Grafana Cloud, refer to the **Cloud** page in your OSS Grafana OnCall instance.

## Twilio Setup
## Supported Phone Providers

### Twilio

Grafana OnCall supports Twilio SMS and phone call notifications delivery. If you prefer to configure SMS and phone call
notifications using Twilio, complete the following steps:

1. Set `GRAFANA_CLOUD_NOTIFICATIONS_ENABLED` as **False** to ensure the Grafana OSS <-> Cloud connector is disabled.
1. From your **OnCall** environment, select **Env Variables** and configure all variables starting with `TWILIO_`.

### Zvonok.com

Grafana OnCall supports Zvonok.com phone call notifications delivery. To configure phone call notifications using
Zvonok.com, complete the following steps:

1. Change `PHONE_PROVIDER` value to `zvonok`.
2. Create a public API key on the Profile->Settings page, and assign its value to `ZVONOK_API_KEY`.
3. Create campaign and assign its ID value to `ZVONOK_CAMPAIGN_ID`.
4. If you are planning to use pre-recorded audio instead of a speech synthesizer, you can copy the ID of the audio clip
to the variable `ZVONOK_AUDIO_ID` (optional step).
5. To make a call with a specific voice, you can set the `ZVONOK_SPEAKER_ID`.
By default, the ID used is `Salli` (optional step).
6. To process the call status, it is required to add a postback with the GET/POST method on the side of the zvonok.com
service with the following format (optional step):
`${ONCALL_BASE_URL}/zvonok/call_status_events?campaign_id={ct_campaign_id}&call_id={ct_call_id}&status={ct_status}&user_choice={ct_user_choice}`

The names of the transmitted parameters can be redefined through environment variables:

- `ZVONOK_POSTBACK_CALL_ID` - call id (ct_call_id) query parameter name
- `ZVONOK_POSTBACK_CAMPAIGN_ID` - company id (ct_campaign_id) query parameter name
- `ZVONOK_POSTBACK_STATUS` - status (ct_status) query parameter name
- `ZVONOK_POSTBACK_USER_CHOICE` - user choice (ct_user_choice) query parameter name
- `ZVONOK_POSTBACK_USER_CHOICE_ACK` - user choice (ct_user_choice) query parameter value for acknowledge alert group

## Email Setup

Grafana OnCall is capable of sending emails using SMTP as a user notification step. To setup email notifications, populate
Expand Down
19 changes: 19 additions & 0 deletions engine/apps/base/models/live_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ class LiveSetting(models.Model):
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED",
"DANGEROUS_WEBHOOKS_ENABLED",
"PHONE_PROVIDER",
"ZVONOK_API_KEY",
"ZVONOK_CAMPAIGN_ID",
"ZVONOK_AUDIO_ID",
"ZVONOK_SPEAKER_ID",
"ZVONOK_POSTBACK_CALL_ID",
"ZVONOK_POSTBACK_CAMPAIGN_ID",
"ZVONOK_POSTBACK_STATUS",
"ZVONOK_POSTBACK_USER_CHOICE",
"ZVONOK_POSTBACK_USER_CHOICE_ACK",
)

DESCRIPTIONS = {
Expand Down Expand Up @@ -148,6 +157,15 @@ class LiveSetting(models.Model):
"GRAFANA_CLOUD_NOTIFICATIONS_ENABLED": "Enable SMS/call notifications via Grafana Cloud OnCall",
"DANGEROUS_WEBHOOKS_ENABLED": "Enable outgoing webhooks to private networks",
"PHONE_PROVIDER": f"Phone provider name. Available options: {','.join(list(settings.PHONE_PROVIDERS.keys()))}",
"ZVONOK_API_KEY": "API public key. You can get it in Profile->Settings section.",
"ZVONOK_CAMPAIGN_ID": "Calls by API campaign ID. You can get it after campaign creation.",
"ZVONOK_AUDIO_ID": "Calls with specific audio. You can get it in Audioclips section.",
"ZVONOK_SPEAKER_ID": "Calls with speaker.",
"ZVONOK_POSTBACK_CALL_ID": "'Postback' call id (ct_call_id) query parameter name to validate a postback request.",
"ZVONOK_POSTBACK_CAMPAIGN_ID": "'Postback' company id (ct_campaign_id) query parameter name to validate a postback request.",
"ZVONOK_POSTBACK_STATUS": "'Postback' status (ct_status) query parameter name to validate a postback request.",
"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).",
}

SECRET_SETTING_NAMES = (
Expand All @@ -163,6 +181,7 @@ class LiveSetting(models.Model):
"SLACK_SIGNING_SECRET",
"TELEGRAM_TOKEN",
"GRAFANA_CLOUD_ONCALL_TOKEN",
"ZVONOK_API_KEY",
)

def __str__(self):
Expand Down
Empty file added engine/apps/zvonok/__init__.py
Empty file.
30 changes: 30 additions & 0 deletions engine/apps/zvonok/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 3.2.19 on 2023-07-01 12:28

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='ZvonokPhoneCall',
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, 'attempts_exc'), (20, 'compl_finished'), (30, 'compl_nofinished'), (40, 'deleted'), (50, 'duration_error'), (60, 'expires'), (70, 'novalid_button'), (80, 'no_provider'), (90, 'interrupted'), (100, 'in_process'), (110, 'pincode_nook'), (130, 'synth_error'), (140, 'user')], null=True)),
('call_id', models.CharField(blank=True, max_length=50)),
('campaign_id', models.CharField(max_length=50)),
('phone_call_record', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='zvonok_zvonokphonecall_related', related_query_name='zvonok_zvonokphonecalls', to='phone_notifications.phonecallrecord')),
],
options={
'abstract': False,
},
),
]
Empty file.
1 change: 1 addition & 0 deletions engine/apps/zvonok/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .phone_call import ZvonokCallStatuses, ZvonokPhoneCall # noqa: F401
74 changes: 74 additions & 0 deletions engine/apps/zvonok/models/phone_call.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from django.db import models

from apps.phone_notifications.phone_provider import ProviderPhoneCall


class ZvonokCallStatuses:
"""
https://zvonok.com/ru-ru/guide/guide_statuses/
"""

ATTEMPTS_EXC = 10
COMPL_FINISHED = 20
COMPL_NOFINISHED = 30
DELETED = 40
DURATION_ERROR = 50
EXPIRES = 60
NOVALID_BUTTON = 70
NO_PROVIDER = 80
INTERRUPTED = 90
IN_PROCESS = 100
PINCODE_NOOK = 110
PINCODE_OK = 120
SYNTH_ERROR = 130
USER = 140

CHOICES = (
(ATTEMPTS_EXC, "attempts_exc"),
(COMPL_FINISHED, "compl_finished"),
(COMPL_NOFINISHED, "compl_nofinished"),
(DELETED, "deleted"),
(DURATION_ERROR, "duration_error"),
(EXPIRES, "expires"),
(NOVALID_BUTTON, "novalid_button"),
(NO_PROVIDER, "no_provider"),
(INTERRUPTED, "interrupted"),
(IN_PROCESS, "in_process"),
(PINCODE_NOOK, "pincode_nook"),
(SYNTH_ERROR, "synth_error"),
(USER, "user"),
)

DETERMINANT = {
"attempts_exc": ATTEMPTS_EXC,
"compl_finished": COMPL_FINISHED,
"deleted": DELETED,
"duration_error": DURATION_ERROR,
"expires": EXPIRES,
"novalid_button": NOVALID_BUTTON,
"no_provider": NO_PROVIDER,
"interrupted": INTERRUPTED,
"in_process": IN_PROCESS,
"pincode_nook": PINCODE_NOOK,
"synth_error": SYNTH_ERROR,
"user": USER,
}


class ZvonokPhoneCall(ProviderPhoneCall, models.Model):
created_at = models.DateTimeField(auto_now_add=True)

status = models.PositiveSmallIntegerField(
blank=True,
null=True,
choices=ZvonokCallStatuses.CHOICES,
)

call_id = models.CharField(
blank=True,
max_length=50,
)

campaign_id = models.CharField(
max_length=50,
)
151 changes: 151 additions & 0 deletions engine/apps/zvonok/phone_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import logging
from random import randint
from typing import Optional

import requests
from django.core.cache import cache

from apps.base.utils import live_settings
from apps.phone_notifications.exceptions import FailedToMakeCall, FailedToStartVerification
from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags
from apps.zvonok.models.phone_call import ZvonokCallStatuses, ZvonokPhoneCall

ZVONOK_CALL_URL = "https://zvonok.com/manager/cabapi_external/api/v1/phones/call/"

logger = logging.getLogger(__name__)


class ZvonokPhoneProvider(PhoneProvider):
"""
ZvonokPhoneProvider is an implementation of phone provider which supports only voice calls (zvonok.com).
API docs: https://api-docs.zvonok.com/ . Call status description: https://zvonok.com/ru-ru/guide/guide_statuses/
"""

def make_notification_call(self, number: str, message: str) -> ZvonokPhoneCall:
speaker = None
body = None

if live_settings.ZVONOK_AUDIO_ID:
message = f'<audio id="{live_settings.ZVONOK_AUDIO_ID}"/>'
else:
speaker = live_settings.ZVONOK_SPEAKER_ID

try:
response = self._call_create(number, message, speaker)
response.raise_for_status()
body = response.json()
if not body:
logger.error(f"ZvonokPhoneProvider.make_notification_call: failed, empty body")
raise FailedToMakeCall(graceful_msg=f"Failed make notification call to {number}, empty body")
call_id = body.get("call_id")

if not call_id:
logger.error(f"ZvonokPhoneProvider.make_notification_call: failed, missing call id")
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))

logger.info(f"ZvonokPhoneProvider.make_notification_call: success, call_id {call_id}")

return ZvonokPhoneCall(
status=ZvonokCallStatuses.IN_PROCESS,
call_id=call_id,
campaign_id=live_settings.ZVONOK_CAMPAIGN_ID,
)

except requests.exceptions.HTTPError as http_err:
logger.error(f"ZvonokPhoneProvider.make_notification_call: failed {http_err}")
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err:
logger.error(f"ZvonokPhoneProvider.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
speaker = live_settings.ZVONOK_SPEAKER_ID

try:
response = self._call_create(number, message, speaker)
response.raise_for_status()
body = response.json()
if not body:
logger.error(f"ZvonokPhoneProvider.make_call: failed, empty body")
raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}, empty body")

call_id = body.get("call_id")

if not call_id:
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
logger.info(f"ZvonokPhoneProvider.make_call: success, call_id {call_id}")

except requests.exceptions.HTTPError as http_err:
logger.error(f"ZvonokPhoneProvider.make_call: failed {http_err}")
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(body, number))
except (requests.exceptions.ConnectionError, requests.exceptions.JSONDecodeError, TypeError) as err:
logger.error(f"ZvonokPhoneProvider.make_call: failed {err}")
raise FailedToMakeCall(graceful_msg=f"Failed make call to {number}")

def _call_create(self, number: str, text: str, speaker: Optional[str] = None):
params = {
"public_key": live_settings.ZVONOK_API_KEY,
"campaign_id": live_settings.ZVONOK_CAMPAIGN_ID,
"phone": number,
"text": text,
}

if speaker:
params["speaker"] = speaker

return requests.post(ZVONOK_CALL_URL, params=params)

def _get_graceful_msg(self, body, number):
if body:
status = body.get("status")
data = body.get("data")
if status == "error" and data:
return f"Failed make call to {number} with error: {data}"
return f"Failed make call to {number}"

def make_verification_call(self, number: str):
code = str(randint(100000, 999999))
cache.set(self._cache_key(number), code, timeout=10 * 60)
codewspaces = " ".join(code)

body = None
speaker = live_settings.ZVONOK_SPEAKER_ID

try:
response = self._call_create(number, f"Your verification code is {codewspaces}", speaker)
response.raise_for_status()
body = response.json()
if not body:
logger.error(f"ZvonokPhoneProvider.make_verification_call: failed, empty body")
raise FailedToMakeCall(graceful_msg=f"Failed make verification call to {number}, empty body")

call_id = body.get("call_id")
if not call_id:
raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(body, number))
except requests.exceptions.HTTPError as http_err:
logger.error(f"ZvonokPhoneProvider.make_verification_call: 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"ZvonokPhoneProvider.make_verification_call: failed {err}")
raise FailedToStartVerification(graceful_msg=f"Failed make verification call to {number}")

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"zvonok_provider_{number}"

@property
def flags(self) -> ProviderFlags:
return ProviderFlags(
configured=True,
test_sms=False,
test_call=True,
verification_call=True,
verification_sms=False,
)
Loading

0 comments on commit aeb3500

Please sign in to comment.