Skip to content

Commit ae7561a

Browse files
authored
Map country code to different Twilio resources (#1976)
Many countries are introducing different requirements for SMS senders to register and/or use alpha numeric ids, short codes or regional numbers or face being blocked. The changes in this PR will give us more flexibility by allowing us to map to different resources in Twilio based on the phone number we are trying to reach. For this first implementation the selection is made based on country code of the recipient. Verification and phone calls were given the same treatment although the immediate need is for SMS. Senders with no country code set can be used as catch-all defaults. This also falls back to the configured live settings/environment variables if not configured. Possible future additions: - Move through list of trying multiple senders before failing notification - Easily expanded to allow per-organization or per-user resources to let users and tenants configure their own Twilio - Add UI + replace live settings so users can configure their own settings - More selection criteria if needed TODO: - [x] Add+Fix Tests - [x] Verify changes are compatible with #1713
1 parent 7f9717f commit ae7561a

9 files changed

+384
-24
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Add models and framework to use different services (Phone, SMS, Verify) in Twilio depending on
13+
the destination country code by @mderynck ([#1976](https://github.com/grafana/oncall/pull/1976))
1214
- Prometheus exporter backend for alert groups related metrics
1315

1416
### Fixed

engine/apps/base/tests/test_live_settings.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,6 @@ def test_twilio_respects_changed_credentials(settings):
6767
live_settings.TWILIO_AUTH_TOKEN = "new_twilio_auth_token"
6868
live_settings.TWILIO_NUMBER = "new_twilio_number"
6969

70-
assert twilio_client._twilio_api_client.username == "new_twilio_account_sid"
71-
assert twilio_client._twilio_api_client.password == "new_twilio_auth_token"
72-
assert twilio_client._twilio_number == "new_twilio_number"
70+
assert twilio_client._default_twilio_api_client.username == "new_twilio_account_sid"
71+
assert twilio_client._default_twilio_api_client.password == "new_twilio_auth_token"
72+
assert twilio_client._default_twilio_number == "new_twilio_number"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Generated by Django 3.2.19 on 2023-05-25 15:32
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('twilioapp', '0004_twiliophonecall_twiliosms'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='TwilioAccount',
16+
fields=[
17+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('name', models.CharField(max_length=100)),
19+
('account_sid', models.CharField(max_length=64, unique=True)),
20+
('auth_token', models.CharField(default=None, max_length=64, null=True)),
21+
('api_key_sid', models.CharField(default=None, max_length=64, null=True)),
22+
('api_key_secret', models.CharField(default=None, max_length=64, null=True)),
23+
],
24+
),
25+
migrations.CreateModel(
26+
name='TwilioVerificationSender',
27+
fields=[
28+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
29+
('name', models.CharField(default='Default', max_length=100)),
30+
('country_code', models.CharField(default=None, max_length=16, null=True)),
31+
('verify_service_sid', models.CharField(max_length=64)),
32+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='twilioapp_twilioverificationsender_account', to='twilioapp.twilioaccount')),
33+
],
34+
options={
35+
'abstract': False,
36+
},
37+
),
38+
migrations.CreateModel(
39+
name='TwilioSmsSender',
40+
fields=[
41+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
42+
('name', models.CharField(default='Default', max_length=100)),
43+
('country_code', models.CharField(default=None, max_length=16, null=True)),
44+
('sender', models.CharField(max_length=16)),
45+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='twilioapp_twiliosmssender_account', to='twilioapp.twilioaccount')),
46+
],
47+
options={
48+
'abstract': False,
49+
},
50+
),
51+
migrations.CreateModel(
52+
name='TwilioPhoneCallSender',
53+
fields=[
54+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
55+
('name', models.CharField(default='Default', max_length=100)),
56+
('country_code', models.CharField(default=None, max_length=16, null=True)),
57+
('number', models.CharField(max_length=16)),
58+
('account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='twilioapp_twiliophonecallsender_account', to='twilioapp.twilioaccount')),
59+
],
60+
options={
61+
'abstract': False,
62+
},
63+
),
64+
]
+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
from .twilio_log_record import TwilioLogRecord # noqa: F401
22
from .twilio_phone_call import TwilioCallStatuses, TwilioPhoneCall # noqa: F401
3+
from .twilio_sender import ( # noqa: F401
4+
TwilioAccount,
5+
TwilioPhoneCallSender,
6+
TwilioSender,
7+
TwilioSmsSender,
8+
TwilioVerificationSender,
9+
)
310
from .twilio_sms import TwilioSMS, TwilioSMSstatuses # noqa: F401
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from django.db import models
2+
from twilio.rest import Client
3+
4+
5+
class TwilioAccount(models.Model):
6+
name = models.CharField(max_length=100)
7+
account_sid = models.CharField(max_length=64, null=False, blank=False, unique=True)
8+
auth_token = models.CharField(max_length=64, null=True, default=None)
9+
api_key_sid = models.CharField(max_length=64, null=True, default=None)
10+
api_key_secret = models.CharField(max_length=64, null=True, default=None)
11+
12+
def get_twilio_api_client(self):
13+
if self.api_key_sid and self.api_key_secret:
14+
return Client(self.api_key_sid, self.api_key_secret, self.account_sid)
15+
else:
16+
return Client(self.account_sid, self.auth_token)
17+
18+
19+
class TwilioSender(models.Model):
20+
name = models.CharField(max_length=100, null=False, default="Default")
21+
# Note: country_code does not have + prefix here
22+
country_code = models.CharField(max_length=16, null=True, default=None)
23+
account = models.ForeignKey(
24+
"twilioapp.TwilioAccount", on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s_account"
25+
)
26+
27+
class Meta:
28+
abstract = True
29+
30+
31+
class TwilioSmsSender(TwilioSender):
32+
# Sender for sms is phone number, short code or alphanumeric id
33+
sender = models.CharField(max_length=16, null=False, blank=False)
34+
35+
36+
class TwilioPhoneCallSender(TwilioSender):
37+
number = models.CharField(max_length=16, null=False, blank=False)
38+
39+
40+
class TwilioVerificationSender(TwilioSender):
41+
verify_service_sid = models.CharField(max_length=64, null=False, blank=False)

