add zvonok integration (#2339)

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>
This commit is contained in:
Andrey Oleynik 2023-07-05 08:55:53 +03:00 committed by GitHub
parent 5cc9d5441f
commit aeb35009be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 461 additions and 2 deletions

View file

@ -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)

View file

@ -207,7 +207,9 @@ 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:
@ -215,6 +217,30 @@ 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

View file

@ -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 = {
@ -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 = (
@ -163,6 +181,7 @@ class LiveSetting(models.Model):
"SLACK_SIGNING_SECRET",
"TELEGRAM_TOKEN",
"GRAFANA_CLOUD_ONCALL_TOKEN",
"ZVONOK_API_KEY",
)
def __str__(self):

View file

View file

@ -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,
},
),
]

View file

@ -0,0 +1 @@
from .phone_call import ZvonokCallStatuses, ZvonokPhoneCall # noqa: F401

View file

@ -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,
)

View file

@ -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,
)

View file

@ -0,0 +1,86 @@
import logging
from typing import Optional
from django.apps import apps
from apps.alerts.constants import ActionSource
from apps.alerts.signals import user_notification_action_triggered_signal
from apps.base.utils import live_settings
from apps.zvonok.models.phone_call import ZvonokCallStatuses, ZvonokPhoneCall
logger = logging.getLogger(__name__)
def update_zvonok_call_status(call_id: str, call_status: str, user_choice: Optional[str] = None):
UserNotificationPolicyLogRecord = apps.get_model("base", "UserNotificationPolicyLogRecord")
status_code = ZvonokCallStatuses.DETERMINANT.get(call_status)
if status_code is None:
logger.warning(f"zvonok.update_zvonok_call_status: unexpected status call_id={call_id} status={call_status}")
return
zvonok_phone_call = ZvonokPhoneCall.objects.filter(call_id=call_id).first()
if zvonok_phone_call is None:
logger.warning(f"zvonok.update_zvonok_call_status: zvonok_phone_call not found call_id={call_id}")
return
logger.info(f"zvonok.update_zvonok_call_status: found zvonok_phone_call call_id={call_id}")
zvonok_phone_call.status = status_code
zvonok_phone_call.save(update_fields=["status"])
phone_call_record = zvonok_phone_call.phone_call_record
if phone_call_record is None:
logger.warning(
f"zvonok.update_zvonok_call_status: zvonok_phone_call has no phone_call record call_id={call_id} "
f"status={call_status}"
)
return
logger.info(
f"zvonok.update_zvonok_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 = [ZvonokCallStatuses.USER, ZvonokCallStatuses.COMPL_FINISHED]
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"zvonok.update_zvonok_call_status: created log_record log_record_id={log_record.id} "
f"type={log_record_type}"
)
user_notification_action_triggered_signal.send(sender=update_zvonok_call_status, log_record=log_record)
if user_choice and user_choice == live_settings.ZVONOK_POSTBACK_USER_CHOICE_ACK:
alert_group = phone_call_record.represents_alert_group
user = phone_call_record.receiver
logger.info(
f"zvonok.update_zvonok_call_status: processing user choice"
f" phone_call_record id={phone_call_record.id} zvonok_phone_call_id={call_id} "
f"alert_group_id={alert_group.id} user_id={user.id}"
)
alert_group.acknowledge_by_user(user, action_source=ActionSource.PHONE)

View file

@ -0,0 +1,7 @@
from django.urls import path
from .views import CallStatusCallback
urlpatterns = [
path("call_status_events", CallStatusCallback.as_view(), name="call_status_events"),
]

View file

