• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 3f809551-6712-425b-b278-bf1cf7d34ed4

19 Sep 2025 06:01PM UTC coverage: 88.138% (-0.7%) from 88.863%
3f809551-6712-425b-b278-bf1cf7d34ed4

Pull #5885

circleci

joeherm
fix(twilio): Add error handling for flaky Twilio calls
Pull Request #5885: fix(twilio): Add error handling for erroring Twilio calls

2925 of 3955 branches covered (73.96%)

Branch coverage included in aggregate %.

42 of 42 new or added lines in 2 files covered. (100.0%)

116 existing lines in 7 files now uncovered.

18199 of 20012 relevant lines covered (90.94%)

11.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.62
/phones/models.py
1
from __future__ import annotations
1✔
2

3
import logging
1✔
4
import secrets
1✔
5
import string
1✔
6
from collections.abc import Iterator
1✔
7
from datetime import UTC, datetime, timedelta
1✔
8
from math import floor
1✔
9

10
from django.conf import settings
1✔
11
from django.contrib.auth.models import User
1✔
12
from django.core.cache import cache
1✔
13
from django.core.exceptions import BadRequest, ValidationError
1✔
14
from django.db import models
1✔
15
from django.db.migrations.recorder import MigrationRecorder
1✔
16
from django.db.models.signals import post_save
1✔
17
from django.dispatch.dispatcher import receiver
1✔
18
from django.urls import reverse
1✔
19

20
import phonenumbers
1✔
21
from twilio.base.exceptions import TwilioException, TwilioRestException
1✔
22
from twilio.rest import Client
1✔
23

24
from emails.utils import incr_if_enabled
1✔
25

26
from .apps import phones_config, twilio_client
1✔
27
from .iq_utils import send_iq_sms
1✔
28

29
logger = logging.getLogger("eventsinfo")
1✔
30
events_logger = logging.getLogger("events")
1✔
31

32

33
MAX_MINUTES_TO_VERIFY_REAL_PHONE = 5
1✔
34
LAST_CONTACT_TYPE_CHOICES = [
1✔
35
    ("call", "call"),
36
    ("text", "text"),
37
]
38
DEFAULT_REGION = "US"
1✔
39

40

41
def verification_code_default():
1✔
42
    return str(secrets.randbelow(1000000)).zfill(6)
1✔
43

44

45
def verification_sent_date_default():
1✔
46
    return datetime.now(UTC)
1✔
47

48

49
def get_last_text_sender(relay_number: RelayNumber) -> InboundContact | None:
1✔
50
    """
51
    Get the last text sender.
52

53
    MPP-2581 introduces a last_text_date column for determining the last sender.
54
    Before MPP-2581, the last_inbound_date with last_inbound_type=text was used.
55
    During the transition, look at both methods.
56
    """
57
    try:
1✔
58
        latest = InboundContact.objects.filter(
1✔
59
            relay_number=relay_number, last_text_date__isnull=False
60
        ).latest("last_text_date")
61
    except InboundContact.DoesNotExist:
1✔
62
        latest = None
1✔
63

64
    try:
1✔
65
        latest_by_old_method = InboundContact.objects.filter(
1✔
66
            relay_number=relay_number, last_inbound_type="text"
67
        ).latest("last_inbound_date")
68
    except InboundContact.DoesNotExist:
1✔
69
        latest_by_old_method = None
1✔
70

71
    if (latest is None and latest_by_old_method is not None) or (
1✔
72
        latest
73
        and latest_by_old_method
74
        and latest != latest_by_old_method
75
        and latest.last_text_date
76
        and latest_by_old_method.last_inbound_date > latest.last_text_date
77
    ):
78
        # Pre-MPP-2581 server handled the latest text message
79
        return latest_by_old_method
1✔
80

81
    return latest
1✔
82

83

84
def iq_fmt(e164_number: str) -> str:
1✔
UNCOV
85
    return "1" + str(phonenumbers.parse(e164_number, "E164").national_number)
×
86

87

88
class VerifiedRealPhoneManager(models.Manager["RealPhone"]):
1✔
89
    """Return verified RealPhone records."""
90

91
    def get_queryset(self) -> models.query.QuerySet[RealPhone]:
1✔
92
        return super().get_queryset().filter(verified=True)
1✔
93

94
    def get_for_user(self, user: User) -> RealPhone:
1✔
95
        """Get the one verified RealPhone for the user, or raise DoesNotExist."""
96
        return self.get(user=user)
1✔
97