engine/apps/twilioapp/phone_provider.py

+49-21
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import urllib.parse
33
from string import digits
44

5+
from django.apps import apps
6+
from django.db.models import F, Q
57
from phonenumbers import COUNTRY_CODE_TO_REGION_CODE
68
from twilio.base.exceptions import TwilioRestException
79
from twilio.rest import Client
@@ -95,9 +97,10 @@ def finish_verification(self, number: str, code: str):
9597
normalized_number, _ = self._normalize_phone_number(number)
9698
if normalized_number:
9799
try:
98-
verification_check = self._twilio_api_client.verify.services(
99-
live_settings.TWILIO_VERIFY_SERVICE_SID
100-
).verification_checks.create(to=normalized_number, code=code)
100+
client, verify_service_sid = self._verify_sender(number)
101+
verification_check = client.verify.services(verify_service_sid).verification_checks.create(
102+
to=normalized_number, code=code
103+
)
101104
logger.info(f"TwilioPhoneProvider.finish_verification: verification_status {verification_check.status}")
102105
if verification_check.status == "approved":
103106
return normalized_number
@@ -133,46 +136,45 @@ def _message_to_twiml(self, message: str, with_gather=False):
133136
)
134137

135138
def _call_create(self, twiml_query: str, to: str, with_callback: bool):
139+
client, from_ = self._phone_sender(to)
136140
url = "http://twimlets.com/echo?Twiml=" + twiml_query
137141
if with_callback:
138142
status_callback = get_call_status_callback_url()
139143
status_callback_events = ["initiated", "ringing", "answered", "completed"]
140-
return self._twilio_api_client.calls.create(
144+
return client.calls.create(
141145
url=url,
142146
to=to,
143-
from_=self._twilio_number,
147+
from_=from_,
144148
method="GET",
145149
status_callback=status_callback,
146150
status_callback_event=status_callback_events,
147151
status_callback_method="POST",
148152
)
149153
else:
150-
return self._twilio_api_client.calls.create(
154+
return client.calls.create(
151155
url=url,
152156
to=to,
153-
from_=self._twilio_number,
157+
from_=from_,
154158
method="GET",
155159
)
156160

157161
def _messages_create(self, number: str, text: str, with_callback: bool):
162+
client, from_ = self._sms_sender(number)
158163
if with_callback:
159164
status_callback = get_sms_status_callback_url()
160-
return self._twilio_api_client.messages.create(
161-
body=text, to=number, from_=self._twilio_number, status_callback=status_callback
162-
)
165+
return client.messages.create(body=text, to=number, from_=from_, status_callback=status_callback)
163166
else:
164-
return self._twilio_api_client.messages.create(
167+
return client.messages.create(
165168
body=text,
166169
to=number,
167-
from_=self._twilio_number,
170+
from_=from_,
168171
)
169172

170173
def _send_verification_code(self, number: str, via: str):
171174
# https://www.twilio.com/docs/verify/api/verification?code-sample=code-start-a-verification-with-sms&code-language=Python&code-sdk-version=6.x
172175
try:
173-
verification = self._twilio_api_client.verify.services(
174-
live_settings.TWILIO_VERIFY_SERVICE_SID
175-
).verifications.create(to=number, channel=via)
176+
client, verify_service_sid = self._verify_sender(number)
177+
verification = client.verify.services(verify_service_sid).verifications.create(to=number, channel=via)
176178
logger.info(f"TwilioPhoneProvider._send_verification_code: verification status {verification.status}")
177179
except TwilioRestException as e:
178180
logger.error(f"Twilio verification start error: {e} to number {number}")
@@ -202,7 +204,7 @@ def _normalize_phone_number(self, number: str):
202204
# Use responsibly
203205
def _parse_number(self, number: str):
204206
try:
205-
response = self._twilio_api_client.lookups.phone_numbers(number).fetch()
207+
response = self._default_twilio_api_client.lookups.phone_numbers(number).fetch()
206208
return True, response.phone_number, self._get_calling_code(response.country_code)
207209
except TwilioRestException as e:
208210
if e.code == 20404:
@@ -217,24 +219,50 @@ def _parse_number(self, number: str):
217219
return False, None, None
218220

219221
@property
220-
def _twilio_api_client(self):
222+
def _default_twilio_api_client(self):
221223
if live_settings.TWILIO_API_KEY_SID and live_settings.TWILIO_API_KEY_SECRET:
222224
return Client(
223225
live_settings.TWILIO_API_KEY_SID, live_settings.TWILIO_API_KEY_SECRET, live_settings.TWILIO_ACCOUNT_SID
224226
)
225227
else:
226228
return Client(live_settings.TWILIO_ACCOUNT_SID, live_settings.TWILIO_AUTH_TOKEN)
227229

230+
@property
231+
def _default_twilio_number(self):
232+
return live_settings.TWILIO_NUMBER
233+
234+
def _twilio_sender(self, sender_type, to):
235+
_, _, country_code = self._parse_number(to)
236+
TwilioSender = apps.get_model("twilioapp", sender_type)
237+
sender = (
238+
TwilioSender.objects.filter(Q(country_code=country_code) | Q(country_code__isnull=True))
239+
.order_by(F("country_code").desc(nulls_last=True))
240+
.first()
241+
)
242+
243+
if sender:
244+
return sender.account.get_twilio_api_client(), sender
245+
246+
return self._default_twilio_api_client, None
247+
248+
def _sms_sender(self, to):
249+
client, sender = self._twilio_sender("TwilioSmsSender", to)
250+
return client, sender.sender if sender else self._default_twilio_number
251+
252+
def _phone_sender(self, to):
253+
client, sender = self._twilio_sender("TwilioPhoneCallSender", to)
254+
return client, sender.number if sender else self._default_twilio_number
255+
256+
def _verify_sender(self, to):
257+
client, sender = self._twilio_sender("TwilioVerificationSender", to)
258+
return client, sender.verify_service_sid if sender else live_settings.TWILIO_VERIFY_SERVICE_SID
259+
228260
def _get_calling_code(self, iso):
229261
for code, isos in COUNTRY_CODE_TO_REGION_CODE.items():
230262
if iso.upper() in isos:
231263
return code
232264
return None
233265

234-
@property
235-
def _twilio_number(self):
236-
return live_settings.TWILIO_NUMBER
237-
238266
def _escape_call_message(self, message):
239267
# https://www.twilio.com/docs/api/errors/12100
240268
message = message.replace("&", "&")
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import pytest
2+
3+
from apps.twilioapp.tests.factories import (
4+
TwilioAccountFactory,
5+
TwilioPhoneCallSenderFactory,
6+
TwilioSmsSenderFactory,
7+
TwilioVerificationSenderFactory,
8+
)
9+
10+
11+
@pytest.fixture
12+
def make_twilio_account():
13+
def _make_twilio_account(**kwargs):
14+
return TwilioAccountFactory(**kwargs)
15+
16+
return _make_twilio_account
17+
18+
19+
@pytest.fixture
20+
def make_twilio_phone_call_sender():
21+
def _make_twilio_phone_call_sender(**kwargs):
22+
return TwilioPhoneCallSenderFactory(**kwargs)
23+
24+
return _make_twilio_phone_call_sender
25+
26+
27+
@pytest.fixture
28+
def make_twilio_sms_sender():
29+
def _make_twilio_sms_sender(**kwargs):
30+
return TwilioSmsSenderFactory(**kwargs)
31+
32+
return _make_twilio_sms_sender
33+
34+
35+
@pytest.fixture
36+
def make_twilio_verification_sender():
37+
def _make_twilio_verification_sender(**kwargs):
38+
return TwilioVerificationSenderFactory(**kwargs)
39+
40+
return _make_twilio_verification_sender
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import factory
2+
3+
from apps.twilioapp.models.twilio_sender import (
4+
TwilioAccount,
5+
TwilioPhoneCallSender,
6+
TwilioSmsSender,
7+
TwilioVerificationSender,
8+
)
9+
10+
11+
class TwilioAccountFactory(factory.DjangoModelFactory):
12+
class Meta:
13+
model = TwilioAccount
14+
15+
16+
class TwilioPhoneCallSenderFactory(factory.DjangoModelFactory):
17+
class Meta:
18+
model = TwilioPhoneCallSender
19+
20+
21+
class TwilioSmsSenderFactory(factory.DjangoModelFactory):
22+
class Meta:
23+
model = TwilioSmsSender
24+
25+
26+
class TwilioVerificationSenderFactory(factory.DjangoModelFactory):
27+
class Meta:
28+
model = TwilioVerificationSender

0 commit comments

Comments
 (0)