@ -0,0 +1,52 @@
from django.apps import apps
from rest_framework import status
from rest_framework.permissions import BasePermission
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.base.utils import live_settings
from .status_callback import update_zvonok_call_status
class AllowOnlyZvonok(BasePermission):
def has_permission(self, request, view):
call_id = request.GET.get(live_settings.ZVONOK_POSTBACK_CALL_ID)
if not call_id:
return False
campaign_id = request.GET.get(live_settings.ZVONOK_POSTBACK_CAMPAIGN_ID)
if not campaign_id:
return False
if campaign_id != live_settings.ZVONOK_CAMPAIGN_ID:
return False
ZvonokCall = apps.get_model("zvonok", "ZvonokPhoneCall")
call = ZvonokCall.objects.filter(call_id=call_id, campaign_id=campaign_id).first()
if call:
return self.validate_request(request)
return False
def validate_request(self, request):
if request.GET.get(live_settings.ZVONOK_POSTBACK_STATUS):
return True
return False
# Receive Call Status from Zvonok
class CallStatusCallback(APIView):
permission_classes = [AllowOnlyZvonok]
def get(self, request):
self._handle_call_status(request)
return Response(data="", status=status.HTTP_204_NO_CONTENT)
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.GET.get(live_settings.ZVONOK_POSTBACK_CALL_ID)
call_status = request.GET.get(live_settings.ZVONOK_POSTBACK_STATUS)
user_choice = request.GET.get(live_settings.ZVONOK_POSTBACK_USER_CHOICE)
update_zvonok_call_status(call_id=call_id, call_status=call_status, user_choice=user_choice)

View file

@ -65,6 +65,7 @@ if settings.FEATURE_SLACK_INTEGRATION_ENABLED:
if settings.IS_OPEN_SOURCE:
urlpatterns += [
path("api/internal/v1/", include("apps.oss_installation.urls", namespace="oss_installation")),
path("zvonok/", include("apps.zvonok.urls")),
]
if settings.DEBUG:

View file

@ -682,7 +682,7 @@ INSTALLED_ONCALL_INTEGRATIONS = [
]
if IS_OPEN_SOURCE:
INSTALLED_APPS += ["apps.oss_installation"] # noqa
INSTALLED_APPS += ["apps.oss_installation", "apps.zvonok"] # noqa
CELERY_BEAT_SCHEDULE["send_usage_stats"] = { # noqa
"task": "apps.oss_installation.tasks.send_usage_stats_report",
@ -724,5 +724,16 @@ PYROSCOPE_AUTH_TOKEN = os.getenv("PYROSCOPE_AUTH_TOKEN", "")
PHONE_PROVIDERS = {
"twilio": "apps.twilioapp.phone_provider.TwilioPhoneProvider",
# "simple": "apps.phone_notifications.simple_phone_provider.SimplePhoneProvider",
"zvonok": "apps.zvonok.phone_provider.ZvonokPhoneProvider",
}
PHONE_PROVIDER = os.environ.get("PHONE_PROVIDER", default="twilio")
ZVONOK_API_KEY = os.getenv("ZVONOK_API_KEY", None)
ZVONOK_CAMPAIGN_ID = os.getenv("ZVONOK_CAMPAIGN_ID", None)
ZVONOK_AUDIO_ID = os.getenv("ZVONOK_AUDIO_ID", None)
ZVONOK_SPEAKER_ID = os.getenv("ZVONOK_SPEAKER_ID", "Salli")
ZVONOK_POSTBACK_CALL_ID = os.getenv("ZVONOK_POSTBACK_CALL_ID", "call_id")
ZVONOK_POSTBACK_CAMPAIGN_ID = os.getenv("ZVONOK_POSTBACK_CAMPAIGN_ID", "campaign_id")
ZVONOK_POSTBACK_STATUS = os.getenv("ZVONOK_POSTBACK_STATUS", "status")
ZVONOK_POSTBACK_USER_CHOICE = os.getenv("ZVONOK_POSTBACK_USER_CHOICE", None)
ZVONOK_POSTBACK_USER_CHOICE_ACK = os.getenv("ZVONOK_POSTBACK_USER_CHOICE_ACK", None)