98
    def exists_for_number(self, number: str) -> bool:
1✔
99
        """Return True if a verified RealPhone exists for this number."""
100
        return self.filter(number=number).exists()
1✔
101

102
    def country_code_for_user(self, user: User) -> str:
1✔
103
        """Return the RealPhone country code for this user."""
104
        return self.values_list("country_code", flat=True).get(user=user)
1✔
105

106

107
class ExpiredRealPhoneManager(models.Manager["RealPhone"]):
1✔
108
    """Return RealPhone records where the sent verification is no longer valid."""
109

110
    def get_queryset(self) -> models.query.QuerySet[RealPhone]:
1✔
111
        return (
1✔
112
            super()
113
            .get_queryset()
114
            .filter(
115
                verified=False,
116
                verification_sent_date__lt=RealPhone.verification_expiration(),
117
            )
118
        )
119

120
    def delete_for_number(self, number: str) -> tuple[int, dict[str, int]]:
1✔
121
        return self.filter(number=number).delete()
1✔
122

123

124
class RecentRealPhoneManager(models.Manager["RealPhone"]):
1✔
125
    """Return RealPhone records where the sent verification is still valid."""
126

127
    def get_queryset(self) -> models.query.QuerySet[RealPhone]:
1✔
128
        return (
1✔
129
            super()
130
            .get_queryset()
131
            .filter(
132
                verified=False,
133
                verification_sent_date__gte=RealPhone.verification_expiration(),
134
            )
135
        )
136

137
    def get_for_user_number_and_verification_code(
1✔
138
        self, user: User, number: str, verification_code: str
139
    ) -> RealPhone:
140
        """Get the RealPhone with this user, number, and recently sent code, or raise"""
141
        return self.get(user=user, number=number, verification_code=verification_code)
1✔
142

143

144
class PendingRealPhoneManager(RecentRealPhoneManager):
1✔
145
    """Return unverified RealPhone records where verification is still valid."""
146

147
    def get_queryset(self) -> models.query.QuerySet[RealPhone]:
1✔
148
        return super().get_queryset().filter(verified=False)
1✔
149

150
    def exists_for_number(self, number: str) -> bool:
1✔
151
        """Return True if a verified RealPhone exists for this number."""
152
        return self.filter(number=number).exists()
1✔
153

154

155
class RealPhone(models.Model):
1✔
156
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
157
    number = models.CharField(max_length=15)
1✔
158
    verification_code = models.CharField(
1✔
159
        max_length=8, default=verification_code_default
160
    )
161
    verification_sent_date = models.DateTimeField(
1✔
162
        blank=True, null=True, default=verification_sent_date_default
163
    )
164
    verified = models.BooleanField(default=False)
1✔
165
    verified_date = models.DateTimeField(blank=True, null=True)
1✔
166
    country_code = models.CharField(max_length=2, default=DEFAULT_REGION)
1✔
167

168
    objects = models.Manager()
1✔
169
    verified_objects = VerifiedRealPhoneManager()
1✔
170
    expired_objects = ExpiredRealPhoneManager()
1✔
171
    recent_objects = RecentRealPhoneManager()
1✔
172
    pending_objects = PendingRealPhoneManager()
1✔
173

174
    class Meta:
1✔
175
        constraints = [
1✔
176
            models.UniqueConstraint(
177
                fields=["number", "verified"],
178
                condition=models.Q(verified=True),
179
                name="unique_verified_number",
180
            )
181
        ]
182

183
    @classmethod
1✔
184
    def verification_expiration(self) -> datetime:
1✔
185
        return datetime.now(UTC) - timedelta(
1✔
186
            0, 60 * settings.MAX_MINUTES_TO_VERIFY_REAL_PHONE
187
        )
188

189
    def save(self, *args, **kwargs):
1✔
190
        # delete any expired unverified RealPhone records for this number
191
        # note: it doesn't matter which user is trying to create a new
192
        # RealPhone record - any expired unverified record for the number
193
        # should be deleted
194
        RealPhone.expired_objects.delete_for_number(self.number)
1✔
195

196
        # We are not ready to support multiple real phone numbers per user,
197
        # so raise an exception if this save() would create a second
198
        # RealPhone record for the user
199
        try:
1✔
200
            verified_number = RealPhone.verified_objects.get_for_user(self.user)
1✔
201
            if not (
1✔
202
                verified_number.number == self.number
203
                and verified_number.verification_code == self.verification_code
204
            ):
205
                raise BadRequest("User already has a verified number.")
