From 855eeeb108041dde06f42642ba7f977213d44539 Mon Sep 17 00:00:00 2001 From: Alexander Metzger Date: Sat, 13 Jul 2024 20:35:23 -0700 Subject: [PATCH] refactor + remove twilio connect function --- ...integration_twilio_account_sid_and_more.py | 7 +- bots/models.py | 30 ++- bots/tasks.py | 9 + daras_ai_v2/bots.py | 57 +++-- daras_ai_v2/twilio_bot.py | 114 +++++++-- recipes/VideoBotsStats.py | 2 + routers/twilio_api.py | 240 ++++-------------- 7 files changed, 217 insertions(+), 242 deletions(-) diff --git a/bots/migrations/0077_botintegration_twilio_account_sid_and_more.py b/bots/migrations/0077_botintegration_twilio_account_sid_and_more.py index 38b89adc9..0f1550e92 100644 --- a/bots/migrations/0077_botintegration_twilio_account_sid_and_more.py +++ b/bots/migrations/0077_botintegration_twilio_account_sid_and_more.py @@ -1,6 +1,7 @@ -# Generated by Django 4.2.7 on 2024-07-08 20:35 +# Generated by Django 4.2.7 on 2024-07-11 01:30 from django.db import migrations, models +import phonenumber_field.modelfields class Migration(migrations.Migration): @@ -48,7 +49,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='botintegration', name='twilio_phone_number', - field=models.TextField(blank=True, default='', help_text='Twilio unformatted phone number as found on twilio.com/console/phone-numbers/incoming (mandatory)'), + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, default='', help_text='Twilio unformatted phone number as found on twilio.com/console/phone-numbers/incoming (mandatory)', max_length=128, region=None), ), migrations.AddField( model_name='botintegration', @@ -78,7 +79,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='conversation', name='twilio_phone_number', - field=models.TextField(blank=True, default='', help_text="User's Twilio phone number (mandatory)"), + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, default='', help_text="User's Twilio phone number (mandatory)", max_length=128, region=None), ), migrations.AlterField( model_name='botintegration', diff --git a/bots/models.py b/bots/models.py index f2c18eb24..8b8ad343a 100644 --- a/bots/models.py +++ b/bots/models.py @@ -635,7 +635,7 @@ class BotIntegration(models.Model): default="", help_text="Twilio auth token as found on twilio.com/console (mandatory)", ) - twilio_phone_number = models.TextField( + twilio_phone_number = PhoneNumberField( blank=True, default="", help_text="Twilio unformatted phone number as found on twilio.com/console/phone-numbers/incoming (mandatory)", @@ -731,7 +731,7 @@ def get_display_name(self): or " | #".join( filter(None, [self.slack_team_name, self.slack_channel_name]) ) - or self.twilio_phone_number + or (self.twilio_phone_number and self.twilio_phone_number.as_international) or self.name or ( self.platform == Platform.WEB @@ -764,6 +764,21 @@ def get_web_widget_config(self, target="#gooey-embed") -> dict: ) return config + def translate(self, text: str) -> str: + from daras_ai_v2.asr import run_google_translate, should_translate_lang + + if text and should_translate_lang(self.user_language): + active_run = self.get_active_saved_run() + return run_google_translate( + [text], + self.user_language, + glossary_url=( + active_run.state.get("output_glossary") if active_run else None + ), + )[0] + else: + return text + class BotIntegrationAnalysisRun(models.Model): bot_integration = models.ForeignKey( @@ -834,7 +849,12 @@ class ConversationQuerySet(models.QuerySet): def get_unique_users(self) -> "ConversationQuerySet": """Get unique conversations""" return self.distinct( - "fb_page_id", "ig_account_id", "wa_phone_number", "slack_user_id" + "fb_page_id", + "ig_account_id", + "wa_phone_number", + "slack_user_id", + "twilio_phone_number", + "web_user_id", ) def to_df(self, tz=pytz.timezone(settings.TIME_ZONE)) -> "pd.DataFrame": @@ -1027,7 +1047,7 @@ class Conversation(models.Model): help_text="Whether this is a personal slack channel between the bot and the user", ) - twilio_phone_number = models.TextField( + twilio_phone_number = PhoneNumberField( blank=True, default="", help_text="User's Twilio phone number (mandatory)", @@ -1078,7 +1098,7 @@ def get_display_name(self): or self.fb_page_id or self.slack_user_id or self.web_user_id - or self.twilio_phone_number + or (self.twilio_phone_number and self.twilio_phone_number.as_international) ) get_display_name.short_description = "User" diff --git a/bots/tasks.py b/bots/tasks.py index d49abd6bc..85d55d34b 100644 --- a/bots/tasks.py +++ b/bots/tasks.py @@ -23,6 +23,7 @@ create_personal_channel, SlackBot, ) +from daras_ai_v2.twilio_bot import create_voice_call, send_sms_message from daras_ai_v2.vector_search import references_as_prompt from gooeysite.bg_db_conn import get_celery_result_db_safe from recipes.VideoBots import ReplyButton, messages_as_prompt @@ -153,6 +154,7 @@ def send_broadcast_msgs_chunked( buttons: list[ReplyButton] = None, convo_qs: QuerySet[Conversation], bi: BotIntegration, + medium: str, ): convo_ids = list(convo_qs.values_list("id", flat=True)) for i in range(0, len(convo_ids), 100): @@ -164,6 +166,7 @@ def send_broadcast_msgs_chunked( documents=documents, bi_id=bi.id, convo_ids=convo_ids[i : i + 100], + medium=medium, ) @@ -177,6 +180,7 @@ def send_broadcast_msg( documents: list[str] = None, bi_id: int, convo_ids: list[int], + medium: str, ): bi = BotIntegration.objects.get(id=bi_id) convos = Conversation.objects.filter(id__in=convo_ids) @@ -205,6 +209,11 @@ def send_broadcast_msg( username=bi.name, token=bi.slack_access_token, )[0] + case Platform.TWILIO: + if medium == "Voice Call": + create_voice_call(convo, text, audio) + else: + send_sms_message(convo, text, media_url=audio) case _: raise NotImplementedError( f"Platform {bi.platform} doesn't support broadcasts yet" diff --git a/daras_ai_v2/bots.py b/daras_ai_v2/bots.py index 6096b9808..aeb0a30d8 100644 --- a/daras_ai_v2/bots.py +++ b/daras_ai_v2/bots.py @@ -84,7 +84,7 @@ class BotInterface: page_cls: typing.Type[BasePage] | None query_params: dict - language: str + language: str | None billing_account_uid: str show_feedback_buttons: bool = False streaming_enabled: bool = False @@ -140,37 +140,44 @@ def nice_filename(self, mime_type: str) -> str: return f"{self.platform.name}_{self.input_type}_from_{self.user_id}_to_{self.bot_id}{ext}" def _unpack_bot_integration(self): - bi = self.convo.bot_integration - if bi.published_run: - self.page_cls = Workflow(bi.published_run.workflow).page_cls + self.bi = self.convo.bot_integration + if self.bi.published_run: + self.page_cls = Workflow(self.bi.published_run.workflow).page_cls self.query_params = self.page_cls.clean_query_params( - example_id=bi.published_run.published_run_id, + example_id=self.bi.published_run.published_run_id, run_id="", uid="", ) - saved_run = bi.published_run.saved_run + saved_run = self.bi.published_run.saved_run self.input_glossary = saved_run.state.get("input_glossary_document") self.output_glossary = saved_run.state.get("output_glossary_document") self.language = saved_run.state.get("user_language") - elif bi.saved_run: - self.page_cls = Workflow(bi.saved_run.workflow).page_cls + elif self.bi.saved_run: + self.page_cls = Workflow(self.bi.saved_run.workflow).page_cls self.query_params = self.page_cls.clean_query_params( - example_id=bi.saved_run.example_id, - run_id=bi.saved_run.run_id, - uid=bi.saved_run.uid, + example_id=self.bi.saved_run.example_id, + run_id=self.bi.saved_run.run_id, + uid=self.bi.saved_run.uid, ) - self.input_glossary = bi.saved_run.state.get("input_glossary_document") - self.output_glossary = bi.saved_run.state.get("output_glossary_document") - self.language = bi.saved_run.state.get("user_language") + self.input_glossary = self.bi.saved_run.state.get("input_glossary_document") + self.output_glossary = self.bi.saved_run.state.get( + "output_glossary_document" + ) + self.language = self.bi.saved_run.state.get("user_language") else: self.page_cls = None self.query_params = {} - self.billing_account_uid = bi.billing_account_uid - if should_translate_lang(bi.user_language): - self.language = bi.user_language - self.show_feedback_buttons = bi.show_feedback_buttons - self.streaming_enabled = bi.streaming_enabled + self.billing_account_uid = self.bi.billing_account_uid + if should_translate_lang(self.bi.user_language): + self.language = self.bi.user_language + else: + self.language = None + self.show_feedback_buttons = self.bi.show_feedback_buttons + self.streaming_enabled = self.bi.streaming_enabled + + def translate(self, text: str | None) -> str: + return self.bi.translate(text or "") def _echo(bot, input_text): @@ -199,8 +206,18 @@ def _mock_api_output(input_text): } -@db_middleware def msg_handler(bot: BotInterface): + try: + _msg_handler(bot) + except Exception as e: + bot.send_msg( + text=bot.translate("Sorry, an error occurred. Please try again later."), + ) + capture_exception(e) + + +@db_middleware +def _msg_handler(bot: BotInterface): recieved_time: datetime = timezone.now() if not bot.page_cls: bot.send_msg(text=PAGE_NOT_CONNECTED_ERROR) diff --git a/daras_ai_v2/twilio_bot.py b/daras_ai_v2/twilio_bot.py index 31d369ab6..4eb614c7b 100644 --- a/daras_ai_v2/twilio_bot.py +++ b/daras_ai_v2/twilio_bot.py @@ -1,8 +1,13 @@ from bots.models import BotIntegration, Platform, Conversation from daras_ai_v2.bots import BotInterface, ReplyButton +from phonenumber_field.phonenumber import PhoneNumber + +from twilio.rest import Client +from twilio.twiml.voice_response import VoiceResponse +from daras_ai_v2.fastapi_tricks import get_route_url -from daras_ai_v2.asr import run_google_translate, should_translate_lang from uuid import uuid4 +import base64 class TwilioSMS(BotInterface): @@ -16,10 +21,9 @@ def __init__(self, *, sid: str, convo: Conversation, text: str, bi: BotIntegrati self.user_msg_id = sid self.bot_id = bi.id - self.user_id = convo.twilio_phone_number + self.user_id = convo.twilio_phone_number.as_e164 self._unpack_bot_integration() - self.bi = bi def get_input_text(self) -> str | None: return self._text @@ -35,15 +39,11 @@ def send_msg( should_translate: bool = False, update_msg_id: str | None = None, ) -> str | None: - from routers.twilio_api import send_sms_message - assert buttons is None, "Interactive mode is not implemented yet" assert update_msg_id is None, "Twilio does not support un-sms-ing things" - if text and should_translate and should_translate_lang(self.language): - text = run_google_translate( - [text], self.language, glossary_url=self.output_glossary - )[0] + if should_translate: + text = self.translate(text) return send_sms_message( self.convo, @@ -76,12 +76,12 @@ def __init__( try: self.convo = Conversation.objects.get( - twilio_phone_number=incoming_number, + twilio_phone_number=PhoneNumber.from_string(incoming_number), bot_integration=bi, ) except Conversation.DoesNotExist: self.convo = Conversation.objects.get_or_create( - twilio_phone_number=incoming_number, + twilio_phone_number=PhoneNumber.from_string(incoming_number), bot_integration=bi, )[0] @@ -94,7 +94,6 @@ def __init__( self._audio = audio self._unpack_bot_integration() - self.bi = bi def get_input_text(self) -> str | None: return self._text @@ -113,8 +112,6 @@ def send_msg( should_translate: bool = False, update_msg_id: str | None = None, ) -> str | None: - from routers.twilio_api import twilio_voice_call_respond - assert documents is None, "Twilio does not support sending documents via Voice" assert video is None, "Twilio does not support sending videos via Voice" assert buttons is None, "Interactive mode is not implemented yet" @@ -126,10 +123,8 @@ def send_msg( else: audio = None - if text and should_translate and should_translate_lang(self.language): - text = run_google_translate( - [text], self.language, glossary_url=self.output_glossary - )[0] + if should_translate: + text = self.translate(text) twilio_voice_call_respond( text=text, @@ -143,3 +138,86 @@ def send_msg( def mark_read(self): pass # handled in the webhook + + +def twilio_voice_call_respond( + text: str | None, + audio_url: str | None, + queue_name: str, + call_sid: str, + bi: BotIntegration, +): + """Respond to the user in the queue with the given text and audio URL.""" + from routers.twilio_api import twilio_voice_call_response + + text = text + audio_url = audio_url + text = base64.b64encode(text.encode()).decode() if text else "N" + audio_url = base64.b64encode(audio_url.encode()).decode() if audio_url else "N" + + queue_sid = None + client = Client(bi.twilio_account_sid, bi.twilio_auth_token) + for queue in client.queues.list(): + if queue.friendly_name == queue_name: + queue_sid = queue.sid + break + assert queue_sid, "Queue not found" + + client.queues(queue_sid).members(call_sid).update( + url=get_route_url( + twilio_voice_call_response, + dict(bi_id=bi.id, text=text, audio_url=audio_url), + ), + method="POST", + ) + + return queue_sid + + +def create_voice_call(convo: Conversation, text: str | None, audio_url: str | None): + """Create a new voice call saying the given text and audio URL and then hanging up. Useful for notifications.""" + from routers.twilio_api import say + + assert ( + convo.twilio_phone_number + ), "This is not a Twilio conversation, it has no phone number." + + bi: BotIntegration = convo.bot_integration + client = Client(bi.twilio_account_sid, bi.twilio_auth_token) + + resp = VoiceResponse() + if text: + say(resp, text, bi) + if audio_url: + resp.play(audio_url) + + call = client.calls.create( + twiml=str(resp), + to=convo.twilio_phone_number.as_e164, + from_=bi.twilio_phone_number.as_e164, + ) + + return call + + +def send_sms_message( + convo: Conversation, text: str | None, media_url: str | None = None +): + """Send an SMS message to the given conversation.""" + + assert ( + convo.twilio_phone_number + ), "This is not a Twilio conversation, it has no phone number." + + account_sid = convo.bot_integration.twilio_account_sid + auth_token = convo.bot_integration.twilio_auth_token + client = Client(account_sid, auth_token) + + message = client.messages.create( + body=text or "", + media_url=media_url, + from_=convo.bot_integration.twilio_phone_number.as_e164, + to=convo.twilio_phone_number.as_e164, + ) + + return message diff --git a/recipes/VideoBotsStats.py b/recipes/VideoBotsStats.py index a550c368e..b3bed4bee 100644 --- a/recipes/VideoBotsStats.py +++ b/recipes/VideoBotsStats.py @@ -42,6 +42,8 @@ "conversation__ig_account_id", "conversation__wa_phone_number", "conversation__slack_user_id", + "conversation__twilio_phone_number", + "conversation__web_user_id", ] diff --git a/routers/twilio_api.py b/routers/twilio_api.py index 928e3072d..30b0c430a 100644 --- a/routers/twilio_api.py +++ b/routers/twilio_api.py @@ -4,31 +4,17 @@ from app_users.models import AppUser from bots.models import Conversation, BotIntegration, SavedRun, PublishedRun, Platform -from daras_ai_v2.asr import run_google_translate, should_translate_lang -from daras_ai_v2 import settings +from phonenumber_field.phonenumber import PhoneNumber -from furl import furl from fastapi import APIRouter, Response from starlette.background import BackgroundTasks -from daras_ai_v2.fastapi_tricks import fastapi_request_urlencoded_body +from daras_ai_v2.fastapi_tricks import fastapi_request_urlencoded_body, get_route_url import base64 +from sentry_sdk import capture_exception router = APIRouter() -def translate(text: str, bi: BotIntegration) -> str: - if text and should_translate_lang(bi.user_language): - return run_google_translate( - [text], - bi.user_language, - glossary_url=bi.get_active_saved_run().state.get( - "output_glossary_document" - ), - )[0] - else: - return text - - def say(resp: VoiceResponse, text: str, bi: BotIntegration): """Say the given text using the bot integration's voice. If the bot integration is set to use Gooey TTS, use that instead.""" @@ -52,8 +38,9 @@ def say(resp: VoiceResponse, text: str, bi: BotIntegration): sr = page.run_doc_sr(run_id, uid) state = sr.to_dict() resp.play(state["audio_url"]) - except Exception: + except Exception as e: resp.say(text, voice=bi.twilio_voice) + capture_exception(e) else: resp.say(text, voice=bi.twilio_voice) @@ -72,17 +59,18 @@ def twilio_voice_call( try: bi = BotIntegration.objects.get( - twilio_account_sid=account_sid, twilio_phone_number=phone_number + twilio_account_sid=account_sid, + twilio_phone_number=PhoneNumber.from_string(phone_number), ) - except BotIntegration.DoesNotExist: + except BotIntegration.DoesNotExist as e: + capture_exception(e) return Response(status_code=404) text = bi.twilio_initial_text.strip() audio_url = bi.twilio_initial_audio_url.strip() if not text and not audio_url: - text = translate( + text = bi.translate( f"Welcome to {bi.name}! Please ask your question and press 0 if the end of your question isn't detected.", - bi, ) if bi.twilio_use_missed_call: @@ -100,7 +88,10 @@ def twilio_voice_call( resp = VoiceResponse() resp.redirect( - url_for(twilio_voice_call_response, bi_id=bi.id, text=text, audio_url=audio_url) + get_route_url( + twilio_voice_call_response, + dict(bi_id=bi.id, text=text, audio_url=audio_url), + ) ) return Response(str(resp), headers={"Content-Type": "text/xml"}) @@ -126,7 +117,8 @@ def twilio_voice_call_asked( bi = BotIntegration.objects.get( twilio_account_sid=account_sid, twilio_phone_number=phone_number ) - except BotIntegration.DoesNotExist: + except BotIntegration.DoesNotExist as e: + capture_exception(e) return Response(status_code=404) # start processing the user's question @@ -140,24 +132,15 @@ def twilio_voice_call_asked( bi=bi, ) - def msg_handler_with_error_handling(bot: TwilioVoice): - """Handle the user's question and catch any errors to make sure they don't get stuck in the queue until it times out.""" - try: - msg_handler(bot) - except Exception: - bot.send_msg( - text=translate("Sorry, an error occurred. Please try again later.", bi), - ) - - background_tasks.add_task(msg_handler_with_error_handling, bot) + background_tasks.add_task(msg_handler, bot) # send back waiting audio resp = VoiceResponse() - say(resp, translate("I heard ", bi) + text, bi) + say(resp, bot.translate("I heard ") + text, bi) resp.enqueue( name=queue_name, - wait_url=url_for(twilio_voice_call_wait, bi_id=bi.id), + wait_url=get_route_url(twilio_voice_call_wait, dict(bi_id=bi.id)), wait_url_method="POST", ) @@ -184,7 +167,8 @@ def twilio_voice_call_asked_audio( bi = BotIntegration.objects.get( twilio_account_sid=account_sid, twilio_phone_number=phone_number ) - except BotIntegration.DoesNotExist: + except BotIntegration.DoesNotExist as e: + capture_exception(e) return Response(status_code=404) # start processing the user's question @@ -198,22 +182,13 @@ def twilio_voice_call_asked_audio( bi=bi, ) - def msg_handler_with_error_handling(bot: TwilioVoice): - """Handle the user's question and catch any errors to make sure they don't get stuck in the queue until it times out.""" - try: - msg_handler(bot) - except Exception: - bot.send_msg( - text=translate("Sorry, an error occurred. Please try again later.", bi), - ) - - background_tasks.add_task(msg_handler_with_error_handling, bot) + background_tasks.add_task(msg_handler, bot) # send back waiting audio resp = VoiceResponse() resp.enqueue( name=queue_name, - wait_url=url_for(twilio_voice_call_wait, bi_id=bi.id), + wait_url=get_route_url(twilio_voice_call_wait, dict(bi_id=bi.id)), wait_url_method="POST", ) @@ -226,7 +201,8 @@ def twilio_voice_call_wait(bi_id: int): try: bi = BotIntegration.objects.get(id=bi_id) - except BotIntegration.DoesNotExist: + except BotIntegration.DoesNotExist as e: + capture_exception(e) return Response(status_code=404) resp = VoiceResponse() @@ -249,38 +225,6 @@ def twilio_voice_call_wait(bi_id: int): return Response(str(resp), headers={"Content-Type": "text/xml"}) -def twilio_voice_call_respond( - text: str | None, - audio_url: str | None, - queue_name: str, - call_sid: str, - bi: BotIntegration, -): - """Respond to the user in the queue with the given text and audio URL.""" - - text = text - audio_url = audio_url - text = base64.b64encode(text.encode()).decode() if text else "N" - audio_url = base64.b64encode(audio_url.encode()).decode() if audio_url else "N" - - queue_sid = None - client = Client(bi.twilio_account_sid, bi.twilio_auth_token) - for queue in client.queues.list(): - if queue.friendly_name == queue_name: - queue_sid = queue.sid - break - assert queue_sid, "Queue not found" - - client.queues(queue_sid).members(call_sid).update( - url=url_for( - twilio_voice_call_response, bi_id=bi.id, text=text, audio_url=audio_url - ), - method="POST", - ) - - return queue_sid - - @router.post("/__/twilio/voice/response/{bi_id}/{text}/{audio_url}/") def twilio_voice_call_response(bi_id: int, text: str, audio_url: str): """Response is ready, user has been dequeued, send the response and ask for the next one.""" @@ -290,7 +234,8 @@ def twilio_voice_call_response(bi_id: int, text: str, audio_url: str): try: bi = BotIntegration.objects.get(id=bi_id) - except BotIntegration.DoesNotExist: + except BotIntegration.DoesNotExist as e: + capture_exception(e) return Response(status_code=404) resp = VoiceResponse() @@ -305,7 +250,7 @@ def twilio_voice_call_response(bi_id: int, text: str, audio_url: str): # try recording 3 times to give the user a chance to start speaking for _ in range(3): resp.record( - action=url_for(twilio_voice_call_asked_audio), + action=get_route_url(twilio_voice_call_asked_audio), method="POST", timeout=3, finish_on_key="0", @@ -316,7 +261,7 @@ def twilio_voice_call_response(bi_id: int, text: str, audio_url: str): input="speech", # also supports dtmf (keypad input) and a combination of both timeout=20, # users get 20 to start speaking speechTimeout=3, # a 3 second pause ends the input - action=url_for( + action=get_route_url( twilio_voice_call_asked ), # the URL to send the user's question to method="POST", @@ -337,9 +282,8 @@ def twilio_voice_call_response(bi_id: int, text: str, audio_url: str): # if the user doesn't say anything, we'll ask them to call back in a quieter environment say( resp, - translate( - "Sorry, I didn't get that. Please call again in a more quiet environment.", - bi, + bi.translate( + "Sorry, I didn't get that. Please call again in a more quiet environment." ), bi, ) @@ -381,12 +325,16 @@ def twilio_sms( user_phone_number = data["From"][0] try: - bi = BotIntegration.objects.get(twilio_phone_number=phone_number) - except BotIntegration.DoesNotExist: + bi = BotIntegration.objects.get( + twilio_phone_number=PhoneNumber.from_string(phone_number) + ) + except BotIntegration.DoesNotExist as e: + capture_exception(e) return Response(status_code=404) convo, created = Conversation.objects.get_or_create( - bot_integration=bi, twilio_phone_number=user_phone_number + bot_integration=bi, + twilio_phone_number=PhoneNumber.from_string(user_phone_number), ) bot = TwilioSMS( sid=data["MessageSid"][0], @@ -404,7 +352,7 @@ def twilio_sms( if bi.twilio_waiting_text.strip(): resp.message(bi.twilio_waiting_text) else: - resp.message(translate("Please wait while we process your request.", bi)) + resp.message(bot.translate("Please wait while we process your request.")) return Response(str(resp), headers={"Content-Type": "text/xml"}) @@ -432,116 +380,16 @@ def start_voice_call_session( audio_url = base64.b64encode(audio_url.encode()).decode() if audio_url else "N" resp.redirect( - url_for(twilio_voice_call_response, bi_id=bi.id, text=text, audio_url=audio_url) + get_route_url( + twilio_voice_call_response, + dict(bi_id=bi.id, text=text, audio_url=audio_url), + ) ) call = client.calls.create( twiml=str(resp), to=user_phone_number, - from_=bi.twilio_phone_number, + from_=bi.twilio_phone_number.as_e164, ) return call - - -def create_voice_call(convo: Conversation, text: str | None, audio_url: str | None): - """Create a new voice call saying the given text and audio URL and then hanging up. Useful for notifications.""" - - assert ( - convo.twilio_phone_number - ), "This is not a Twilio conversation, it has no phone number." - - bi: BotIntegration = convo.bot_integration - client = Client(bi.twilio_account_sid, bi.twilio_auth_token) - - resp = VoiceResponse() - if text: - say(resp, text, bi) - if audio_url: - resp.play(audio_url) - - call = client.calls.create( - twiml=str(resp), - to=convo.twilio_phone_number, - from_=bi.twilio_phone_number, - ) - - return call - - -def send_sms_message(convo: Conversation, text: str, media_url: str | None = None): - """Send an SMS message to the given conversation.""" - - assert ( - convo.twilio_phone_number - ), "This is not a Twilio conversation, it has no phone number." - - account_sid = convo.bot_integration.twilio_account_sid - auth_token = convo.bot_integration.twilio_auth_token - client = Client(account_sid, auth_token) - - message = client.messages.create( - body=text, - media_url=media_url, - from_=convo.bot_integration.twilio_phone_number, - to=convo.twilio_phone_number, - ) - - return message - - -def url_for(fx, **kwargs): - """Get the URL for the given Twilio endpoint.""" - - return ( - furl( - settings.APP_BASE_URL, - ) - / router.url_path_for(fx.__name__, **kwargs) - ).tostr() - - -def twilio_connect( - current_run: SavedRun, - published_run: PublishedRun, - twilio_account_sid: str, - twilio_auth_token: str, - twilio_phone_number: str, - twilio_phone_number_sid: str, - billing_user: AppUser, -) -> BotIntegration: - """Connect a new bot integration to Twilio and return it. This will also setup the necessary webhooks for voice calls and SMS messages""" - - # setup webhooks so we can receive voice calls and SMS messages - client = Client(twilio_account_sid, twilio_auth_token) - client.incoming_phone_numbers(twilio_phone_number_sid).update( - sms_fallback_method="POST", - sms_fallback_url=url_for(twilio_sms_error), - sms_method="POST", - sms_url=url_for(twilio_sms), - # status_callback_method="POST", # uncomment for debugging - # status_callback=url_for(twilio_voice_call_status), - voice_fallback_method="POST", - voice_fallback_url=url_for(twilio_voice_call_error), - voice_method="POST", - voice_url=url_for(twilio_voice_call), - ) - - # create bot integration - return BotIntegration.objects.create( - name=published_run.title, - billing_account_uid=billing_user.uid, - platform=Platform.TWILIO, - streaming_enabled=False, - saved_run=current_run, - published_run=published_run, - by_line=billing_user.display_name, - descripton=published_run.notes, - photo_url=billing_user.photo_url, - website_url=billing_user.website_url, - user_language=current_run.state.get("user_language") or "en", - twilio_account_sid=twilio_account_sid, - twilio_auth_token=twilio_auth_token, - twilio_phone_number=twilio_phone_number, - twilio_phone_number_sid=twilio_phone_number_sid, - )