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

mozilla / fx-private-relay / 5c21a565-f214-49dc-aa7c-394e223be5c0

02 May 2024 05:45PM CUT coverage: 84.35% (+0.02%) from 84.335%
5c21a565-f214-49dc-aa7c-394e223be5c0

push

circleci

jwhitlock
Skip documentation of 405s

3473 of 4531 branches covered (76.65%)

Branch coverage included in aggregate %.

14534 of 16817 relevant lines covered (86.42%)

10.98 hits per line

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

90.87
/api/views/phones.py
1
import hashlib
1✔
2
import logging
1✔
3
import re
1✔
4
import string
1✔
5
from dataclasses import asdict, dataclass, field
1✔
6
from datetime import UTC, datetime
1✔
7
from typing import Any, Literal
1✔
8

9
from django.conf import settings
1✔
10
from django.contrib.auth.models import User
1✔
11
from django.core.exceptions import ObjectDoesNotExist
1✔
12
from django.db.models.query import QuerySet
1✔
13
from django.forms import model_to_dict
1✔
14

15
import django_ftl
1✔
16
import phonenumbers
1✔
17
from drf_spectacular.utils import (
1✔
18
    OpenApiExample,
19
    OpenApiParameter,
20
    OpenApiRequest,
21
    OpenApiResponse,
22
    extend_schema,
23
)
24
from rest_framework import (
1✔
25
    decorators,
26
    exceptions,
27
    permissions,
28
    response,
29
    throttling,
30
    viewsets,
31
)
32
from rest_framework.generics import get_object_or_404
1✔
33
from rest_framework.request import Request
1✔
34
from twilio.base.exceptions import TwilioRestException
1✔
35
from waffle import flag_is_active, get_waffle_flag_model
1✔
36

37
from api.views import SaveToRequestUser
1✔
38
from emails.utils import incr_if_enabled
1✔
39
from phones.apps import phones_config, twilio_client
1✔
40
from phones.iq_utils import send_iq_sms
1✔
41
from phones.models import (
1✔
42
    InboundContact,
43
    RealPhone,
44
    RelayNumber,
45
    area_code_numbers,
46
    get_last_text_sender,
47
    get_pending_unverified_realphone_records,
48
    get_valid_realphone_verification_record,
49
    get_verified_realphone_record,
50
    get_verified_realphone_records,
51
    location_numbers,
52
    send_welcome_message,
53
    suggested_numbers,
54
)
55
from privaterelay.ftl_bundles import main as ftl_bundle
1✔
56

57
from ..exceptions import ConflictError, ErrorContextType
1✔
58
from ..permissions import HasPhoneService
1✔
59
from ..renderers import TemplateTwiMLRenderer, vCardRenderer
1✔
60
from ..serializers.phones import (
1✔
61
    InboundContactSerializer,
62
    IqInboundSmsSerializer,
63
    OutboundCallSerializer,
64
    OutboundSmsSerializer,
65
    RealPhoneSerializer,
66
    RelayNumberSerializer,
67
    TwilioInboundCallSerializer,
68
    TwilioInboundSmsSerializer,
69
    TwilioMessagesSerializer,
70
    TwilioSmsStatusSerializer,
71
    TwilioVoiceStatusSerializer,
72
)
73

74
logger = logging.getLogger("events")
1✔
75
info_logger = logging.getLogger("eventsinfo")
1✔
76

77

78
def twilio_validator():
1✔
79
    return phones_config().twilio_validator
1✔
80

81

82
def twiml_app():
1✔
83
    return phones_config().twiml_app
×
84

85

86
class RealPhoneRateThrottle(throttling.UserRateThrottle):
1✔
87
    rate = settings.PHONE_RATE_LIMIT
1✔
88

89

90
@extend_schema(tags=["phones"])
1✔
91
class RealPhoneViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
92
    """
93
    Get real phone number records for the authenticated user.
94

95
    The authenticated user must have a subscription that grants one of the
96
    `SUBSCRIPTIONS_WITH_PHONE` capabilities.
97

98
    Client must be authenticated, and these endpoints only return data that is
99
    "owned" by the authenticated user.
100

101
    All endpoints are rate-limited to settings.PHONE_RATE_LIMIT
102
    """
103

104
    http_method_names = ["get", "post", "patch", "delete"]
1✔
105
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
106
    serializer_class = RealPhoneSerializer
1✔
107
    # TODO: this doesn't seem to e working?
108
    throttle_classes = [RealPhoneRateThrottle]
1✔
109

110
    def get_queryset(self) -> QuerySet[RealPhone]:
1✔
111
        if isinstance(self.request.user, User):
1✔
112
            return RealPhone.objects.filter(user=self.request.user)
1✔
113
        return RealPhone.objects.none()
1✔
114

115
    def create(self, request):
1✔
116
        """
117
        Add real phone number to the authenticated user.
118

119
        The "flow" to verify a real phone number is:
120
        1. POST a number (Will text a verification code to the number)
121
        2a. PATCH the verification code to the realphone/{id} endpoint
122
        2b. POST the number and verification code together
123

124
        The authenticated user must have a subscription that grants one of the
125
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
126

127
        The `number` field should be in [E.164][e164] format which includes a country
128
        code. If the number is not in E.164 format, this endpoint will try to
129
        create an E.164 number by prepending the country code of the client
130
        making the request (i.e., from the `X-Client-Region` HTTP header).
131

132
        If the `POST` does NOT include a `verification_code` and the number is
133
        a valid (currently, US-based) number, this endpoint will text a
134
        verification code to the number.
135

136
        If the `POST` DOES include a `verification_code`, and the code matches
137
        a code already sent to the number, this endpoint will set `verified` to
138
        `True` for this number.
139

140
        [e164]: https://en.wikipedia.org/wiki/E.164
141
        """
142
        incr_if_enabled("phones_RealPhoneViewSet.create")
1✔
143
        serializer = self.get_serializer(data=request.data)
1✔
144
        serializer.is_valid(raise_exception=True)
1✔
145

146
        # Check if the request includes a valid verification_code
147
        # value, look for any un-expired record that matches both the phone
148
        # number and verification code and mark it verified.
149
        verification_code = serializer.validated_data.get("verification_code")
1✔
150
        if verification_code:
1✔
151
            valid_record = get_valid_realphone_verification_record(
1✔
152
                request.user, serializer.validated_data["number"], verification_code
153
            )
154
            if not valid_record:
1✔
155
                incr_if_enabled("phones_RealPhoneViewSet.create.invalid_verification")
1✔
156
                raise exceptions.ValidationError(
1✔
157
                    "Could not find that verification_code for user and number."
158
                    " It may have expired."
159
                )
160

161
            headers = self.get_success_headers(serializer.validated_data)
1✔
162
            verified_valid_record = valid_record.mark_verified()
1✔
163
            incr_if_enabled("phones_RealPhoneViewSet.create.mark_verified")
1✔
164
            response_data = model_to_dict(
1✔
165
                verified_valid_record,
166
                fields=[
167
                    "id",
168
                    "number",
169
                    "verification_sent_date",
170
                    "verified",
171
                    "verified_date",
172
                ],
173
            )
174
            return response.Response(response_data, status=201, headers=headers)
1✔
175

176
        # to prevent sending verification codes to verified numbers,
177
        # check if the number is already a verified number.
178
        is_verified = get_verified_realphone_record(serializer.validated_data["number"])
1✔
179
        if is_verified:
1!
180
            raise ConflictError("A verified record already exists for this number.")
×
181

182
        # to prevent abusive sending of verification messages,
183
        # check if there is an un-expired verification code for the user
184
        pending_unverified_records = get_pending_unverified_realphone_records(
1✔
185
            serializer.validated_data["number"]
186
        )
187
        if pending_unverified_records:
1✔
188
            raise ConflictError(
1✔
189
                "An unverified record already exists for this number.",
190
            )
191

192
        # We call an additional _validate_number function with the request
193
        # to try to parse the number as a local national number in the
194
        # request.country attribute
195
        valid_number = _validate_number(request)
1✔
196
        serializer.validated_data["number"] = valid_number.phone_number
1✔
197
        serializer.validated_data["country_code"] = valid_number.country_code.upper()
1✔
198

199
        self.perform_create(serializer)
1✔
200
        incr_if_enabled("phones_RealPhoneViewSet.perform_create")
1✔
201
        headers = self.get_success_headers(serializer.validated_data)
1✔
202
        response_data = serializer.data
1✔
203
        response_data["message"] = (
1✔
204
            "Sent verification code to "
205
            f"{valid_number.phone_number} "
206
            f"(country: {valid_number.country_code} "
207
            f"carrier: {valid_number.carrier})"
208
        )