1✔
206
        except RealPhone.DoesNotExist:
1✔
207
            pass
1✔
208

209
        # call super save to save into the DB
210
        # See also: realphone_post_save receiver below
211
        return super().save(*args, **kwargs)
1✔
212

213
    def mark_verified(self):
1✔
214
        incr_if_enabled("phones_RealPhone.mark_verified")
1✔
215
        self.verified = True
1✔
216
        self.verified_date = datetime.now(UTC)
1✔
217
        self.save(force_update=True)
1✔
218
        return self
1✔
219

220

221
@receiver(post_save, sender=RealPhone, dispatch_uid="realphone_post_save")
1✔
222
def realphone_post_save(sender, instance, created, **kwargs):
1✔
223
    # don't do anything if running migrations
224
    if isinstance(instance, MigrationRecorder.Migration):
1!
UNCOV
225
        return
×
226

227
    if created:
1✔
228
        # only send verification_code when creating new record
229
        incr_if_enabled("phones_RealPhone.post_save_created_send_verification")
1✔
230
        text_body = (
1✔
231
            f"Your Firefox Relay verification code is {instance.verification_code}"
232
        )
233
        if settings.PHONES_NO_CLIENT_CALLS_IN_TEST:
1✔
234
            return
1✔
235
        if settings.IQ_FOR_VERIFICATION:
1!
UNCOV
236
            send_iq_sms(instance.number, settings.IQ_MAIN_NUMBER, text_body)
×
UNCOV
237
            return
×
238
        client = twilio_client()
1✔
239
        client.messages.create(
1✔
240
            body=text_body,
241
            from_=settings.TWILIO_MAIN_NUMBER,
242
            to=instance.number,
243
        )
244

245

246
def vcard_lookup_key_default():
1✔
247
    return "".join(
1✔
248
        secrets.choice(string.ascii_letters + string.digits) for i in range(6)
249
    )
250

251

252
class RelayNumber(models.Model):
1✔
253
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
254
    number = models.CharField(max_length=15, db_index=True, unique=True)
1✔
255
    vendor = models.CharField(max_length=15, default="twilio")
1✔
256
    location = models.CharField(max_length=255)
1✔
257
    country_code = models.CharField(max_length=2, default=DEFAULT_REGION)
1✔
258
    vcard_lookup_key = models.CharField(
1✔
259
        max_length=6, default=vcard_lookup_key_default, unique=True
260
    )
261
    enabled = models.BooleanField(default=True)
1✔
262
    remaining_seconds = models.IntegerField(
1✔
263
        default=settings.MAX_MINUTES_PER_BILLING_CYCLE * 60
264
    )
265
    remaining_texts = models.IntegerField(default=settings.MAX_TEXTS_PER_BILLING_CYCLE)
1✔
266
    calls_forwarded = models.IntegerField(default=0)
1✔
267
    calls_blocked = models.IntegerField(default=0)
1✔
268
    texts_forwarded = models.IntegerField(default=0)
1✔
269
    texts_blocked = models.IntegerField(default=0)
1✔
270
    created_at = models.DateTimeField(null=True, auto_now_add=True)
1✔
271

272
    @property
1✔
273
    def remaining_minutes(self) -> int:
1✔
274
        # return a 0 or positive int for remaining minutes
275
        return floor(max(self.remaining_seconds, 0) / 60)
1✔
276

277
    @property
1✔
278
    def calls_and_texts_forwarded(self) -> int:
1✔
279
        return self.calls_forwarded + self.texts_forwarded
1✔
280

281
    @property
1✔
282
    def calls_and_texts_blocked(self) -> int:
1✔
283
        return self.calls_blocked + self.texts_blocked
1✔
284

285
    @property
1✔
286
    def storing_phone_log(self) -> bool:
1✔
287
        return bool(self.user.profile.store_phone_log)
1✔
288

289
    def save(self, *args, **kwargs):
1✔
290
        try:
1✔
291
            realphone = RealPhone.verified_objects.get(user=self.user)
1✔
292
        except RealPhone.DoesNotExist:
1✔
293
            raise ValidationError("User does not have a verified real phone.")
1✔
294

295
        # if this number exists for this user, this is an update call
296
        existing_numbers = RelayNumber.objects.filter(user=self.user)
1✔
297
        this_number = existing_numbers.filter(number=self.number).first()
1✔
298
        update_user_profile_last_engagement = False
1✔
299
        if this_number and this_number.id == self.id:
