oncall-engine/engine/apps/twilioapp/phone_provider.py
Innokentii Konstantinov 03ff4c5426
Listen for alert group actions during whole notification call (#5282)
1. Wrap whole message in twiml <Gather>  - that's an actual fix
2. Use twilio helper lib to build twiml queries
3. URLencode twimlquery only once before making a call to reduce code
duplication.

---------

Co-authored-by: Joey Orlando <joey.orlando@grafana.com>
2024-11-22 04:34:40 +00:00

326 lines
14 KiB
Python

import logging
import urllib.parse
from string import digits
from django.db.models import F, Q
from phonenumbers import COUNTRY_CODE_TO_REGION_CODE
from twilio.base.exceptions import TwilioRestException
from twilio.rest import Client
from twilio.twiml.voice_response import Gather, Say, VoiceResponse
from apps.base.models import LiveSetting
from apps.base.utils import live_settings
from apps.phone_notifications.exceptions import (
FailedToFinishVerification,
FailedToMakeCall,
FailedToSendSMS,
FailedToStartVerification,
)
from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags
from apps.twilioapp.gather import get_alert_group_gather_instructions, get_gather_url
from apps.twilioapp.models import (
TwilioCallStatuses,
TwilioPhoneCall,
TwilioPhoneCallSender,
TwilioSMS,
TwilioSmsSender,
TwilioVerificationSender,
)
from apps.twilioapp.status_callback import get_call_status_callback_url, get_sms_status_callback_url
logger = logging.getLogger(__name__)
class TwilioPhoneProvider(PhoneProvider):
def make_notification_call(self, number: str, message: str) -> TwilioPhoneCall | None:
message = self._escape_call_message(message)
twiml = self._message_to_twiml_gather(message)
response = None
try_without_callback = False
try:
response = self._call_create(twiml, number, with_callback=True)
except TwilioRestException as e:
# If status callback is not valid and not accessible from public url then trying to send message without it
# https://www.twilio.com/docs/api/errors/21609
if e.code == 21609:
logger.info("TwilioPhoneProvider.make_notification_call: error 21609, calling without callback_url")
try_without_callback = True
else:
logger.error(f"TwilioPhoneProvider.make_notification_call: failed {e}")
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(e, number))
if try_without_callback:
try:
response = self._call_create(twiml, number, with_callback=False)
except TwilioRestException as e:
logger.error(f"TwilioPhoneProvider.make_notification_call: failed {e}")
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(e, number))
if response and response.status and response.sid:
return TwilioPhoneCall(
status=TwilioCallStatuses.DETERMINANT.get(response.status, None),
sid=response.sid,
)
return None
def send_notification_sms(self, number: str, message: str) -> TwilioSMS | None:
try_without_callback = False
response = None
try:
response = self._messages_create(number, message, with_callback=True)
except TwilioRestException as e:
# If status callback is not valid and not accessible from public url then trying to send message without it
# https://www.twilio.com/docs/api/errors/21609
if e.code == 21609:
logger.info("TwilioPhoneProvider.send_notification_sms: error 21609, sending without callback_url")
try_without_callback = True
else:
logger.error(f"TwilioPhoneProvider.send_notification_sms: failed {e}")
raise FailedToSendSMS(graceful_msg=self._get_graceful_msg(e, number))
if try_without_callback:
try:
response = self._messages_create(number, message, with_callback=False)
except TwilioRestException as e:
logger.error(f"TwilioPhoneProvider.send_notification_sms: failed {e}")
raise FailedToSendSMS(graceful_msg=self._get_graceful_msg(e, number))
if response and response.status and response.sid:
return TwilioSMS(
status=TwilioCallStatuses.DETERMINANT.get(response.status, None),
sid=response.sid,
)
return None
def send_verification_sms(self, number: str):
self._send_verification_code(number, via="sms")
def finish_verification(self, number: str, code: str):
# I'm not sure if we need verification_and_parse via twilio pipeline here
# Verification code anyway is sent to not verified phone number.
# Just leaving it as it was before phone_provider refactoring.
normalized_number, _ = self._normalize_phone_number(number)
if normalized_number:
try:
client, verify_service_sid = self._verify_sender(number)
verification_check = client.verify.services(verify_service_sid).verification_checks.create(
to=normalized_number, code=code
)
logger.info(f"TwilioPhoneProvider.finish_verification: verification_status {verification_check.status}")
if verification_check.status == "approved":
return normalized_number
except TwilioRestException as e:
logger.error(f"TwilioPhoneProvider.finish_verification: failed to verify number {number}: {e}")
raise FailedToFinishVerification(graceful_msg=self._get_graceful_msg(e, number))
else:
return None
"""
Errors we will raise without graceful messages:
20404 - We should not be requesting missing resources
30808 - Unknown error, likely on the carrier side
30007, 32017 - Blocked or filtered, Intermediary / Carrier Analytics blocked call
due to poor reputation score on the telephone number:
* We need to register our number or sender with the analytics provider or carrier for that jurisdiction
"""
def _get_graceful_msg(self, e, number):
if e.code in (30003, 30005):
return f"Destination handset {number} is unreachable"
elif e.code == 30004:
return f"Sending message to {number} is blocked"
elif e.code == 30006:
return f"Cannot send to {number} is landline or unreachable carrier"
elif e.code == 30410:
return f"Provider for {number} is experiencing timeouts"
elif e.code == 60200:
return f"{number} is incorrectly formatted"
elif e.code in (21215, 60410, 60605):
return f"Verification to {number} is blocked"
elif e.code == 60203:
return f"Max verification attempts for {number} reached"
return None
def make_call(self, number: str, message: str):
twiml = self._message_to_twiml_say(message)
try:
self._call_create(twiml, number, with_callback=False)
except TwilioRestException as e:
logger.error(f"TwilioPhoneProvider.make_call: failed {e}")
raise FailedToMakeCall(graceful_msg=self._get_graceful_msg(e, number))
def send_sms(self, number: str, message: str):
try:
self._messages_create(number, message, with_callback=False)
except TwilioRestException as e:
logger.error(f"TwilioPhoneProvider.send_sms: failed {e}")
raise FailedToSendSMS(graceful_msg=self._get_graceful_msg(e, number))
def _message_to_twiml_say(self, message: str) -> VoiceResponse:
response = VoiceResponse()
say = Say(message)
response.append(say)
return response
def _message_to_twiml_gather(self, message: str) -> VoiceResponse:
response = VoiceResponse()
gather = Gather(action=get_gather_url(), method="POST", num_digits=1)
gather.say(message)
gather.pause(length=1)
gather.say(get_alert_group_gather_instructions())
response.append(gather)
return response
def _call_create(self, twiml: VoiceResponse, to: str, with_callback: bool):
client, from_ = self._phone_sender(to)
# encode twiml VoiceResponse to use in url
twiml_query = urllib.parse.quote(str(twiml), safe="")
url = "http://twimlets.com/echo?Twiml=" + twiml_query
if with_callback:
status_callback = get_call_status_callback_url()
status_callback_events = ["initiated", "ringing", "answered", "completed"]
return client.calls.create(
url=url,
to=to,
from_=from_,
method="GET",
status_callback=status_callback,
status_callback_event=status_callback_events,
status_callback_method="POST",
)
else:
return client.calls.create(
url=url,
to=to,
from_=from_,
method="GET",
)
def _messages_create(self, number: str, text: str, with_callback: bool):
client, from_ = self._sms_sender(number)
if with_callback:
status_callback = get_sms_status_callback_url()
return client.messages.create(body=text, to=number, from_=from_, status_callback=status_callback)
else:
return client.messages.create(
body=text,
to=number,
from_=from_,
)
def _send_verification_code(self, number: str, via: str):
# https://www.twilio.com/docs/verify/api/verification?code-sample=code-start-a-verification-with-sms&code-language=Python&code-sdk-version=6.x
try:
client, verify_service_sid = self._verify_sender(number)
verification = client.verify.services(verify_service_sid).verifications.create(to=number, channel=via)
logger.info(f"TwilioPhoneProvider._send_verification_code: verification status {verification.status}")
except TwilioRestException as e:
logger.error(f"Twilio verification start error: {e} to number {number}")
raise FailedToStartVerification(graceful_msg=self._get_graceful_msg(e, number))
def _normalize_phone_number(self, number: str):
# TODO: phone_provider: is it best place to parse phone number?
number = self._parse_phone_number(number)
# Verify and parse phone number with Twilio API
normalized_phone_number = None
country_code = None
if number != "" and number != "+":
try:
ok, normalized_phone_number, country_code = self._parse_number(number)
if normalized_phone_number == "":
normalized_phone_number = None
country_code = None
if not ok:
normalized_phone_number = None
country_code = None
except TypeError:
return None, None
return normalized_phone_number, country_code
# Use responsibly
def _parse_number(self, number: str):
try:
response = self._default_twilio_api_client.lookups.phone_numbers(number).fetch()
return True, response.phone_number, self._get_calling_code(response.country_code)
except TwilioRestException as e:
if e.code == 20404:
# Not sure, why 20404 (NotFound according to TwilioDocs) handled gracefully, leaving it as it is.
# https://www.twilio.com/docs/api/errors/20404"
return False, None, None
if e.code == 20003:
raise e
except KeyError as e:
# Don't know why KeyError is gracefully handled here, probably exception raised by twilio_client.
logger.info(f"twilio_client._parse_number: Gracefully handle KeyError: {e}")
return False, None, None
@property
def _default_twilio_api_client(self):
if live_settings.TWILIO_API_KEY_SID and live_settings.TWILIO_API_KEY_SECRET:
return Client(
live_settings.TWILIO_API_KEY_SID, live_settings.TWILIO_API_KEY_SECRET, live_settings.TWILIO_ACCOUNT_SID
)
else:
return Client(live_settings.TWILIO_ACCOUNT_SID, live_settings.TWILIO_AUTH_TOKEN)
@property
def _default_twilio_number(self):
return live_settings.TWILIO_NUMBER
def _twilio_sender(self, sender_model, to):
_, _, country_code = self._parse_number(to)
sender = (
sender_model.objects.filter(Q(country_code=country_code) | Q(country_code__isnull=True))
.order_by(F("country_code").desc(nulls_last=True))
.first()
)
if sender:
return sender.account.get_twilio_api_client(), sender
return self._default_twilio_api_client, None
def _sms_sender(self, to):
client, sender = self._twilio_sender(TwilioSmsSender, to)
return client, sender.sender if sender else self._default_twilio_number
def _phone_sender(self, to):
client, sender = self._twilio_sender(TwilioPhoneCallSender, to)
return client, sender.number if sender else self._default_twilio_number
def _verify_sender(self, to):
client, sender = self._twilio_sender(TwilioVerificationSender, to)
return client, sender.verify_service_sid if sender else live_settings.TWILIO_VERIFY_SERVICE_SID
def _get_calling_code(self, iso):
for code, isos in COUNTRY_CODE_TO_REGION_CODE.items():
if iso.upper() in isos:
return code
return None
def _escape_call_message(self, message):
# https://www.twilio.com/docs/api/errors/12100
message = message.replace("&", "&amp;")
message = message.replace(">", "&gt;")
message = message.replace("<", "&lt;")
return message
def _parse_phone_number(self, raw_phone_number):
return "+" + "".join(c for c in raw_phone_number if c in digits)
@property
def flags(self) -> ProviderFlags:
return ProviderFlags(
configured=not LiveSetting.objects.filter(name__startswith="TWILIO", error__isnull=False).exists(),
test_sms=False,
test_call=True,
verification_call=False,
verification_sms=True,
)