209
        return response.Response(response_data, status=201, headers=headers)
1✔
210

211
    # check verification_code during partial_update to compare
212
    # the value sent in the request against the value already on the instance
213
    # TODO: this logic might be able to move "up" into the model, but it will
214
    # need some more serious refactoring of the RealPhone.save() method
215
    def partial_update(self, request, *args, **kwargs):
1✔
216
        """
217
        Update the authenticated user's real phone number.
218

219
        The authenticated user must have a subscription that grants one of the
220
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
221

222
        The `{id}` should match a previously-`POST`ed resource that belongs to the user.
223

224
        The `number` field should be in [E.164][e164] format which includes a country
225
        code.
226

227
        The `verification_code` should be the code that was texted to the
228
        number during the `POST`. If it matches, this endpoint will set
229
        `verified` to `True` for this number.
230

231
        [e164]: https://en.wikipedia.org/wiki/E.164
232
        """
233
        incr_if_enabled("phones_RealPhoneViewSet.partial_update")
1✔
234
        instance = self.get_object()
1✔
235
        if request.data["number"] != instance.number:
1✔
236
            raise exceptions.ValidationError("Invalid number for ID.")
1✔
237
        # TODO: check verification_sent_date is not "expired"?
238
        # Note: the RealPhone.save() logic should prevent expired verifications
239
        if (
1✔
240
            "verification_code" not in request.data
241
            or not request.data["verification_code"] == instance.verification_code
242
        ):
243
            raise exceptions.ValidationError(
1✔
244
                "Invalid verification_code for ID. It may have expired."
245
            )
246

247
        instance.mark_verified()
1✔
248
        incr_if_enabled("phones_RealPhoneViewSet.partial_update.mark_verified")
1✔
249
        return super().partial_update(request, *args, **kwargs)
1✔
250

251
    def destroy(self, request, *args, **kwargs):
1✔
252
        """
253
        Delete a real phone resource.
254

255
        Only **un-verified** real phone resources can be deleted.
256
        """
257
        incr_if_enabled("phones_RealPhoneViewSet.destroy")
1✔
258
        instance = self.get_object()
1✔
259
        if instance.verified:
1✔
260
            raise exceptions.ValidationError(
1✔
261
                "Only un-verified real phone resources can be deleted."
262
            )
263

264
        return super().destroy(request, *args, **kwargs)
1✔
265

266

267
@extend_schema(tags=["phones"])
1✔
268
class RelayNumberViewSet(SaveToRequestUser, viewsets.ModelViewSet):
1✔
269
    http_method_names = ["get", "post", "patch"]
1✔
270
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
271
    serializer_class = RelayNumberSerializer
1✔
272

273
    def get_queryset(self) -> QuerySet[RelayNumber]:
1✔
274
        if isinstance(self.request.user, User):
1✔
275
            return RelayNumber.objects.filter(user=self.request.user)
1✔
276
        return RelayNumber.objects.none()
1✔
277

278
    def create(self, request, *args, **kwargs):
1✔
279
        """
280
        Provision a phone number with Twilio and assign to the authenticated user.
281

282
        ⚠️ **THIS WILL BUY A PHONE NUMBER** ⚠️
283
        If you have real account credentials in your `TWILIO_*` env vars, this
284
        will really provision a Twilio number to your account. You can use
285
        [Test Credentials][test-creds] to call this endpoint without making a
286
        real phone number purchase. If you do, you need to pass one of the
287
        [test phone numbers][test-numbers].
288

289
        The `number` should be in [E.164][e164] format.
290

291
        Every call or text to the relay number will be sent as a webhook to the
292
        URL configured for your `TWILIO_SMS_APPLICATION_SID`.
293

294
        [test-creds]: https://www.twilio.com/docs/iam/test-credentials
295
        [test-numbers]: https://www.twilio.com/docs/iam/test-credentials#test-incoming-phone-numbers-parameters-PhoneNumber
296
        [e164]: https://en.wikipedia.org/wiki/E.164
297
        """  # noqa: E501  # ignore long line for URL
298
        incr_if_enabled("phones_RelayNumberViewSet.create")
1✔
299
        existing_number = RelayNumber.objects.filter(user=request.user)
1✔
300
        if existing_number:
1!
301
            raise exceptions.ValidationError("User already has a RelayNumber.")
1✔
302
        return super().create(request, *args, **kwargs)
×
303

304
    def partial_update(self, request, *args, **kwargs):
1✔
305
        """
306
        Update the authenticated user's relay number.
307

308
        The authenticated user must have a subscription that grants one of the
309
        `SUBSCRIPTIONS_WITH_PHONE` capabilities.
310

311
        The `{id}` should match a previously-`POST`ed resource that belongs to
312
        the authenticated user.
313

314
        This is primarily used to toggle the `enabled` field.
315
        """
316
        incr_if_enabled("phones_RelayNumberViewSet.partial_update")
1✔
317
        return super().partial_update(request, *args, **kwargs)
1✔
318

319
    @decorators.action(detail=False)
1✔
320
    def suggestions(self, request):
1✔
321
        """
322
        Returns suggested relay numbers for the authenticated user.
323

324
        Based on the user's real number, returns available relay numbers:
325
          * `same_prefix_options`: Numbers that match as much of the user's
326
            real number as possible.
327
          * `other_areas_options`: Numbers that exactly match the user's real
328
            number, in a different area code.
329
          * `same_area_options`: Other numbers in the same area code as the user.
330
          * `random_options`: Available numbers in the user's country
331
        """
332
        incr_if_enabled("phones_RelayNumberViewSet.suggestions")
1✔
333
        numbers = suggested_numbers(request.user)
1✔
334
        return response.Response(numbers)
1✔
335

336
    @decorators.action(detail=False)
1✔
337
    def search(self, request):
1✔
338
        """
339
        Search for available numbers.
340

341
        This endpoints uses the underlying [AvailablePhoneNumbers][apn] API.
342

343
        Accepted query params:
344
          * ?location=
345
            * Will be passed to `AvailablePhoneNumbers` `in_locality` param
346
          * ?area_code=
347
            * Will be passed to `AvailablePhoneNumbers` `area_code` param
348

349
        [apn]: https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource#read-multiple-availablephonenumberlocal-resources
350
        """  # noqa: E501  # ignore long line for URL
351
        incr_if_enabled("phones_RelayNumberViewSet.search")
1✔
352
        real_phone = get_verified_realphone_records(request.user).first()
1✔
353
        if real_phone:
1✔
354
            country_code = real_phone.country_code
1✔
355
        else:
356
            country_code = "US"
1✔
357
        location = request.query_params.get("location")
1✔
358
        if location is not None:
1✔
359
            numbers = location_numbers(location, country_code)
1✔
360
            return response.Response(numbers)
1✔
361

362
        area_code = request.query_params.get("area_code")
1✔
363
        if area_code is not None:
1✔
364
            numbers = area_code_numbers(area_code, country_code)
1✔
365
            return response.Response(numbers)
1✔
366

367
        return response.Response({}, 404)
1✔
368

369

370
@extend_schema(tags=["phones"])
1✔
371
class InboundContactViewSet(viewsets.ModelViewSet):
1✔
372
    http_method_names = ["get", "patch"]
1✔
373
    permission_classes = [permissions.IsAuthenticated, HasPhoneService]
1✔
374
    serializer_class = InboundContactSerializer
1✔
375

376
    def get_queryset(self) -> QuerySet[InboundContact]:
1✔
377
        if isinstance(self.request.user, User):
1✔
378
            relay_number = get_object_or_404(RelayNumber, user=self.request.user)
1✔
379
            return InboundContact.objects.filter(relay_number=relay_number)
1✔
380
        return InboundContact.objects.none()
1✔
381

382

383
def _validate_number(request, number_field="number"):
1✔
384
    if number_field not in request.data:
1✔
385
        raise exceptions.ValidationError({number_field: "A number is required."})
1✔
386

387
    parsed_number = _parse_number(
1✔
388
        request.data[number_field], getattr(request, "country", None)
389
    )
390
    if not parsed_number:
1✔
391
        country = None
1✔
392
        if hasattr(request, "country"):
1!
393
            country = request.country
×
394
        error_message = (
1✔
395
            "number must be in E.164 format, or in local national format of the"
396
            f" country detected: {country}"
397
        )
398
        raise exceptions.ValidationError(error_message)
1✔
399

400
    e164_number = f"+{parsed_number.country_code}{parsed_number.national_number}"
1✔
401
    number_details = _get_number_details(e164_number)
1✔
402
    if not number_details:
1✔
403
        raise exceptions.ValidationError(
1✔
404
            f"Could not get number details for {e164_number}"
405
        )
406

407
    if number_details.country_code.upper() not in settings.TWILIO_ALLOWED_COUNTRY_CODES:
1✔
408
        incr_if_enabled("phones_validate_number_unsupported_country")
1✔
409
        raise exceptions.ValidationError(
1✔
410
            "Relay Phone is currently only available for these country codes: "
411
            f"{sorted(settings.TWILIO_ALLOWED_COUNTRY_CODES)!r}. "
412
            "Your phone number country code is: "
413
            f"'{number_details.country_code.upper()}'."
414
        )
415

416
    return number_details
1✔
417

418

419
def _parse_number(number, country=None):
1✔
420
    try:
1✔
421
        # First try to parse assuming number is E.164 with country prefix
422
        return phonenumbers.parse(number)
1✔
423
    except phonenumbers.phonenumberutil.NumberParseException as e:
1✔
424
        if e.error_type == e.INVALID_COUNTRY_CODE and country is not None:
1✔
425
            try:
1✔
426
                # Try to parse, assuming number is local national format
427
                # in the detected request country
428
                return phonenumbers.parse(number, country)
1✔
429
            except Exception:
×
430
                return None
×
431
    return None
1✔
432

433

434
def _get_number_details(e164_number):
1✔
435
    incr_if_enabled("phones_get_number_details")
1✔
436
    try:
1✔
437
        client = twilio_client()
1✔
438
        return client.lookups.v1.phone_numbers(e164_number).fetch(type=["carrier"])
1✔
439
    except Exception:
1✔
440
        logger.exception(f"Could not get number details for {e164_number}")
1✔
441
        return None
1✔
442

443

444
@extend_schema(
1✔
445
    tags=["phones"],
446
    responses={
447
        "200": OpenApiResponse(
448
            bytes,
449
            description="A Virtual Contact File (VCF) for the user's Relay number.",
450
            examples=[
451
                OpenApiExample(
452
                    name="partial VCF",
453
                    media_type="text/x-vcard",
454
                    value=(
455
                        "BEGIN:VCARD\nVERSION:3.0\nFN:Firefox Relay\n"
456
                        "TEL:+14045555555\nEND:VCARD\n"
457
                    ),
458
                )
459
            ],
460
        ),
461
        "404": OpenApiResponse(description="No or unknown lookup key"),
462
    },
463
)
464
@decorators.api_view()
1✔
465
@decorators.permission_classes([permissions.AllowAny])
1✔
466
@decorators.renderer_classes([vCardRenderer])
1✔
467
def vCard(request: Request, lookup_key: str) -> response.Response:
1✔
468
    """
469
    Get a Relay vCard. `lookup_key` should be passed in url path.
470

471
    We use this to return a vCard for a number. When we create a RelayNumber,
472
    we create a secret lookup_key and text it to the user.
473
    """
474
    incr_if_enabled("phones_vcard")
1✔
475
    if lookup_key is None:
1!
476
        return response.Response(status=404)
×
477

478
    try:
1✔
479
        relay_number = RelayNumber.objects.get(vcard_lookup_key=lookup_key)
1✔
480
    except RelayNumber.DoesNotExist:
1✔
481
        raise exceptions.NotFound()
1✔
482
    number = relay_number.number
1✔
483

484
    resp = response.Response({"number": number})
1✔
485
    resp["Content-Disposition"] = f"attachment; filename={number}.vcf"
1✔
486
    return resp
1✔
487

488

489
@extend_schema(
1✔
490
    tags=["phones"],
491
    request=OpenApiRequest(),
492
    responses={
493
        "200": OpenApiResponse(
494
            {"type": "object"},
495
            description="Welcome message sent.",
496
            examples=[OpenApiExample("success", {"msg": "sent"})],
497
        ),
498
        "401": OpenApiResponse(description="Not allowed"),
499
        "404": OpenApiResponse(description="User does not have a Relay number."),
500
    },
501
)
502
@decorators.api_view(["POST"])
1✔
503
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
504
def resend_welcome_sms(request):
1✔
505
    """
506
    Resend the "Welcome" SMS, including vCard.
507

508
    Requires the user to be signed in and to have phone service.
509
    """
510
    incr_if_enabled("phones_resend_welcome_sms")
1✔
511
    try:
1✔
512
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
513
    except RelayNumber.DoesNotExist:
×
514
        raise exceptions.NotFound()
×
515
    send_welcome_message(request.user, relay_number)
1✔
516

517
    resp = response.Response(status=201, data={"msg": "sent"})
1✔
518
    return resp
1✔
519

520

521
def _try_delete_from_twilio(message):
1✔
522
    try:
1✔
523
        message.delete()
1✔
524
    except TwilioRestException as e:
×
525
        # Raise the exception unless it's a 404 indicating the message is already gone
526
        if e.status != 404:
×
527
            raise e
×
528

529

530
def message_body(from_num, body):
1✔
531
    return f"[Relay 📲 {from_num}] {body}"
1✔
532

533

534
def _get_user_error_message(real_phone: RealPhone, sms_exception) -> Any:
1✔
535
    # Send a translated message to the user
536
    ftl_code = sms_exception.get_codes().replace("_", "-")
1✔
537
    ftl_id = f"sms-error-{ftl_code}"
1✔
538
    # log the error in English
539
    with django_ftl.override("en"):
1✔
540
        logger.exception(ftl_bundle.format(ftl_id, sms_exception.error_context()))
1✔
541
    with django_ftl.override(real_phone.user.profile.language):
1✔
542
        user_message = ftl_bundle.format(ftl_id, sms_exception.error_context())
1✔
543
    return user_message
1✔
544

545

546
@extend_schema(
1✔
547
    tags=["phones: Twilio"],
548
    parameters=[
549
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
550
    ],
551
    request=OpenApiRequest(
552
        TwilioInboundSmsSerializer,
553
        examples=[
554
            OpenApiExample(
555
                "request",
556
                {"to": "+13035556789", "from": "+14045556789", "text": "Hello!"},
557
            )
558
        ],
559
    ),
560
    responses={
561
        "200": OpenApiResponse(
562
            {"type": "string", "xml": {"name": "Response"}},
563
            description="The number is disabled.",
564
            examples=[OpenApiExample("disabled", None)],
565
        ),
566
        "201": OpenApiResponse(
567
            {"type": "string", "xml": {"name": "Response"}},
568
            description="Forward the message to the user.",
569
            examples=[OpenApiExample("success", None)],
570
        ),
571
        "400": OpenApiResponse(
572
            {"type": "object", "xml": {"name": "Error"}},
573
            description="Unable to complete request.",
574
            examples=[
575
                OpenApiExample(
576
                    "invalid signature",
577
                    {
578
                        "status_code": 400,
579
                        "code": "invalid",
580
                        "title": "Invalid Request: Invalid Signature",
581
                    },
582
                )
583
            ],
584
        ),
585
    },
586
)
587
@decorators.api_view(["POST"])
1✔
588
@decorators.permission_classes([permissions.AllowAny])
1✔
589
@decorators.renderer_classes([TemplateTwiMLRenderer])
1✔
590
def inbound_sms(request):
1✔
591
    """
592
    Handle an inbound SMS message sent by Twilio.
593

594
    The return value is TwilML Response XML that reports the error or an empty success
595
    message.
596
    """
597
    incr_if_enabled("phones_inbound_sms")
1✔
598
    _validate_twilio_request(request)
1✔
599

600
    """
1✔
601
    TODO: delete the message from Twilio; how to do this AFTER this request? queue?
602
    E.g., with a django-celery task in phones.tasks:
603

604
    inbound_msg_sid = request.data.get("MessageSid", None)
605
    if inbound_msg_sid is None:
606
        raise exceptions.ValidationError("Request missing MessageSid")
607
    tasks._try_delete_from_twilio.delay(args=message, countdown=10)
608
    """
609

610
    inbound_body = request.data.get("Body", None)
1✔
611
    inbound_from = request.data.get("From", None)
1✔
612
    inbound_to = request.data.get("To", None)
1✔
613
    if inbound_body is None or inbound_from is None or inbound_to is None:
1✔
614
        raise exceptions.ValidationError("Request missing From, To, Or Body.")
1✔
615

616
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
617
    _check_remaining(relay_number, "texts")
1✔
618

619
    if inbound_from == real_phone.number:
1✔
620
        try:
1✔
621
            relay_number, destination_number, body = _prepare_sms_reply(
1✔
622
                relay_number, inbound_body
623
            )
624
            client = twilio_client()
1✔
625
            incr_if_enabled("phones_send_sms_reply")
1✔
626
            client.messages.create(
1✔
627
                from_=relay_number.number, body=body, to=destination_number
628
            )
629
            relay_number.remaining_texts -= 1
1✔
630
            relay_number.texts_forwarded += 1
1✔
631
            relay_number.save()
1✔
632
        except RelaySMSException as sms_exception:
1✔
633
            user_error_message = _get_user_error_message(real_phone, sms_exception)
1✔
634
            twilio_client().messages.create(
1✔
635
                from_=relay_number.number, body=user_error_message, to=real_phone.number
636
            )
637

638
            # Return 400 on critical exceptions
639
            if sms_exception.critical:
1✔
640
                raise exceptions.ValidationError(
1✔
641
                    sms_exception.detail
642
                ) from sms_exception
643
        return response.Response(
1✔
644
            status=200,
645
            template_name="twiml_empty_response.xml",
646
        )
647

648
    number_disabled = _check_disabled(relay_number, "texts")
1✔
649
    if number_disabled:
1✔
650
        return response.Response(
1✔
651
            status=200,
652
            template_name="twiml_empty_response.xml",
653
        )
654
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
655
    if inbound_contact:
1✔
656
        _check_and_update_contact(inbound_contact, "texts", relay_number)
1✔
657

658
    client = twilio_client()
1✔
659
    app = twiml_app()
1✔
660
    incr_if_enabled("phones_outbound_sms")
1✔
661
    body = message_body(inbound_from, inbound_body)
1✔
662
    client.messages.create(
1✔
663
        from_=relay_number.number,
664
        body=body,
665
        status_callback=app.sms_status_callback,
666
        to=real_phone.number,
667
    )
668
    relay_number.remaining_texts -= 1
1✔
669
    relay_number.texts_forwarded += 1
1✔
670
    relay_number.save()
1✔
671
    return response.Response(
1✔
672
        status=201,
673
        template_name="twiml_empty_response.xml",
674
    )
675

676

677
@extend_schema(
1✔
678
    tags=["phones: Inteliquent"],
679
    request=OpenApiRequest(
680
        IqInboundSmsSerializer,
681
        examples=[
682
            OpenApiExample(
683
                "request",
684
                {"to": "+13035556789", "from": "+14045556789", "text": "Hello!"},
685
            )
686
        ],
687
    ),
688
    parameters=[
689
        OpenApiParameter(name="VerificationToken", required=True, location="header"),
690
        OpenApiParameter(name="MessageId", required=True, location="header"),
691
    ],
692
    responses={
693
        "200": OpenApiResponse(
694
            description=(
695
                "The message was forwarded, or the user is out of text messages."
696
            )
697
        ),
698
        "401": OpenApiResponse(description="Invalid signature"),
699
        "400": OpenApiResponse(description="Invalid request"),
700
    },
701
)
702
@decorators.api_view(["POST"])
1✔
703
@decorators.permission_classes([permissions.AllowAny])
1✔
704
def inbound_sms_iq(request: Request) -> response.Response:
1✔
705
    """Handle an inbound SMS message sent by Inteliquent."""
706
    incr_if_enabled("phones_inbound_sms_iq")
×
707
    _validate_iq_request(request)
×
708

709
    inbound_body = request.data.get("text", None)
×
710
    inbound_from = request.data.get("from", None)
×
711
    inbound_to = request.data.get("to", None)
×
712
    if inbound_body is None or inbound_from is None or inbound_to is None:
×
713
        raise exceptions.ValidationError("Request missing from, to, or text.")
×
714

715
    from_num = phonenumbers.format_number(
×
716
        phonenumbers.parse(inbound_from, "US"),
717
        phonenumbers.PhoneNumberFormat.E164,
718
    )
719
    single_num = inbound_to[0]
×
720
    relay_num = phonenumbers.format_number(
×
721
        phonenumbers.parse(single_num, "US"), phonenumbers.PhoneNumberFormat.E164
722
    )
723

724
    relay_number, real_phone = _get_phone_objects(relay_num)
×
725
    _check_remaining(relay_number, "texts")
×
726

727
    if from_num == real_phone.number:
×
728
        try:
×
729
            relay_number, destination_number, body = _prepare_sms_reply(
×
730
                relay_number, inbound_body
731
            )
732
            send_iq_sms(destination_number, relay_number.number, body)
×
733
            relay_number.remaining_texts -= 1
×
734
            relay_number.texts_forwarded += 1
×
735
            relay_number.save()
×
736
            incr_if_enabled("phones_send_sms_reply_iq")
×
737
        except RelaySMSException as sms_exception:
×
738
            user_error_message = _get_user_error_message(real_phone, sms_exception)
×
739
            send_iq_sms(real_phone.number, relay_number.number, user_error_message)
×
740

741
            # Return 400 on critical exceptions
742
            if sms_exception.critical:
×
743
                raise exceptions.ValidationError(
×
744
                    sms_exception.detail
745
                ) from sms_exception
746
        return response.Response(
×
747
            status=200,
748
            template_name="twiml_empty_response.xml",
749
        )
750

751
    number_disabled = _check_disabled(relay_number, "texts")
×
752
    if number_disabled:
×
753
        return response.Response(status=200)
×
754

755
    inbound_contact = _get_inbound_contact(relay_number, from_num)
×
756
    if inbound_contact:
×
757
        _check_and_update_contact(inbound_contact, "texts", relay_number)
×
758

759
    text = message_body(inbound_from, inbound_body)
×
760
    send_iq_sms(real_phone.number, relay_number.number, text)
×
761

762
    relay_number.remaining_texts -= 1
×
763
    relay_number.texts_forwarded += 1
×
764
    relay_number.save()
×
765
    return response.Response(status=200)
×
766

767

768
@extend_schema(
1✔
769
    tags=["phones: Twilio"],
770
    parameters=[
771
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
772
    ],
773
    request=OpenApiRequest(
774
        TwilioInboundCallSerializer,
775
        examples=[
776
            OpenApiExample(
777
                "request",
778
                {"Caller": "+13035556789", "Called": "+14045556789"},
779
            )
780
        ],
781
    ),
782
    responses={
783
        "200": OpenApiResponse(
784
            {
785
                "type": "object",
786
                "xml": {"name": "Response"},
787
                "properties": {"say": {"type": "string"}},
788
            },
789
            description="The number is disabled.",
790
            examples=[
791
                OpenApiExample(
792
                    "disabled", {"say": "Sorry, that number is not available."}
793
                )
794
            ],
795
        ),
796
        "201": OpenApiResponse(
797
            {
798
                "type": "object",
799
                "xml": {"name": "Response"},
800
                "properties": {
801
                    "Dial": {
802
                        "type": "object",
803
                        "properties": {
804
                            "callerId": {
805
                                "type": "string",
806
                                "xml": {"attribute": "true"},
807
                            },
808
                            "Number": {"type": "string"},
809
                        },
810
                    }
811
                },
812
            },
813
            description="Connect the caller to the Relay user.",
814
            examples=[
815
                OpenApiExample(
816
                    "success",
817
                    {"Dial": {"callerId": "+13035556789", "Number": "+15025558642"}},
818
                )
819
            ],
820
        ),
821
        "400": OpenApiResponse(
822
            {"type": "object", "xml": {"name": "Error"}},
823
            description="Unable to complete request.",
824
            examples=[
825
                OpenApiExample(
826
                    "invalid signature",
827
                    {
828
                        "status_code": 400,
829
                        "code": "invalid",
830
                        "title": "Invalid Request: Invalid Signature",
831
                    },
832
                ),
833
                OpenApiExample(
834
                    "out of call time for month",
835
                    {
836
                        "status_code": 400,
837
                        "code": "invalid",
838
                        "title": "Number Is Out Of Seconds.",
839
                    },
840
                ),
841
            ],
842
        ),
843
    },
844
)
845
@decorators.api_view(["POST"])
1✔
846
@decorators.permission_classes([permissions.AllowAny])
1✔
847
@decorators.renderer_classes([TemplateTwiMLRenderer])
1✔
848
def inbound_call(request):
1✔
849
    """
850
    Handle an inbound call request sent by Twilio.
851

852
    The return value is TwilML Response XML that reports the error or instructs
853
    Twilio to connect the callers.
854
    """
855
    incr_if_enabled("phones_inbound_call")
1✔
856
    _validate_twilio_request(request)
1✔
857
    inbound_from = request.data.get("Caller", None)
1✔
858
    inbound_to = request.data.get("Called", None)
1✔
859
    if inbound_from is None or inbound_to is None:
1✔
860
        raise exceptions.ValidationError("Call data missing Caller or Called.")
1✔
861

862
    relay_number, real_phone = _get_phone_objects(inbound_to)
1✔
863

864
    number_disabled = _check_disabled(relay_number, "calls")
1✔
865
    if number_disabled:
1✔
866
        say = "Sorry, that number is not available."
1✔
867
        return response.Response(
1✔
868
            {"say": say}, status=200, template_name="twiml_blocked.xml"
869
        )
870

871
    _check_remaining(relay_number, "seconds")
1✔
872

873
    inbound_contact = _get_inbound_contact(relay_number, inbound_from)
1✔
874
    if inbound_contact:
1!
875
        _check_and_update_contact(inbound_contact, "calls", relay_number)
1✔
876

877
    relay_number.calls_forwarded += 1
1✔
878
    relay_number.save()
1✔
879

880
    # Note: TemplateTwiMLRenderer will render this as TwiML
881
    incr_if_enabled("phones_outbound_call")
1✔
882
    return response.Response(
1✔
883
        {"inbound_from": inbound_from, "real_number": real_phone.number},
884
        status=201,
885
        template_name="twiml_dial.xml",
886
    )
887

888

889
@extend_schema(
1✔
890
    tags=["phones: Twilio"],
891
    request=OpenApiRequest(
892
        TwilioVoiceStatusSerializer,
893
        examples=[
894
            OpenApiExample(
895
                "Call is complete",
896
                {
897
                    "CallSid": "CA" + "x" * 32,
898
                    "Called": "+14045556789",
899
                    "CallStatus": "completed",
900
                    "CallDuration": 127,
901
                },
902
            )
903
        ],
904
    ),
905
    parameters=[
906
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
907
    ],
908
    responses={
909
        "200": OpenApiResponse(description="Call status was processed."),
910
        "400": OpenApiResponse(
911
            description="Required parameters are incorrect or missing."
912
        ),
913
    },
914
)
915
@decorators.api_view(["POST"])
1✔
916
@decorators.permission_classes([permissions.AllowAny])
1✔
917
def voice_status(request):
1✔
918
    """
919
    Twilio callback for voice call status.
920

921
    When the call is complete, the user's remaining monthly time is updated, and
922
    the call is deleted from Twilio logs.
923
    """
924
    incr_if_enabled("phones_voice_status")
1✔
925
    _validate_twilio_request(request)
1✔
926
    call_sid = request.data.get("CallSid", None)
1✔
927
    called = request.data.get("Called", None)
1✔
928
    call_status = request.data.get("CallStatus", None)
1✔
929
    if call_sid is None or called is None or call_status is None:
1✔
930
        raise exceptions.ValidationError("Call data missing Called, CallStatus")
1✔
931
    if call_status != "completed":
1✔
932
        return response.Response(status=200)
1✔
933
    call_duration = request.data.get("CallDuration", None)
1✔
934
    if call_duration is None:
1✔
935
        raise exceptions.ValidationError("completed call data missing CallDuration")
1✔
936
    relay_number, _ = _get_phone_objects(called)
1✔
937
    relay_number.remaining_seconds = relay_number.remaining_seconds - int(call_duration)
1✔
938
    relay_number.save()
1✔
939
    if relay_number.remaining_seconds < 0:
1✔
940
        info_logger.info(
1✔
941
            "phone_limit_exceeded",
942
            extra={
943
                "fxa_uid": relay_number.user.profile.fxa.uid,
944
                "call_duration_in_seconds": int(call_duration),
945
                "relay_number_enabled": relay_number.enabled,
946
                "remaining_seconds": relay_number.remaining_seconds,
947
                "remaining_minutes": relay_number.remaining_minutes,
948
            },
949
        )
950
    client = twilio_client()
1✔
951
    client.calls(call_sid).delete()
1✔
952
    return response.Response(status=200)
1✔
953

954

955
@extend_schema(
1✔
956
    tags=["phones: Twilio"],
957
    request=OpenApiRequest(
958
        TwilioSmsStatusSerializer,
959
        examples=[
960
            OpenApiExample(
961
                "SMS is delivered",
962
                {"SmsStatus": "delivered", "MessageSid": "SM" + "x" * 32},
963
            )
964
        ],
965
    ),
966
    parameters=[
967
        OpenApiParameter(name="X-Twilio-Signature", required=True, location="header"),
968
    ],
969
    responses={
970
        "200": OpenApiResponse(description="SMS status was processed."),
971
        "400": OpenApiResponse(
972
            description="Required parameters are incorrect or missing."
973
        ),
974
    },
975
)
976
@decorators.api_view(["POST"])
1✔
977
@decorators.permission_classes([permissions.AllowAny])
1✔
978
def sms_status(request):
1✔
979
    """
980
    Twilio callback for SMS status.
981

982
    When the message is delivered, this calls Twilio to delete the message from logs.
983
    """
984
    _validate_twilio_request(request)
1✔
985
    sms_status = request.data.get("SmsStatus", None)
1✔
986
    message_sid = request.data.get("MessageSid", None)
1✔
987
    if sms_status is None or message_sid is None:
1✔
988
        raise exceptions.ValidationError(
1✔
989
            "Text status data missing SmsStatus or MessageSid"
990
        )
991
    if sms_status != "delivered":
1✔
992
        return response.Response(status=200)
1✔
993
    client = twilio_client()
1✔
994
    message = client.messages(message_sid)
1✔
995
    _try_delete_from_twilio(message)
1✔
996
    return response.Response(status=200)
1✔
997

998

999
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1000
@extend_schema(
1✔
1001
    tags=["phones: Outbound"],
1002
    request=OpenApiRequest(
1003
        OutboundCallSerializer,
1004
        examples=[OpenApiExample("request", {"to": "+13035556789"})],
1005
    ),
1006
    responses={
1007
        200: OpenApiResponse(description="Call initiated."),
1008
        400: OpenApiResponse(
1009
            description="Input error, or user does not have a Relay phone."
1010
        ),
1011
        401: OpenApiResponse(description="Authentication required."),
1012
        403: OpenApiResponse(
1013
            description="User does not have 'outbound_phone' waffle flag."
1014
        ),
1015
    },
1016
)
1017
@decorators.api_view(["POST"])
1✔
1018
def outbound_call(request):
1✔
1019
    """Make a call from the authenticated user's relay number."""
1020
    # TODO: Create or update an OutboundContact (new model) on send, or limit
1021
    # to InboundContacts.
1022
    if not flag_is_active(request, "outbound_phone"):
1✔
1023
        # Return Permission Denied error
1024
        return response.Response(
1✔
1025
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1026
        )
1027
    try:
1✔
1028
        real_phone = RealPhone.objects.get(user=request.user, verified=True)
1✔
1029
    except RealPhone.DoesNotExist:
1✔
1030
        return response.Response(
1✔
1031
            {"detail": "Requires a verified real phone and phone mask."}, status=400
1032
        )
1033
    try:
1✔
1034
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1035
    except RelayNumber.DoesNotExist:
1✔
1036
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1037

1038
    client = twilio_client()
1✔
1039

1040
    to = _validate_number(request, "to")  # Raises ValidationError on invalid number
1✔
1041
    client.calls.create(
1✔
1042
        twiml=(
1043
            f"<Response><Say>Dialing {to.national_format} ...</Say>"
1044
            f"<Dial>{to.phone_number}</Dial></Response>"
1045
        ),
1046
        to=real_phone.number,
1047
        from_=relay_number.number,
1048
    )
1049
    return response.Response(status=200)
1✔
1050

1051

1052
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1053
@extend_schema(
1✔
1054
    tags=["phones: Outbound"],
1055
    request=OpenApiRequest(
1056
        OutboundSmsSerializer,
1057
        examples=[
1058
            OpenApiExample("request", {"body": "Hello!", "destination": "+13045554567"})
1059
        ],
1060
    ),
1061
    responses={
1062
        200: OpenApiResponse(description="Message sent."),
1063
        400: OpenApiResponse(
1064
            description="Input error, or user does not have a Relay phone."
1065
        ),
1066
        401: OpenApiResponse(description="Authentication required."),
1067
        403: OpenApiResponse(
1068
            description="User does not have 'outbound_phone' waffle flag."
1069
        ),
1070
    },
1071
)
1072
@decorators.api_view(["POST"])
1✔
1073
def outbound_sms(request):
1✔
1074
    """
1075
    Send a message from the user's relay number.
1076

1077
    POST params:
1078
        body: the body of the message
1079
        destination: E.164-formatted phone number
1080

1081
    """
1082
    # TODO: Create or update an OutboundContact (new model) on send, or limit
1083
    # to InboundContacts.
1084
    # TODO: Reduce user's SMS messages for the month by one
1085
    if not flag_is_active(request, "outbound_phone"):
1✔
1086
        return response.Response(
1✔
1087
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1088
        )
1089
    try:
1✔
1090
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1091
    except RelayNumber.DoesNotExist:
1✔
1092
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1093

1094
    errors = {}
1✔
1095
    body = request.data.get("body")
1✔
1096
    if not body:
1✔
1097
        errors["body"] = "A message body is required."
1✔
1098
    destination_number = request.data.get("destination")
1✔
1099
    if not destination_number:
1✔
1100
        errors["destination"] = "A destination number is required."
1✔
1101
    if errors:
1✔
1102
        return response.Response(errors, status=400)
1✔
1103

1104
    # Raises ValidationError on invalid number
1105
    to = _validate_number(request, "destination")
1✔
1106

1107
    client = twilio_client()
1✔
1108
    client.messages.create(from_=relay_number.number, body=body, to=to.phone_number)
1✔
1109
    return response.Response(status=200)
1✔
1110

1111

1112
@decorators.permission_classes([permissions.IsAuthenticated, HasPhoneService])
1✔
1113
@extend_schema(
1✔
1114
    tags=["phones: Outbound"],
1115
    parameters=[
1116
        OpenApiParameter(
1117
            name="with",
1118
            description="filter to messages with the given E.164 number",
1119
        ),
1120
        OpenApiParameter(
1121
            name="direction",
1122
            enum=["inbound", "outbound"],
1123
            description="filter to inbound or outbound messages",
1124
        ),
1125
    ],
1126
    responses={
1127
        "200": OpenApiResponse(
1128
            TwilioMessagesSerializer(many=True),
1129
            description="A list of the user's SMS messages.",
1130
            examples=[
1131
                OpenApiExample(
1132
                    "success",
1133
                    {
1134
                        "to": "+13035556789",
1135
                        "date_sent": datetime.now(UTC).isoformat(),
1136
                        "body": "Hello!",
1137
                        "from": "+14045556789",
1138
                    },
1139
                )
1140
            ],
1141
        ),
1142
        "400": OpenApiResponse(description="Unable to complete request."),
1143
        "403": OpenApiResponse(
1144
            description="Caller does not have 'outbound_phone' waffle flag."
1145
        ),
1146
    },
1147
)
1148
@decorators.api_view(["GET"])
1✔
1149
def list_messages(request):
1✔
1150
    """
1151
    Get the user's SMS messages sent to or from the phone mask
1152

1153
    Pass ?with=<E.164> parameter to filter the messages to only the ones sent between
1154
    the phone mask and the <E.164> number.
1155

1156
    Pass ?direction=inbound|outbound to filter the messages to only the inbound or
1157
    outbound messages. If omitted, return both.
1158
    """
1159
    # TODO: Support filtering to messages for outbound-only phones.
1160
    # TODO: Show data from our own (encrypted) store, rather than from Twilio's
1161

1162
    if not flag_is_active(request, "outbound_phone"):
1✔
1163
        return response.Response(
1✔
1164
            {"detail": "Requires outbound_phone waffle flag."}, status=403
1165
        )
1166
    try:
1✔
1167
        relay_number = RelayNumber.objects.get(user=request.user)
1✔
1168
    except RelayNumber.DoesNotExist:
1✔
1169
        return response.Response({"detail": "Requires a phone mask."}, status=400)
1✔
1170

1171
    _with = request.query_params.get("with", None)
1✔
1172
    _direction = request.query_params.get("direction", None)
1✔
1173
    if _direction and _direction not in ("inbound", "outbound"):
1✔
1174
        return response.Response(
1✔
1175
            {"direction": "Invalid value, valid values are 'inbound' or 'outbound'"},
1176
            status=400,
1177
        )
1178

1179
    contact = None
1✔
1180
    if _with:
1✔
1181
        try:
1✔
1182
            contact = InboundContact.objects.get(
1✔
1183
                relay_number=relay_number, inbound_number=_with
1184
            )
1185
        except InboundContact.DoesNotExist:
1✔
1186
            return response.Response(
1✔
1187
                {"with": "No inbound contacts matching the number"}, status=400
1188
            )
1189

1190
    data = {}
1✔
1191
    client = twilio_client()
1✔
1192
    if not _direction or _direction == "inbound":
1✔
1193
        # Query Twilio for SMS messages to the user's phone mask
1194
        params = {"to": relay_number.number}
1✔
1195
        if contact:
1✔
1196
            # Filter query to SMS from this contact to the phone mask
1197
            params["from_"] = contact.inbound_number
1✔
1198
        data["inbound_messages"] = convert_twilio_messages_to_dict(
1✔
1199
            client.messages.list(**params)
1200
        )
1201
    if not _direction or _direction == "outbound":
1✔
1202
        # Query Twilio for SMS messages from the user's phone mask
1203
        params = {"from_": relay_number.number}
1✔
1204
        if contact:
1✔
1205
            # Filter query to SMS from the phone mask to this contact
1206
            params["to"] = contact.inbound_number
1✔
1207
        data["outbound_messages"] = convert_twilio_messages_to_dict(
1✔
1208
            client.messages.list(**params)
1209
        )
1210
    return response.Response(data, status=200)
1✔
1211

1212

1213
def _get_phone_objects(inbound_to):
1✔
1214
    # Get RelayNumber and RealPhone
1215
    try:
1✔
1216
        relay_number = RelayNumber.objects.get(number=inbound_to)
1✔
1217
        real_phone = RealPhone.objects.get(user=relay_number.user, verified=True)
1✔
1218
    except ObjectDoesNotExist:
1✔
1219
        raise exceptions.ValidationError("Could not find relay number.")
1✔
1220

1221
    return relay_number, real_phone
1✔
1222

1223

1224
class RelaySMSException(Exception):
1✔
1225
    """
1226
    Base class for exceptions when handling SMS messages.
1227

1228
    Modeled after restframework.APIException, but without a status_code.
1229

1230
    TODO MPP-3722: Refactor to a common base class with api.exceptions.RelayAPIException
1231
    """
1232

1233
    critical: bool
1✔
1234
    default_code: str
1✔
1235
    default_detail: str | None = None
1✔
1236
    default_detail_template: str | None = None
1✔
1237

1238
    def __init__(self, critical=False, *args, **kwargs):
1✔
1239
        self.critical = critical
1✔
1240
        assert (
1✔
1241
            self.default_detail is not None and self.default_detail_template is None
1242
        ) or (self.default_detail is None and self.default_detail_template is not None)
1243
        super().__init__(*args, **kwargs)
1✔
1244

1245
    @property
1✔
1246
    def detail(self):
1✔
1247
        if self.default_detail:
1✔
1248
            return self.default_detail
1✔
1249
        else:
1250
            assert self.default_detail_template is not None
1✔
1251
            return self.default_detail_template.format(**self.error_context())
1✔
1252

1253
    def get_codes(self):
1✔
1254
        return self.default_code
1✔
1255

1256
    def error_context(self) -> ErrorContextType:
1✔
1257
        """Return context variables for client-side translation."""
1258
        return {}
1✔
1259

1260

1261
class NoPhoneLog(RelaySMSException):
1✔
1262
    default_code = "no_phone_log"
1✔
1263
    default_detail_template = (
1✔
1264
        "To reply, you must allow Firefox Relay to keep a log of your callers"
1265
        " and text senders. You can update this under “Caller and texts log” here:"
1266
        "{account_settings_url}."
1267
    )
1268

1269
    def error_context(self) -> ErrorContextType:
1✔
1270
        return {
1✔
1271
            "account_settings_url": f"{settings.SITE_ORIGIN or ''}/accounts/settings/"
1272
        }
1273

1274

1275
class NoPreviousSender(RelaySMSException):
1✔
1276
    default_code = "no_previous_sender"
1✔
1277
    default_detail = (
1✔
1278
        "Message failed to send. You can only reply to phone numbers that have sent"
1279
        " you a text message."
1280
    )
1281

1282

1283
class ShortPrefixException(RelaySMSException):
1✔
1284
    """Base exception for short prefix exceptions"""
1285

1286
    def __init__(self, short_prefix: str, *args, **kwargs):
1✔
1287
        self.short_prefix = short_prefix
1✔
1288
        super().__init__(*args, **kwargs)
1✔
1289

1290
    def error_context(self) -> ErrorContextType:
1✔
1291
        return {"short_prefix": self.short_prefix}
1✔
1292

1293

1294
class FullNumberException(RelaySMSException):
1✔
1295
    """Base exception for full number exceptions"""
1296

1297
    def __init__(self, full_number: str, *args, **kwargs):
1✔
1298
        self.full_number = full_number
1✔
1299
        super().__init__(*args, **kwargs)
1✔
1300

1301
    def error_context(self) -> ErrorContextType:
1✔
1302
        return {"full_number": self.full_number}
1✔
1303

1304

1305
class ShortPrefixMatchesNoSenders(ShortPrefixException):
1✔
1306
    default_code = "short_prefix_matches_no_senders"
1✔
1307
    default_detail_template = (
1✔
1308
        "Message failed to send. There is no phone number in this thread ending"
1309
        " in {short_prefix}. Please check the number and try again."
1310
    )
1311

1312

1313
class FullNumberMatchesNoSenders(FullNumberException):
1✔
1314
    default_code = "full_number_matches_no_senders"
1✔
1315
    default_detail_template = (
1✔
1316
        "Message failed to send. There is no previous sender with the phone"
1317
        " number {full_number}. Please check the number and try again."
1318
    )
1319

1320

1321
class MultipleNumberMatches(ShortPrefixException):
1✔
1322
    default_code = "multiple_number_matches"
1✔
1323
    default_detail_template = (
1✔
1324
        "Message failed to send. There is more than one phone number in this"
1325
        " thread ending in {short_prefix}. To retry, start your message with"
1326
        " the complete number."
1327
    )
1328

1329

1330
class NoBodyAfterShortPrefix(ShortPrefixException):
1✔
1331
    default_code = "no_body_after_short_prefix"
1✔
1332
    default_detail_template = (
1✔
1333
        "Message failed to send. Please include a message after the sender identifier"
1334
        " {short_prefix}."
1335
    )
1336

1337

1338
class NoBodyAfterFullNumber(FullNumberException):
1✔
1339
    default_code = "no_body_after_full_number"
1✔
1340
    default_detail_template = (
1✔
1341
        "Message failed to send. Please include a message after the phone number"
1342
        " {full_number}."
1343
    )
1344

1345

1346
def _prepare_sms_reply(
1✔
1347
    relay_number: RelayNumber, inbound_body: str
1348
) -> tuple[RelayNumber, str, str]:
1349
    incr_if_enabled("phones_handle_sms_reply")
1✔
1350
    if not relay_number.storing_phone_log:
1✔
1351
        # We do not store user's contacts in our database
1352
        raise NoPhoneLog(critical=True)
1✔
1353

1354
    match = _match_senders_by_prefix(relay_number, inbound_body)
1✔
1355

1356
    # Fail if prefix match is ambiguous
1357
    if match and not match.contacts and match.match_type == "short":
1✔
1358
        raise ShortPrefixMatchesNoSenders(short_prefix=match.detected)
1✔
1359
    if match and not match.contacts and match.match_type == "full":
1✔
1360
        raise FullNumberMatchesNoSenders(full_number=match.detected)
1✔
1361
    if match and len(match.contacts) > 1:
1✔
1362
        assert match.match_type == "short"
1✔
1363
        raise MultipleNumberMatches(short_prefix=match.detected)
1✔
1364

1365
    # Determine the destination number
1366
    destination_number: str | None = None
1✔
1367
    if match:
1✔
1368
        # Use the sender matched by the prefix
1369
        assert len(match.contacts) == 1
1✔
1370
        destination_number = match.contacts[0].inbound_number
1✔
1371
    else:
1372
        # No prefix, default to last sender if any
1373
        last_sender = get_last_text_sender(relay_number)
1✔
1374
        destination_number = getattr(last_sender, "inbound_number", None)
1✔
1375

1376
    # Fail if no last sender
1377
    if destination_number is None:
1✔
1378
        raise NoPreviousSender(critical=True)
1✔
1379

1380
    # Determine the message body
1381
    if match:
1✔
1382
        body = inbound_body.removeprefix(match.prefix)
1✔
1383
    else:
1384
        body = inbound_body
1✔
1385

1386
    # Fail if the prefix matches a sender, but there is no body to send
1387
    if match and not body and match.match_type == "short":
1✔
1388
        raise NoBodyAfterShortPrefix(short_prefix=match.detected)
1✔
1389
    if match and not body and match.match_type == "full":
1✔
1390
        raise NoBodyAfterFullNumber(full_number=match.detected)
1✔
1391

1392
    return (relay_number, destination_number, body)
1✔
1393

1394

1395
@dataclass
1✔
1396
class MatchByPrefix:
1✔
1397
    """Details of parsing a text message for a prefix."""
1398

1399
    # Was it matched by short code or full number?
1400
    match_type: Literal["short", "full"]
1✔
1401
    # The prefix portion of the text message
1402
    prefix: str
1✔
1403
    # The detected short code or full number
1404
    detected: str
1✔
1405
    # The matching numbers, as e.164 strings, empty if None
1406
    numbers: list[str] = field(default_factory=list)
1✔
1407

1408

1409
@dataclass
1✔
1410
class MatchData(MatchByPrefix):
1✔
1411
    """Details of expanding a MatchByPrefix with InboundContacts."""
1412

1413
    # The matching InboundContacts
1414
    contacts: list[InboundContact] = field(default_factory=list)
1✔
1415

1416

1417
def _match_senders_by_prefix(relay_number: RelayNumber, text: str) -> MatchData | None:
1✔
1418
    """
1419
    Match a prefix to previous InboundContact(s).
1420

1421
    If no prefix was found, returns None
1422
    If a prefix was found, a MatchData object has details and matching InboundContacts
1423
    """
1424
    multi_replies_flag, _ = get_waffle_flag_model().objects.get_or_create(
1✔
1425
        name="multi_replies",
1426
        defaults={
1427
            "note": (
1428
                "MPP-2252: Use prefix on SMS text to specify the recipient,"
1429
                " rather than default of last contact."
1430
            )
1431
        },
1432
    )
1433

1434
    if (
1✔
1435
        multi_replies_flag.is_active_for_user(relay_number.user)
1436
        or multi_replies_flag.everyone
1437
    ):
1438
        # Load all the previous contacts, collect possible countries
1439
        contacts = InboundContact.objects.filter(relay_number=relay_number).all()
1✔
1440
        contacts_by_number: dict[str, InboundContact] = {}
1✔
1441
        for contact in contacts:
1✔
1442
            # TODO: don't default to US when we support other regions
1443
            pn = phonenumbers.parse(contact.inbound_number, "US")
1✔
1444
            e164 = phonenumbers.format_number(pn, phonenumbers.PhoneNumberFormat.E164)
1✔
1445
            if e164 not in contacts_by_number:
1!
1446
                contacts_by_number[e164] = contact
1✔
1447

1448
        match = _match_by_prefix(text, set(contacts_by_number.keys()))
1✔
1449
        if match:
1✔
1450
            return MatchData(
1✔
1451
                contacts=[contacts_by_number[num] for num in match.numbers],
1452
                **asdict(match),
1453
            )
1454
    return None
1✔
1455

1456

1457
_SMS_SHORT_PREFIX_RE = re.compile(
1✔
1458
    r"""
1459
^               # Start of string
1460
\s*             # One or more spaces
1461
\d{4}           # 4 digits
1462
\s*             # Optional whitespace
1463
[:]?     # At most one separator, sync with SMS_SEPARATORS below
1464
\s*             # Trailing whitespace
1465
""",
1466
    re.VERBOSE | re.ASCII,
1467
)
1468
_SMS_SEPARATORS = set(":")  # Sync with SMS_SHORT_PREFIX_RE above
1✔
1469

1470

1471
def _match_by_prefix(text: str, candidate_numbers: set[str]) -> MatchByPrefix | None:
1✔
1472
    """
1473
    Look for a prefix in a text message matching a set of candidate numbers.
1474

1475
    Arguments:
1476
    * A SMS text message
1477
    * A set of phone numbers in E.164 format
1478

1479
    Return None if no prefix was found, or MatchByPrefix with likely match(es)
1480
    """
1481
    # Gather potential region codes, needed by PhoneNumberMatcher
1482
    region_codes = set()
1✔
1483
    for candidate_number in candidate_numbers:
1✔
1484
        pn = phonenumbers.parse(candidate_number)
1✔
1485
        if pn.country_code:
1!
1486
            region_codes |= set(
1✔
1487
                phonenumbers.region_codes_for_country_code(pn.country_code)
1488
            )
1489

1490
    # Determine where the message may start
1491
    #  PhoneNumberMatcher doesn't work well with a number directly followed by text,
1492
    #  so just feed it the start of the message that _may_ be a number.
1493
    msg_start = 0
1✔
1494
    phone_characters = set(string.digits + string.punctuation + string.whitespace)
1✔
1495
    while msg_start < len(text) and text[msg_start] in phone_characters:
1✔
1496
        msg_start += 1
1✔
1497

1498
    # Does PhoneNumberMatcher detect a full number at start of message?
1499
    text_to_match = text[:msg_start]
1✔
1500
    for region_code in region_codes:
1✔
1501
        for match in phonenumbers.PhoneNumberMatcher(text_to_match, region_code):
1✔
1502
            e164 = phonenumbers.format_number(
1✔
1503
                match.number, phonenumbers.PhoneNumberFormat.E164
1504
            )
1505

1506
            # Look for end of prefix
1507
            end = match.start + len(match.raw_string)
1✔
1508
            found_one_sep = False
1✔
1509
            while True:
1✔
1510
                if end >= len(text):
1✔
1511
                    break
1✔
1512
                elif text[end].isspace():
1✔
1513
                    end += 1
1✔
1514
                elif text[end] in _SMS_SEPARATORS and not found_one_sep:
1✔
1515
                    found_one_sep = True
1✔
1516
                    end += 1
1✔
1517
                else:
1518
                    break
1✔
1519

1520
            prefix = text[:end]
1✔
1521
            if e164 in candidate_numbers:
1✔
1522
                numbers = [e164]
1✔
1523
            else:
1524
                numbers = []
1✔
1525
            return MatchByPrefix(
1✔
1526
                match_type="full", prefix=prefix, detected=e164, numbers=numbers
1527
            )
1528

1529
    # Is there a short prefix? Return all contacts whose last 4 digits match.
1530
    text_prefix_match = _SMS_SHORT_PREFIX_RE.match(text)
1✔
1531
    if text_prefix_match:
1✔
1532
        text_prefix = text_prefix_match.group(0)
1✔
1533
        digits = set(string.digits)
1✔
1534
        digit_suffix = "".join(digit for digit in text_prefix if digit in digits)
1✔
1535
        numbers = [e164 for e164 in candidate_numbers if e164[-4:] == digit_suffix]
1✔
1536
        return MatchByPrefix(
1✔
1537
            match_type="short",
1538
            prefix=text_prefix,
1539
            detected=digit_suffix,
1540
            numbers=sorted(numbers),
1541
        )
1542

1543
    # No prefix detected
1544
    return None
1✔
1545

1546

1547
def _check_disabled(relay_number, contact_type):
1✔
1548
    # Check if RelayNumber is disabled
1549
    if not relay_number.enabled:
1✔
1550
        attr = f"{contact_type}_blocked"
1✔
1551
        incr_if_enabled(f"phones_{contact_type}_global_blocked")
1✔
1552
        setattr(relay_number, attr, getattr(relay_number, attr) + 1)
1✔
1553
        relay_number.save()
1✔
1554
        return True
1✔
1555

1556

1557
def _check_remaining(relay_number, resource_type):
1✔
1558
    # Check the owner of the relay number (still) has phone service
1559
    if not relay_number.user.profile.has_phone:
1!
1560
        raise exceptions.ValidationError("Number owner does not have phone service")
×
1561
    model_attr = f"remaining_{resource_type}"
1✔
1562
    if getattr(relay_number, model_attr) <= 0:
1✔
1563
        incr_if_enabled(f"phones_out_of_{resource_type}")
1✔
1564
        raise exceptions.ValidationError(f"Number is out of {resource_type}.")
1✔
1565
    return True
1✔
1566

1567

1568
def _get_inbound_contact(relay_number, inbound_from):
1✔
1569
    # Check if RelayNumber is storing phone log
1570
    if not relay_number.storing_phone_log:
1✔
1571
        return None
1✔
1572

1573
    # Check if RelayNumber is blocking this inbound_from
1574
    inbound_contact, _ = InboundContact.objects.get_or_create(
1✔
1575
        relay_number=relay_number, inbound_number=inbound_from
1576
    )
1577
    return inbound_contact
1✔
1578

1579

1580
def _check_and_update_contact(inbound_contact, contact_type, relay_number):
1✔
1581
    if inbound_contact.blocked:
1✔
1582
        incr_if_enabled(f"phones_{contact_type}_specific_blocked")
1✔
1583
        contact_attr = f"num_{contact_type}_blocked"
1✔
1584
        setattr(
1✔
1585
            inbound_contact, contact_attr, getattr(inbound_contact, contact_attr) + 1
1586
        )
1587
        inbound_contact.save()
1✔
1588
        relay_attr = f"{contact_type}_blocked"
1✔
1589
        setattr(relay_number, relay_attr, getattr(relay_number, relay_attr) + 1)
1✔
1590
        relay_number.save()
1✔
1591
        raise exceptions.ValidationError(f"Number is not accepting {contact_type}.")
1✔
1592

1593
    inbound_contact.last_inbound_date = datetime.now(UTC)
1✔
1594
    singular_contact_type = contact_type[:-1]  # strip trailing "s"
1✔
1595
    inbound_contact.last_inbound_type = singular_contact_type
1✔
1596
    attr = f"num_{contact_type}"
1✔
1597
    setattr(inbound_contact, attr, getattr(inbound_contact, attr) + 1)
1✔
1598
    last_date_attr = f"last_{singular_contact_type}_date"
1✔
1599
    setattr(inbound_contact, last_date_attr, inbound_contact.last_inbound_date)
1✔
1600
    inbound_contact.save()
1✔
1601

1602

1603
def _validate_twilio_request(request):
1✔
1604
    if "X-Twilio-Signature" not in request._request.headers:
1✔
1605
        raise exceptions.ValidationError(
1✔
1606
            "Invalid request: missing X-Twilio-Signature header."
1607
        )
1608

1609
    url = request._request.build_absolute_uri()
1✔
1610
    sorted_params = {}
1✔
1611
    for param_key in sorted(request.data):
1✔
1612
        sorted_params[param_key] = request.data.get(param_key)
1✔
1613
    request_signature = request._request.headers["X-Twilio-Signature"]
1✔
1614
    validator = twilio_validator()
1✔
1615
    if not validator.validate(url, sorted_params, request_signature):
1✔
1616
        incr_if_enabled("phones_invalid_twilio_signature")
1✔
1617
        raise exceptions.ValidationError("Invalid request: invalid signature")
1✔
1618

1619

1620
def compute_iq_mac(message_id: str) -> str:
1✔
1621
    iq_api_key = settings.IQ_INBOUND_API_KEY
×
1622
    # FIXME: switch to proper hmac when iQ is ready
1623
    # mac = hmac.new(
1624
    #     iq_api_key.encode(), msg=message_id.encode(), digestmod=hashlib.sha256
1625
    # )
1626
    combined = iq_api_key + message_id
×
1627
    return hashlib.sha256(combined.encode()).hexdigest()
×
1628

1629

1630
def _validate_iq_request(request: Request) -> None:
1✔
1631
    if "Verificationtoken" not in request._request.headers:
×
1632
        raise exceptions.AuthenticationFailed("missing Verificationtoken header.")
×
1633

1634
    if "MessageId" not in request._request.headers:
×
1635
        raise exceptions.AuthenticationFailed("missing MessageId header.")
×
1636

1637
    message_id = request._request.headers["Messageid"]
×
1638
    mac = compute_iq_mac(message_id)
×
1639

1640
    token = request._request.headers["verificationToken"]
×
1641

1642
    if mac != token:
×
1643
        raise exceptions.AuthenticationFailed("verficiationToken != computed sha256")
×
1644

1645

1646
def convert_twilio_messages_to_dict(twilio_messages):
1✔
1647
    """
1648
    To serialize twilio messages to JSON for the API,
1649
    we need to convert them into dictionaries.
1650
    """
1651
    messages_as_dicts = []
1✔
1652
    for twilio_message in twilio_messages:
1✔
1653
        message = {}
1✔
1654
        message["from"] = twilio_message.from_
1✔
1655
        message["to"] = twilio_message.to
1✔
1656
        message["date_sent"] = twilio_message.date_sent
1✔
1657
        message["body"] = twilio_message.body
1✔
1658
        messages_as_dicts.append(message)
1✔
1659
    return messages_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