1✔
300
            update_user_profile_last_engagement = any(
1✔
301
                [
302
                    self.enabled != this_number.enabled,
303
                    self.calls_forwarded != this_number.calls_forwarded,
304
                    self.calls_blocked != this_number.calls_blocked,
305
                    self.texts_forwarded != this_number.texts_forwarded,
306
                    self.texts_blocked != this_number.texts_blocked,
307
                ]
308
            )
309
            if update_user_profile_last_engagement:
1✔
310
                self.user.profile.last_engagement = datetime.now(UTC)
1✔
311
                self.user.profile.save()
1✔
312
            return super().save(*args, **kwargs)
1✔
313
        elif existing_numbers.exists():
1✔
314
            raise ValidationError("User can have only one relay number.")
1✔
315

316
        if RelayNumber.objects.filter(number=self.number).exists():
1✔
317
            raise ValidationError("This number is already claimed.")
1✔
318

319
        use_twilio = (
1✔
320
            self.vendor == "twilio" and not settings.PHONES_NO_CLIENT_CALLS_IN_TEST
321
        )
322

323
        if use_twilio:
1✔
324
            # Before saving into DB provision the number in Twilio
325
            client = twilio_client()
1✔
326

327
            # Since this will charge the Twilio account, first see if this
328
            # is running with TEST creds to avoid charges.
329
            if settings.TWILIO_TEST_ACCOUNT_SID:
1!
UNCOV
330
                client = phones_config().twilio_test_client
×
331

332
            twilio_incoming_number = client.incoming_phone_numbers.create(
1✔
333
                phone_number=self.number,
334
                sms_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
335
                voice_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
336
            )
337

338
        # Assume number was selected through suggested_numbers, so same country
339
        # as realphone
340
        self.country_code = realphone.country_code.upper()
1✔
341

342
        # Add numbers to the Relay messaging service, so it goes into our
343
        # A2P 10DLC campaigns
344
        if use_twilio and self.country_code in settings.TWILIO_NEEDS_10DLC_CAMPAIGN:
1✔
345
            if settings.TWILIO_MESSAGING_SERVICE_SID:
1✔
346
                register_with_messaging_service(client, twilio_incoming_number.sid)
1✔
347
            else:
348
                events_logger.warning(
1✔
349
                    "Skipping Twilio Messaging Service registration, since"
350
                    " TWILIO_MESSAGING_SERVICE_SID is empty.",
351
                    extra={"number_sid": twilio_incoming_number.sid},
352
                )
353

354
        return super().save(*args, **kwargs)
1✔
355

356

357
class CachedList:
1✔
358
    """A list that is stored in a cache."""
359

360
    def __init__(self, cache_key: str) -> None:
1✔
361
        self.cache_key = cache_key
1✔
362
        cache_value = cache.get(self.cache_key, "")
1✔
363
        if cache_value:
1✔
364
            self.data = cache_value.split(",")
1✔
365
        else:
366
            self.data = []
1✔
367

368
    def __iter__(self) -> Iterator[str]:
1✔
369
        return (item for item in self.data)
1✔
370

371
    def append(self, item: str) -> None:
1✔
372
        self.data.append(item)
1✔
373
        self.data.sort()
1✔
374
        cache.set(self.cache_key, ",".join(self.data))
1✔
375

376

377
def register_with_messaging_service(client: Client, number_sid: str) -> None:
1✔
378
    """Register a Twilio US phone number with a Messaging Service."""
379

380
    if not settings.TWILIO_MESSAGING_SERVICE_SID:
1!
UNCOV
381
        raise ValueError(
×
382
            "settings.TWILIO_MESSAGING_SERVICE_SID must contain a value when calling "
383
            "register_with_messaging_service"
384
        )
385

386
    closed_sids = CachedList("twilio_messaging_service_closed")
1✔
387

388
    for service_sid in settings.TWILIO_MESSAGING_SERVICE_SID:
1✔
389
        if service_sid in closed_sids:
1✔
390
            continue
1✔
391
        try:
1✔
392
            client.messaging.v1.services(service_sid).phone_numbers.create(
1✔
393
                phone_number_sid=number_sid
394
            )
395
        except TwilioRestException as err:
1✔
396
            log_extra = {
1✔
397
                "err_msg": err.msg,
398
                "status": err.status,
399
                "code": err.code,
400
                "service_sid": service_sid,
401
                "number_sid": number_sid,
402
            }
403
            if err.status == 409 and err.code == 21710:
1✔
404
                # Log "Phone Number is already in the Messaging Service"
