Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Map country code to different Twilio resources #1976

Merged
merged 16 commits into from
May 25, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Added

- Add models and framework to use different services (Phone, SMS, Verify) in Twilio depending on
the destination country code by @mderynck ([#1976](https://github.com/grafana/oncall/pull/1976))

### Changed

- Phone provider refactoring [#1713](https://github.com/grafana/oncall/pull/1713)
Expand Down
6 changes: 3 additions & 3 deletions engine/apps/base/tests/test_live_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,6 @@ def test_twilio_respects_changed_credentials(settings):
live_settings.TWILIO_AUTH_TOKEN = "new_twilio_auth_token"
live_settings.TWILIO_NUMBER = "new_twilio_number"

assert twilio_client._twilio_api_client.username == "new_twilio_account_sid"
assert twilio_client._twilio_api_client.password == "new_twilio_auth_token"
assert twilio_client._twilio_number == "new_twilio_number"
assert twilio_client._default_twilio_api_client.username == "new_twilio_account_sid"
assert twilio_client._default_twilio_api_client.password == "new_twilio_auth_token"
assert twilio_client._default_twilio_number == "new_twilio_number"
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Generated by Django 3.2.19 on 2023-05-24 19:30

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('twilioapp', '0004_twiliophonecall_twiliosms'),
]

operations = [
migrations.CreateModel(
name='TwilioAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('account_sid', models.CharField(max_length=64, unique=True)),
('auth_token', models.CharField(default=None, max_length=64, null=True)),
('api_key_sid', models.CharField(default=None, max_length=64, null=True)),
('api_key_secret', models.CharField(default=None, max_length=64, null=True)),
],
),
migrations.CreateModel(
name='TwilioSender',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='Default', max_length=100)),
('country_code', models.CharField(default=None, max_length=16, null=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='twilio_sender', to='twilioapp.twilioaccount')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_twilioapp.twiliosender_set+', to='contenttypes.contenttype')),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
),
migrations.CreateModel(
name='TwilioPhoneCallSender',
fields=[
('twiliosender_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='twilioapp.twiliosender')),
('number', models.CharField(max_length=16)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('twilioapp.twiliosender',),
),
migrations.CreateModel(
name='TwilioSmsSender',
fields=[
('twiliosender_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='twilioapp.twiliosender')),
('sender', models.CharField(max_length=16)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('twilioapp.twiliosender',),
),
migrations.CreateModel(
name='TwilioVerificationSender',
fields=[
('twiliosender_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='twilioapp.twiliosender')),
('verify_service_sid', models.CharField(max_length=64)),
],
options={
'abstract': False,
'base_manager_name': 'objects',
},
bases=('twilioapp.twiliosender',),
),
]
7 changes: 7 additions & 0 deletions engine/apps/twilioapp/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
from .twilio_log_record import TwilioLogRecord # noqa: F401
from .twilio_phone_call import TwilioCallStatuses, TwilioPhoneCall # noqa: F401
from .twilio_sender import ( # noqa: F401
TwilioAccount,
TwilioPhoneCallSender,
TwilioSender,
TwilioSmsSender,
TwilioVerificationSender,
)
from .twilio_sms import TwilioSMS, TwilioSMSstatuses # noqa: F401
37 changes: 37 additions & 0 deletions engine/apps/twilioapp/models/twilio_sender.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from django.db import models
from polymorphic.models import PolymorphicModel
from twilio.rest import Client


class TwilioAccount(models.Model):
name = models.CharField(max_length=100)
account_sid = models.CharField(max_length=64, null=False, blank=False, unique=True)
auth_token = models.CharField(max_length=64, null=True, default=None)
api_key_sid = models.CharField(max_length=64, null=True, default=None)
api_key_secret = models.CharField(max_length=64, null=True, default=None)

def get_twilio_api_client(self):
if self.api_key_sid and self.api_key_secret:
return Client(self.api_key_sid, self.api_key_secret, self.account_sid)
else:
return Client(self.account_sid, self.auth_token)


class TwilioSender(PolymorphicModel):
mderynck marked this conversation as resolved.
Show resolved Hide resolved
name = models.CharField(max_length=100, null=False, default="Default")
# Note: country_code does not have + prefix here
country_code = models.CharField(max_length=16, null=True, default=None)
account = models.ForeignKey("twilioapp.TwilioAccount", on_delete=models.CASCADE, related_name="twilio_sender")


class TwilioSmsSender(TwilioSender):
# Sender for sms is phone number, short code or alphanumeric id
sender = models.CharField(max_length=16, null=False, blank=False)


class TwilioPhoneCallSender(TwilioSender):
number = models.CharField(max_length=16, null=False, blank=False)


class TwilioVerificationSender(TwilioSender):
verify_service_sid = models.CharField(max_length=64, null=False, blank=False)
72 changes: 51 additions & 21 deletions engine/apps/twilioapp/phone_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import urllib.parse
from string import digits

from django.apps import apps
from django.db.models import Q
from phonenumbers import COUNTRY_CODE_TO_REGION_CODE
from twilio.base.exceptions import TwilioRestException
from twilio.rest import Client
Expand All @@ -17,6 +19,7 @@
from apps.phone_notifications.phone_provider import PhoneProvider, ProviderFlags
from apps.twilioapp.gather import get_gather_message, get_gather_url
from apps.twilioapp.models import TwilioCallStatuses, TwilioPhoneCall, TwilioSMS
from apps.twilioapp.models.twilio_sender import TwilioPhoneCallSender, TwilioSmsSender, TwilioVerificationSender
from apps.twilioapp.status_callback import get_call_status_callback_url, get_sms_status_callback_url

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -95,9 +98,10 @@ def finish_verification(self, number: str, code: str):
normalized_number, _ = self._normalize_phone_number(number)
if normalized_number:
try:
verification_check = self._twilio_api_client.verify.services(
live_settings.TWILIO_VERIFY_SERVICE_SID
).verification_checks.create(to=normalized_number, code=code)
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
Expand Down Expand Up @@ -133,46 +137,45 @@ def _message_to_twiml(self, message: str, with_gather=False):
)

def _call_create(self, twiml_query: str, to: str, with_callback: bool):
client, from_ = self._phone_sender(to)
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 self._twilio_api_client.calls.create(
return client.calls.create(
url=url,
to=to,
from_=self._twilio_number,
from_=from_,
method="GET",
status_callback=status_callback,
status_callback_event=status_callback_events,
status_callback_method="POST",
)
else:
return self._twilio_api_client.calls.create(
return client.calls.create(
url=url,
to=to,
from_=self._twilio_number,
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 self._twilio_api_client.messages.create(
body=text, to=number, from_=self._twilio_number, status_callback=status_callback
)
return client.messages.create(body=text, to=number, from_=from_, status_callback=status_callback)
else:
return self._twilio_api_client.messages.create(
return client.messages.create(
body=text,
to=number,
from_=self._twilio_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:
verification = self._twilio_api_client.verify.services(
live_settings.TWILIO_VERIFY_SERVICE_SID
).verifications.create(to=number, channel=via)
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}")
Expand Down Expand Up @@ -202,7 +205,7 @@ def _normalize_phone_number(self, number: str):
# Use responsibly
def _parse_number(self, number: str):
try:
response = self._twilio_api_client.lookups.phone_numbers(number).fetch()
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:
Expand All @@ -217,24 +220,51 @@ def _parse_number(self, number: str):
return False, None, None

@property
def _twilio_api_client(self):
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_type, to):
_, _, country_code = self._parse_number(to)
TwilioSender = apps.get_model("twilioapp", "TwilioSender")
senders = list(
TwilioSender.objects.instance_of(sender_type).filter(
Q(country_code=country_code) | Q(country_code__isnull=True)
)
)
senders.sort(key=lambda x: (not x.country_code, x))

if senders:
return senders[0].account.get_twilio_api_client(), senders[0]

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

@property
def _twilio_number(self):
return live_settings.TWILIO_NUMBER

def _escape_call_message(self, message):
# https://www.twilio.com/docs/api/errors/12100
message = message.replace("&", "&")
Expand Down
40 changes: 40 additions & 0 deletions engine/apps/twilioapp/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import pytest

from apps.twilioapp.tests.factories import (
TwilioAccountFactory,
TwilioPhoneCallSenderFactory,
TwilioSmsSenderFactory,
TwilioVerificationSenderFactory,
)


@pytest.fixture
def make_twilio_account():
def _make_twilio_account(**kwargs):
return TwilioAccountFactory(**kwargs)

return _make_twilio_account


@pytest.fixture
def make_twilio_phone_call_sender():
def _make_twilio_phone_call_sender(**kwargs):
return TwilioPhoneCallSenderFactory(**kwargs)

return _make_twilio_phone_call_sender


@pytest.fixture
def make_twilio_sms_sender():
def _make_twilio_sms_sender(**kwargs):
return TwilioSmsSenderFactory(**kwargs)

return _make_twilio_sms_sender


@pytest.fixture
def make_twilio_verification_sender():
def _make_twilio_verification_sender(**kwargs):
return TwilioVerificationSenderFactory(**kwargs)

return _make_twilio_verification_sender
28 changes: 28 additions & 0 deletions engine/apps/twilioapp/tests/factories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import factory

from apps.twilioapp.models.twilio_sender import (
TwilioAccount,
TwilioPhoneCallSender,
TwilioSmsSender,
TwilioVerificationSender,
)


class TwilioAccountFactory(factory.DjangoModelFactory):
class Meta:
model = TwilioAccount


class TwilioPhoneCallSenderFactory(factory.DjangoModelFactory):
class Meta:
model = TwilioPhoneCallSender


class TwilioSmsSenderFactory(factory.DjangoModelFactory):
class Meta:
model = TwilioSmsSender


class TwilioVerificationSenderFactory(factory.DjangoModelFactory):
class Meta:
model = TwilioVerificationSender
Loading