From 32764c95d19327282c33d11299a43c2b61292206 Mon Sep 17 00:00:00 2001 From: groovecoder Date: Sat, 21 Dec 2019 23:53:47 -0600 Subject: [PATCH] initial twilio proxy code --- .env-dist | 3 + phones/__init__.py | 0 phones/admin.py | 10 ++ phones/apps.py | 5 + phones/migrations/0001_initial.py | 22 +++ .../0002_session_initiating_real_number.py | 19 +++ ...0003_session_initiating_participant_sid.py | 19 +++ phones/migrations/0004_auto_20191223_1815.py | 24 +++ phones/migrations/__init__.py | 0 phones/models.py | 9 ++ phones/sequence-diagram.mmd | 17 +++ phones/sequence-diagram.svg | 4 + phones/tests.py | 3 + phones/urls.py | 9 ++ phones/views.py | 140 ++++++++++++++++++ privaterelay/settings.py | 1 + privaterelay/urls.py | 1 + requirements.txt | 4 +- 18 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 phones/__init__.py create mode 100644 phones/admin.py create mode 100644 phones/apps.py create mode 100644 phones/migrations/0001_initial.py create mode 100644 phones/migrations/0002_session_initiating_real_number.py create mode 100644 phones/migrations/0003_session_initiating_participant_sid.py create mode 100644 phones/migrations/0004_auto_20191223_1815.py create mode 100644 phones/migrations/__init__.py create mode 100644 phones/models.py create mode 100644 phones/sequence-diagram.mmd create mode 100644 phones/sequence-diagram.svg create mode 100644 phones/tests.py create mode 100644 phones/urls.py create mode 100644 phones/views.py diff --git a/.env-dist b/.env-dist index 1d698f56d..3c8159546 100644 --- a/.env-dist +++ b/.env-dist @@ -2,3 +2,6 @@ FXA_OAUTH_ENDPOINT=https://oauth-stable.dev.lcip.org/v1 FXA_PROFILE_ENDPOINT=https://stable.dev.lcip.org/profile/v1 SECRET_KEY= SENDGRID_API_KEY= +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_SERVICE_ID= diff --git a/phones/__init__.py b/phones/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/phones/admin.py b/phones/admin.py new file mode 100644 index 000000000..5583dbf8c --- /dev/null +++ b/phones/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from .models import Session + + +class SessionAdmin(admin.ModelAdmin): + pass + + +admin.site.register(Session, SessionAdmin) diff --git a/phones/apps.py b/phones/apps.py new file mode 100644 index 000000000..46bb7dd65 --- /dev/null +++ b/phones/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PhonesConfig(AppConfig): + name = 'phones' diff --git a/phones/migrations/0001_initial.py b/phones/migrations/0001_initial.py new file mode 100644 index 000000000..4e60d53b3 --- /dev/null +++ b/phones/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.9 on 2019-12-22 19:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Session', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('twilio_sid', models.CharField(max_length=34, unique=True)), + ('initiating_proxy_number', models.CharField(max_length=20)), + ], + ), + ] diff --git a/phones/migrations/0002_session_initiating_real_number.py b/phones/migrations/0002_session_initiating_real_number.py new file mode 100644 index 000000000..c129a95d9 --- /dev/null +++ b/phones/migrations/0002_session_initiating_real_number.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.9 on 2019-12-22 20:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('phones', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='initiating_real_number', + field=models.CharField(default='', max_length=20), + preserve_default=False, + ), + ] diff --git a/phones/migrations/0003_session_initiating_participant_sid.py b/phones/migrations/0003_session_initiating_participant_sid.py new file mode 100644 index 000000000..7243fd4a9 --- /dev/null +++ b/phones/migrations/0003_session_initiating_participant_sid.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.9 on 2019-12-23 15:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('phones', '0002_session_initiating_real_number'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='initiating_participant_sid', + field=models.CharField(default='', max_length=20), + preserve_default=False, + ), + ] diff --git a/phones/migrations/0004_auto_20191223_1815.py b/phones/migrations/0004_auto_20191223_1815.py new file mode 100644 index 000000000..4a9d5d728 --- /dev/null +++ b/phones/migrations/0004_auto_20191223_1815.py @@ -0,0 +1,24 @@ +# Generated by Django 2.2.9 on 2019-12-23 18:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('phones', '0003_session_initiating_participant_sid'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='status', + field=models.CharField(default='', max_length=20), + preserve_default=False, + ), + migrations.AlterField( + model_name='session', + name='initiating_participant_sid', + field=models.CharField(max_length=34), + ), + ] diff --git a/phones/migrations/__init__.py b/phones/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/phones/models.py b/phones/models.py new file mode 100644 index 000000000..52780d2b5 --- /dev/null +++ b/phones/models.py @@ -0,0 +1,9 @@ +from django.db import models + + +class Session(models.Model): + twilio_sid = models.CharField(max_length=34, unique=True, blank=False) + initiating_proxy_number = models.CharField(max_length=20, blank=False) + initiating_real_number = models.CharField(max_length=20, blank=False) + initiating_participant_sid = models.CharField(max_length=34, blank=False) + status = models.CharField(max_length=20, blank=False) diff --git a/phones/sequence-diagram.mmd b/phones/sequence-diagram.mmd new file mode 100644 index 000000000..546b3ab65 --- /dev/null +++ b/phones/sequence-diagram.mmd @@ -0,0 +1,17 @@ +sequenceDiagram + User->>Relay Phone Number: TXT {minutes} + Relay Phone Number->>Proxy Number: Creates {minutes} session + Relay Phone Number->>User: TXT {proxy number} + User->>3rd Party: give {proxy number} as phone number + loop proxied TXT conversation + 3rd Party->>Proxy Number: TXT + Proxy Number->>User: TXT + User->>Proxy Number: TXT + Proxy Number->>3rd Party: TXT + end + opt TODO: manual stop + User->>Proxy Number: TXT "stop" + end + opt manual reset + User->>Relay Phone Number: TXT "reset" + end diff --git a/phones/sequence-diagram.svg b/phones/sequence-diagram.svg new file mode 100644 index 000000000..853f10ee9 --- /dev/null +++ b/phones/sequence-diagram.svg @@ -0,0 +1,4 @@ +UserRelay Phone NumberProxy Number3rd PartyTXT {minutes}Creates {minutes} sessionTXT {proxy number}give {proxy number} as phone numberTXTTXTTXTTXTloop[ proxied TXT conversation ]TXT "stop"opt[ TODO: manual stop ]TXT "reset"opt[ manual reset ]UserRelay Phone NumberProxy Number3rd Party \ No newline at end of file diff --git a/phones/tests.py b/phones/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/phones/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/phones/urls.py b/phones/urls.py new file mode 100644 index 000000000..f5e95784a --- /dev/null +++ b/phones/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import views + + +urlpatterns = [ + path('main-twilio-webhook', views.main_twilio_webhook), + path('twilio-proxy-out-of-session', views.twilio_proxy_out_of_session), +] diff --git a/phones/views.py b/phones/views.py new file mode 100644 index 000000000..d2e5ae5bb --- /dev/null +++ b/phones/views.py @@ -0,0 +1,140 @@ +from decouple import config +from phonenumbers import parse, format_number +from phonenumbers import PhoneNumberFormat +from twilio.rest import Client +from twilio.twiml.messaging_response import MessagingResponse + +from django.core.exceptions import MultipleObjectsReturned +from django.http import HttpResponse, HttpResponseNotFound +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt + +from .models import Session + + +account_sid = config('TWILIO_ACCOUNT_SID', None) +auth_token = config('TWILIO_AUTH_TOKEN', None) +client = Client(account_sid, auth_token) +service = client.proxy.services(config('TWILIO_SERVICE_ID')) + + +PROMPT_MESSAGE = "For how many minutes do you need a relay number?" + + +@csrf_exempt +def main_twilio_webhook(request): + resp = MessagingResponse() + from_num = request.POST['From'] + body = request.POST['Body'].lower() + + if body == 'reset': + from_num_sessions = Session.objects.filter( + initiating_real_number=from_num + ) + for session in from_num_sessions: + service.sessions(session.twilio_sid).update(status='closed') + from_num_sessions.delete() + resp.message( + "Relay session reset. \n%s" % PROMPT_MESSAGE + ) + return HttpResponse(resp) + + # TODO: remove this check; allow users to have multiple sessions at once? + if body != 'reset': + existing_sessions = Session.objects.filter( + initiating_real_number=from_num + ) + if existing_sessions: + pretty_proxy = format_number( + parse(existing_sessions[0].initiating_proxy_number), + PhoneNumberFormat.NATIONAL + ) + resp.message( + "You already have a relay number: \n%s. \n" + "Reply 'reset' to reset it." % pretty_proxy + ) + return HttpResponse(resp) + + try: + ttl_minutes = int(body) + except ValueError: + resp.message(PROMPT_MESSAGE) + return HttpResponse(resp) + + session = service.sessions.create(ttl=ttl_minutes*60,) + participant = service.sessions(session.sid).participants.create( + identifier=from_num + ) + proxy_num = participant.proxy_identifier + + # store this half-way open session in our local DB, + # so when the next number texts the proxy number, + # we can add them as the 2nd participant and open the session + Session.objects.create( + twilio_sid = session.sid, + initiating_proxy_number=proxy_num, + initiating_real_number=from_num, + initiating_participant_sid=participant.sid, + status='waiting-for-party', + ) + + # reply back with the number and minutes it will live + pretty_from = format_number(parse(from_num), PhoneNumberFormat.NATIONAL) + pretty_proxy = format_number(parse(proxy_num), PhoneNumberFormat.NATIONAL) + resp.message( + '%s will forward to this number for %s minutes' % + (pretty_proxy, ttl_minutes) + ) + return HttpResponse(resp) + + +@csrf_exempt +def twilio_proxy_out_of_session(request): + """ + By design, Relay doesn't know who the 2nd participant is before they text + the 1st participant. + + Twilio requires both participants be added to a session before either can + communicate with the other. When the 2nd participant sends their first text + to a 1st participant, it triggers an "out-of-session" hook. + + So, we use this to add the 2nd participant to the existing session, relay + the 2nd participant's message, and the 2 parties can begin communicating + thru the proxy number. + + TODO: detect real out-of-session messages - i.e., not first messages + """ + resp = MessagingResponse() + + try: + db_session = Session.objects.get( + initiating_proxy_number=request.POST['To'], + status='waiting-for-party', + ) + except (Session.DoesNotExist, MultipleObjectsReturned) as e: + print(e) + resp.message('The person you are trying to reach is unavailable.') + return HttpResponse(resp) + + twilio_session = service.sessions(db_session.twilio_sid).fetch() + if (twilio_session.status in ['closed', 'failed', 'unknown']): + error_message = ('Twilio session %s status: %s' % + (db_session.twilio_sid, twilio_session.status)) + print(error_message) + return HttpResponseNotFound(error_message) + + from_num = request.POST['From'] + new_participant = service.sessions(twilio_session.sid).participants.create( + identifier=from_num + ) + db_session.status = 'connected-to-party' + db_session.save() + # Now that we've added the 2nd participant, + # send their first message to the 1st participant + message = ( + service.sessions(twilio_session.sid) + .participants(db_session.initiating_participant_sid) + .message_interactions.create(body=request.POST['Body']) + ) + + return HttpResponse(status=201, content="Created") diff --git a/privaterelay/settings.py b/privaterelay/settings.py index fb7801ac4..e7896fc35 100644 --- a/privaterelay/settings.py +++ b/privaterelay/settings.py @@ -62,6 +62,7 @@ INSTALLED_APPS = [ 'allauth.socialaccount.providers.fxa', 'emails.apps.EmailsConfig', + 'phones.apps.PhonesConfig', ] MIDDLEWARE = [ diff --git a/privaterelay/urls.py b/privaterelay/urls.py index f91c3e6d7..dd7ec36e1 100644 --- a/privaterelay/urls.py +++ b/privaterelay/urls.py @@ -24,5 +24,6 @@ urlpatterns = [ path('admin/', admin.site.urls), path('accounts/', include('allauth.urls')), path('emails/', include('emails.urls')), + path('phones/', include('phones.urls')), path('', views.home), ] diff --git a/requirements.txt b/requirements.txt index fe9942b75..a4c09ba36 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,10 @@ -Django==2.2.2 +Django==2.2.9 django-allauth==0.39.1 django-csp==3.5 django-heroku==0.3.1 django-referrer-policy==1.0 gunicorn==19.9.0 +phonenumbers==8.11.1 python-decouple==3.1 sendgrid==6.0.5 +twilio==6.35.1