405
                # https://www.twilio.com/docs/api/errors/21710
406
                events_logger.warning("twilio_messaging_service", extra=log_extra)
1✔
407
                return
1✔
408
            elif err.status == 412 and err.code == 21714:
1✔
409
                # Log "Number Pool size limit reached", continue to next service
410
                # https://www.twilio.com/docs/api/errors/21714
411
                closed_sids.append(service_sid)
1✔
412
                events_logger.warning("twilio_messaging_service", extra=log_extra)
1✔
413
            else:
414
                # Log and re-raise other Twilio errors
415
                events_logger.error("twilio_messaging_service", extra=log_extra)
1✔
416
                raise
1✔
417
        else:
418
            return  # Successfully registered with service
1✔
419

420
    raise Exception("All services in TWILIO_MESSAGING_SERVICE_SID are full")
1✔
421

422

423
@receiver(post_save, sender=RelayNumber)
1✔
424
def relaynumber_post_save(sender, instance, created, **kwargs):
1✔
425
    # don't do anything if running migrations
426
    if isinstance(instance, MigrationRecorder.Migration):
1!
UNCOV
427
        return
×
428

429
    # TODO: if IQ_FOR_NEW_NUMBERS, send welcome message via IQ
430
    if not instance.vendor == "twilio":
1!
UNCOV
431
        return
×
432

433
    if created:
1✔
434
        incr_if_enabled("phones_RelayNumber.post_save_created_send_welcome")
1✔
435
        if not settings.PHONES_NO_CLIENT_CALLS_IN_TEST:
1✔
436
            # only send welcome vCard when creating new record
437
            send_welcome_message(instance.user, instance)
1✔
438

439

440
def send_welcome_message(user, relay_number):
1✔
441
    real_phone = RealPhone.verified_objects.get(user=user)
1✔
442
    if not settings.SITE_ORIGIN:
1!
UNCOV
443
        raise ValueError(
×
444
            "settings.SITE_ORIGIN must contain a value when calling "
445
            "send_welcome_message"
446
        )
447
    media_url = settings.SITE_ORIGIN + reverse(
1✔
448
        "vCard", kwargs={"lookup_key": relay_number.vcard_lookup_key}
449
    )
450
    client = twilio_client()
1✔
451
    client.messages.create(
1✔
452
        body=(
453
            "Welcome to Relay phone masking!"
454
            " 🎉 Please add your number to your contacts."
455
            " This will help you identify your Relay messages and calls."
456
        ),
457
        from_=settings.TWILIO_MAIN_NUMBER,
458
        to=real_phone.number,
459
        media_url=[media_url],
460
    )
461

462

463
def last_inbound_date_default():
1✔
464
    return datetime.now(UTC)
1✔
465

466

467
class InboundContact(models.Model):
1✔
468
    relay_number = models.ForeignKey(RelayNumber, on_delete=models.CASCADE)
1✔
469
    inbound_number = models.CharField(max_length=15)
1✔
470
    last_inbound_date = models.DateTimeField(default=last_inbound_date_default)
1✔
471
    last_inbound_type = models.CharField(
1✔
472
        max_length=4, choices=LAST_CONTACT_TYPE_CHOICES, default="text"
473
    )
474

475
    num_calls = models.PositiveIntegerField(default=0)
1✔
476
    num_calls_blocked = models.PositiveIntegerField(default=0)
1✔
477
    last_call_date = models.DateTimeField(null=True)
1✔
478

479
    num_texts = models.PositiveIntegerField(default=0)
1✔
480
    num_texts_blocked = models.PositiveIntegerField(default=0)
1✔
481
    last_text_date = models.DateTimeField(null=True)
1✔
482

483
    blocked = models.BooleanField(default=False)
1✔
484

485
    class Meta:
1✔
486
        indexes = [models.Index(fields=["relay_number", "inbound_number"])]
1✔
487

488

489
def suggested_numbers(user):
1✔
490
    try:
1✔
491
        real_phone = RealPhone.verified_objects.get_for_user(user)
1✔
492
    except RealPhone.DoesNotExist:
1✔
493
        raise BadRequest(
1✔
494
            "available_numbers: This user hasn't verified a RealPhone yet."
495
        )
496

497
    existing_number = RelayNumber.objects.filter(user=user)
1✔
498
    if existing_number:
1✔
499
        raise BadRequest(
1✔
500
            "available_numbers: Another RelayNumber already exists for this user."
501
        )
502

503
    real_num = real_phone.number
1✔
504
    client = twilio_client()
1✔
505
    avail_nums = client.available_phone_numbers(real_phone.country_code)
1✔
506

507
    # TODO: can we make multiple pattern searches in a single Twilio API request
508
    same_prefix_options = []
1✔
509
    other_areas_options = []
1✔
510
    same_area_options = []
1✔
511
    # look for numbers with same area code and 3-number prefix
512
    try:
1✔
513
        contains = f"{real_num[:8]}****" if real_num else ""
1✔
514
        twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
515
        same_prefix_options.extend(convert_twilio_numbers_to_dict(twilio_nums))
1✔
516
    except (TwilioException, TwilioRestException):
1✔
517
        events_logger.warning(
1✔
518
            "Could not complete area code + 3 number prefix lookup. Failed with: {ex}"
519
        )
520

521
    # look for numbers with same area code, 2-number prefix and suffix
522
    try:
1✔
523
        contains = f"{real_num[:7]}***{real_num[10:]}" if real_num else ""
1✔
524
        twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
525
        same_prefix_options.extend(convert_twilio_numbers_to_dict(twilio_nums))
1✔
526
    except (TwilioException, TwilioRestException):
1✔
527
        events_logger.warning(
1✔
528
            "Could not complete 2-number prefix lookup. Failed with: {ex}"
529
        )
530

531
    # look for numbers with same area code and 1-number prefix
532
    try:
1✔
533
        contains = f"{real_num[:6]}******" if real_num else ""
1✔
534
        twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
535
        same_prefix_options.extend(convert_twilio_numbers_to_dict(twilio_nums))
1✔
536
    except (TwilioException, TwilioRestException):
1✔
537
        events_logger.warning(
1✔
538
            "Could not complete 1-number prefix lookup. Failed with: {ex}"
539
        )
540

541
    # look for same number in other area codes
542
    try:
1✔
543
        contains = f"+1***{real_num[5:]}" if real_num else ""
1✔
544
        twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
545
        other_areas_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
546
    except (TwilioException, TwilioRestException):
1✔
547
        events_logger.warning(
1✔
548
            "Could not complete other area code lookup. Failed with: {ex}"
549
        )
550

551
    # look for any numbers in the area code
552
    try:
1✔
553
        contains = f"{real_num[:5]}*******" if real_num else ""
1✔
554
        twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
555
        same_area_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
556
    except (TwilioException, TwilioRestException):
1✔
557
        events_logger.warning(
1✔
558
            "Could not complete same area code lookup. Failed with: {ex}"
559
        )
560

561
    # look for any available numbers
562
    twilio_nums = avail_nums.local.list(limit=10)
1✔
563
    random_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
564

565
    return {
1✔
566
        "real_num": real_num,
567
        "same_prefix_options": same_prefix_options,
568
        "other_areas_options": other_areas_options,
569
        "same_area_options": same_area_options,
570
        "random_options": random_options,
571
    }
572

573

574
def location_numbers(location, country_code=DEFAULT_REGION):
1✔
575
    client = twilio_client()
1✔
576
    avail_nums = client.available_phone_numbers(country_code)
1✔
577
    twilio_nums = avail_nums.local.list(in_locality=location, limit=10)
1✔
578
    return convert_twilio_numbers_to_dict(twilio_nums)
1✔
579

580

581
def area_code_numbers(area_code, country_code=DEFAULT_REGION):
1✔
582
    client = twilio_client()
1✔
583
    avail_nums = client.available_phone_numbers(country_code)
1✔
584
    twilio_nums = avail_nums.local.list(area_code=area_code, limit=10)
1✔
585
    return convert_twilio_numbers_to_dict(twilio_nums)
1✔
586

587

588
def convert_twilio_numbers_to_dict(twilio_numbers):
1✔
589
    """
590
    To serialize twilio numbers to JSON for the API,
591
    we need to convert them into dictionaries.
592
    """
593
    numbers_as_dicts = []
1✔
594
    for twilio_number in twilio_numbers:
1✔
595
        number = {}
1✔
596
        number["friendly_name"] = twilio_number.friendly_name
1✔
597
        number["iso_country"] = twilio_number.iso_country
1✔
598
        number["locality"] = twilio_number.locality
1✔
599
        number["phone_number"] = twilio_number.phone_number
1✔
600
        number["postal_code"] = twilio_number.postal_code
1✔
601
        number["region"] = twilio_number.region
1✔
602
        numbers_as_dicts.append(number)
1✔
603
    return numbers_as_dicts
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc