diff --git a/bots/admin.py b/bots/admin.py
index 03ac2cc1e..cc8c25f0d 100644
--- a/bots/admin.py
+++ b/bots/admin.py
@@ -70,6 +70,21 @@
"slack_create_personal_channels",
]
web_fields = ["web_allowed_origins", "web_config_extras"]
+twilio_fields = [
+ "twilio_account_sid",
+ "twilio_auth_token",
+ "twilio_phone_number",
+ "twilio_phone_number_sid",
+ "twilio_default_to_gooey_asr",
+ "twilio_default_to_gooey_tts",
+ "twilio_voice",
+ "twilio_asr_language",
+ "twilio_initial_text",
+ "twilio_initial_audio_url",
+ "twilio_use_missed_call",
+ "twilio_waiting_audio_url",
+ "twilio_waiting_text",
+]
class BotIntegrationAdminForm(forms.ModelForm):
@@ -132,12 +147,14 @@ class BotIntegrationAdmin(admin.ModelAdmin):
"slack_channel_name",
"slack_channel_hook_url",
"slack_access_token",
+ "twilio_phone_number",
]
list_display = [
"name",
"get_display_name",
"platform",
"wa_phone_number",
+ "twilio_phone_number",
"created_at",
"updated_at",
"billing_account_uid",
@@ -192,6 +209,7 @@ class BotIntegrationAdmin(admin.ModelAdmin):
*wa_fields,
*slack_fields,
*web_fields,
+ *twilio_fields,
]
},
),
@@ -491,6 +509,7 @@ class ConversationAdmin(admin.ModelAdmin):
"slack_user_name",
"slack_channel_id",
"slack_channel_name",
+ "twilio_phone_number",
] + [f"bot_integration__{field}" for field in BotIntegrationAdmin.search_fields]
actions = [export_to_csv, export_to_excel]
diff --git a/bots/migrations/0076_botintegration_twilio_account_sid_and_more.py b/bots/migrations/0076_botintegration_twilio_account_sid_and_more.py
new file mode 100644
index 000000000..1d48185cb
--- /dev/null
+++ b/bots/migrations/0076_botintegration_twilio_account_sid_and_more.py
@@ -0,0 +1,108 @@
+# Generated by Django 4.2.7 on 2024-07-04 09:17
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bots', '0075_alter_publishedrun_workflow_alter_savedrun_workflow_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_account_sid',
+ field=models.TextField(blank=True, default='', help_text='Twilio account sid as found on twilio.com/console (mandatory)'),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_asr_language',
+ field=models.TextField(default='en-US', help_text='The language to use for Twilio ASR (https://www.twilio.com/docs/voice/twiml/gather#languagetags)'),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_auth_token',
+ field=models.TextField(blank=True, default='', help_text='Twilio auth token as found on twilio.com/console (mandatory)'),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_default_to_gooey_asr',
+ field=models.BooleanField(default=False, help_text="If true, the bot will use Gooey ASR for speech recognition instead of Twilio's when available on the attached run"),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_default_to_gooey_tts',
+ field=models.BooleanField(default=False, help_text="If true, the bot will use Gooey TTS for text to speech instead of Twilio's when available on the attached run"),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_initial_audio_url',
+ field=models.TextField(blank=True, default='', help_text='The initial audio url to play to the user when a call is started'),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_initial_text',
+ field=models.TextField(blank=True, default='', help_text='The initial text to send to the user when a call is started'),
+ ),
+ 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)'),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_phone_number_sid',
+ field=models.TextField(blank=True, default='', help_text='Twilio phone number sid as found on twilio.com/console/phone-numbers/incoming (mandatory)'),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_use_missed_call',
+ field=models.BooleanField(default=False, help_text="If true, the bot will reject incoming calls and call back the user instead so they don't get charged for the call"),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_voice',
+ field=models.TextField(default='woman', help_text="The voice to use for Twilio TTS ('man', 'woman', or Amazon Polly/Google Voices: https://www.twilio.com/docs/voice/twiml/say/text-speech#available-voices-and-languages)"),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_waiting_audio_url',
+ field=models.TextField(blank=True, default='', help_text='The audio url to play to the user while waiting for a response if using voice'),
+ ),
+ migrations.AddField(
+ model_name='botintegration',
+ name='twilio_waiting_text',
+ field=models.TextField(blank=True, default='', help_text='The text to send to the user while waiting for a response if using sms'),
+ ),
+ migrations.AddField(
+ model_name='conversation',
+ name='twilio_phone_number',
+ field=models.TextField(blank=True, default='', help_text="User's Twilio phone number (mandatory)"),
+ ),
+ migrations.AlterField(
+ model_name='botintegration',
+ name='platform',
+ field=models.IntegerField(choices=[(1, 'Facebook Messenger'), (2, 'Instagram'), (3, 'WhatsApp'), (4, 'Slack'), (5, 'Web'), (6, 'Twilio')], help_text='The platform that the bot is integrated with'),
+ ),
+ migrations.AlterField(
+ model_name='workflowmetadata',
+ name='default_image',
+ field=models.URLField(blank=True, default='', help_text='Image shown on explore page'),
+ ),
+ migrations.AlterField(
+ model_name='workflowmetadata',
+ name='help_url',
+ field=models.URLField(blank=True, default='', help_text='(Not implemented)'),
+ ),
+ migrations.AlterField(
+ model_name='workflowmetadata',
+ name='meta_keywords',
+ field=models.JSONField(blank=True, default=list, help_text='(Not implemented)'),
+ ),
+ migrations.AlterField(
+ model_name='workflowmetadata',
+ name='short_title',
+ field=models.TextField(help_text='Title used in breadcrumbs'),
+ ),
+ ]
diff --git a/bots/models.py b/bots/models.py
index fbb124726..4d5faae85 100644
--- a/bots/models.py
+++ b/bots/models.py
@@ -58,11 +58,14 @@ class Platform(models.IntegerChoices):
WHATSAPP = (3, "WhatsApp")
SLACK = (4, "Slack")
WEB = (5, "Web")
+ TWILIO = (6, "Twilio")
def get_icon(self):
match self:
case Platform.WEB:
return f''
+ case Platform.TWILIO:
+ return f''
case _:
return f''
@@ -605,6 +608,67 @@ class BotIntegration(models.Model):
help_text="Extra configuration for the bot's web integration",
)
+ twilio_account_sid = models.TextField(
+ blank=True,
+ default="",
+ help_text="Twilio account sid as found on twilio.com/console (mandatory)",
+ )
+ twilio_auth_token = models.TextField(
+ blank=True,
+ default="",
+ help_text="Twilio auth token as found on twilio.com/console (mandatory)",
+ )
+ twilio_phone_number = models.TextField(
+ blank=True,
+ default="",
+ help_text="Twilio unformatted phone number as found on twilio.com/console/phone-numbers/incoming (mandatory)",
+ )
+ twilio_phone_number_sid = models.TextField(
+ blank=True,
+ default="",
+ help_text="Twilio phone number sid as found on twilio.com/console/phone-numbers/incoming (mandatory)",
+ )
+ twilio_default_to_gooey_asr = models.BooleanField(
+ default=False,
+ help_text="If true, the bot will use Gooey ASR for speech recognition instead of Twilio's when available on the attached run",
+ )
+ twilio_default_to_gooey_tts = models.BooleanField(
+ default=False,
+ help_text="If true, the bot will use Gooey TTS for text to speech instead of Twilio's when available on the attached run",
+ )
+ twilio_voice = models.TextField(
+ default="woman",
+ help_text="The voice to use for Twilio TTS ('man', 'woman', or Amazon Polly/Google Voices: https://www.twilio.com/docs/voice/twiml/say/text-speech#available-voices-and-languages)",
+ )
+ twilio_initial_text = models.TextField(
+ default="",
+ blank=True,
+ help_text="The initial text to send to the user when a call is started",
+ )
+ twilio_initial_audio_url = models.TextField(
+ default="",
+ blank=True,
+ help_text="The initial audio url to play to the user when a call is started",
+ )
+ twilio_use_missed_call = models.BooleanField(
+ default=False,
+ help_text="If true, the bot will reject incoming calls and call back the user instead so they don't get charged for the call",
+ )
+ twilio_waiting_audio_url = models.TextField(
+ default="",
+ blank=True,
+ help_text="The audio url to play to the user while waiting for a response if using voice",
+ )
+ twilio_waiting_text = models.TextField(
+ default="",
+ blank=True,
+ help_text="The text to send to the user while waiting for a response if using sms",
+ )
+ twilio_asr_language = models.TextField(
+ default="en-US",
+ help_text="The language to use for Twilio ASR (https://www.twilio.com/docs/voice/twiml/gather#languagetags)",
+ )
+
streaming_enabled = models.BooleanField(
default=False,
help_text="If set, the bot will stream messages to the frontend (Slack & Web only)",
@@ -650,6 +714,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.name
or (
self.platform == Platform.WEB
@@ -945,6 +1010,12 @@ class Conversation(models.Model):
help_text="Whether this is a personal slack channel between the bot and the user",
)
+ twilio_phone_number = models.TextField(
+ blank=True,
+ default="",
+ help_text="User's Twilio phone number (mandatory)",
+ )
+
web_user_id = models.CharField(
max_length=512,
blank=True,
diff --git a/bots/tasks.py b/bots/tasks.py
index aca2ec369..ba2873d88 100644
--- a/bots/tasks.py
+++ b/bots/tasks.py
@@ -23,6 +23,7 @@
create_personal_channel,
SlackBot,
)
+from routers.twilio_api import start_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
@@ -151,6 +152,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):
@@ -162,6 +164,7 @@ def send_broadcast_msgs_chunked(
documents=documents,
bi_id=bi.id,
convo_ids=convo_ids[i : i + 100],
+ medium=medium,
)
@@ -175,6 +178,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)
@@ -203,6 +207,11 @@ def send_broadcast_msg(
username=bi.name,
token=bi.slack_access_token,
)[0]
+ case Platform.TWILIO:
+ if medium == "Voice Call":
+ start_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/bot_integration_widgets.py b/daras_ai_v2/bot_integration_widgets.py
index 92937ef3b..1d293f403 100644
--- a/daras_ai_v2/bot_integration_widgets.py
+++ b/daras_ai_v2/bot_integration_widgets.py
@@ -128,6 +128,724 @@ def render_workflow_url_input(key: str, del_key: str | None, d: dict):
st.write("---")
+def twilio_specific_settings(bi: BotIntegration):
+ if st.session_state.get(f"_bi_reset_{bi.id}"):
+ st.session_state[f"_bi_twilio_default_to_gooey_asr_{bi.id}"] = (
+ BotIntegration._meta.get_field("twilio_default_to_gooey_asr").default
+ )
+ st.session_state[f"_bi_twilio_default_to_gooey_tts_{bi.id}"] = (
+ BotIntegration._meta.get_field("twilio_default_to_gooey_tts").default
+ )
+ st.session_state[f"_bi_twilio_voice_{bi.id}"] = BotIntegration._meta.get_field(
+ "twilio_voice"
+ ).default
+ st.session_state[f"_bi_twilio_initial_text_{bi.id}"] = (
+ BotIntegration._meta.get_field("twilio_initial_text").default
+ )
+ st.session_state[f"_bi_twilio_initial_audio_url_{bi.id}"] = (
+ BotIntegration._meta.get_field("twilio_initial_audio_url").default
+ )
+ st.session_state[f"_bi_twilio_use_missed_call_{bi.id}"] = (
+ BotIntegration._meta.get_field("twilio_use_missed_call").default
+ )
+ st.session_state[f"_bi_twilio_asr_language_{bi.id}"] = (
+ BotIntegration._meta.get_field("twilio_asr_language").default
+ )
+ st.session_state[f"_bi_twilio_waiting_audio_url_{bi.id}"] = (
+ BotIntegration._meta.get_field("twilio_waiting_audio_url").default
+ )
+ st.session_state[f"_bi_twilio_waiting_text_{bi.id}"] = (
+ BotIntegration._meta.get_field("twilio_waiting_text").default
+ )
+
+ bi.twilio_voice = (
+ st.selectbox(
+ "##### 🗣️ Twilio Voice",
+ [
+ "Google.af-ZA-Standard-A",
+ "Polly.Zeina",
+ "Google.ar-XA-Standard-A",
+ "Google.ar-XA-Standard-B",
+ "Google.ar-XA-Standard-C",
+ "Google.ar-XA-Standard-D",
+ "Google.ar-XA-Wavenet-A",
+ "Google.ar-XA-Wavenet-B",
+ "Google.ar-XA-Wavenet-C",
+ "Google.ar-XA-Wavenet-D",
+ "Polly.Hala-Neural",
+ "Polly.Zayd-Neural",
+ "Google.eu-ES-Standard-A",
+ "Google.bn-IN-Standard-C",
+ "Google.bn-IN-Standard-D",
+ "Google.bn-IN-Wavenet-C",
+ "Google.bn-IN-Wavenet-D",
+ "Google.bg-BG-Standard-A",
+ "Polly.Arlet-Neural",
+ "Google.ca-ES-Standard-A",
+ "Polly.Hiujin-Neural",
+ "Google.yue-HK-Standard-A",
+ "Google.yue-HK-Standard-B",
+ "Google.yue-HK-Standard-C",
+ "Google.yue-HK-Standard-D",
+ "Polly.Zhiyu",
+ "Polly.Zhiyu-Neural",
+ "Google.cmn-CN-Standard-A",
+ "Google.cmn-CN-Standard-B",
+ "Google.cmn-CN-Standard-C",
+ "Google.cmn-CN-Standard-D",
+ "Google.cmn-CN-Wavenet-A",
+ "Google.cmn-CN-Wavenet-B",
+ "Google.cmn-CN-Wavenet-C",
+ "Google.cmn-CN-Wavenet-D",
+ "Google.cmn-TW-Standard-A",
+ "Google.cmn-TW-Standard-B",
+ "Google.cmn-TW-Standard-C",
+ "Google.cmn-TW-Wavenet-A",
+ "Google.cmn-TW-Wavenet-B",
+ "Google.cmn-TW-Wavenet-C",
+ "Google.cs-CZ-Standard-A",
+ "Google.cs-CZ-Wavenet-A",
+ "Polly.Mads",
+ "Polly.Naja",
+ "Polly.Sofie-Neural",
+ "Google.da-DK-Standard-A",
+ "Google.da-DK-Standard-C",
+ "Google.da-DK-Standard-D",
+ "Google.da-DK-Standard-E",
+ "Google.da-DK-Wavenet-A",
+ "Google.da-DK-Wavenet-C",
+ "Google.da-DK-Wavenet-D",
+ "Google.da-DK-Wavenet-E",
+ "Polly.Lisa-Neural",
+ "Google.nl-BE-Standard-A",
+ "Google.nl-BE-Standard-B",
+ "Google.nl-BE-Wavenet-A",
+ "Google.nl-BE-Wavenet-B",
+ "Polly.Lotte",
+ "Polly.Ruben",
+ "Polly.Laura-Neural",
+ "Google.nl-NL-Standard-A",
+ "Google.nl-NL-Standard-B",
+ "Google.nl-NL-Standard-C",
+ "Google.nl-NL-Standard-D",
+ "Google.nl-NL-Standard-E",
+ "Google.nl-NL-Wavenet-A",
+ "Google.nl-NL-Wavenet-B",
+ "Google.nl-NL-Wavenet-C",
+ "Google.nl-NL-Wavenet-D",
+ "Google.nl-NL-Wavenet-E",
+ "Polly.Nicole",
+ "Polly.Russell",
+ "Polly.Olivia-Neural",
+ "Google.en-AU-Standard-A",
+ "Google.en-AU-Standard-B",
+ "Google.en-AU-Standard-C",
+ "Google.en-AU-Standard-D",
+ "Google.en-AU-Wavenet-A",
+ "Google.en-AU-Wavenet-B",
+ "Google.en-AU-Wavenet-C",
+ "Google.en-AU-Wavenet-D",
+ "Google.en-AU-Neural2-A",
+ "Google.en-AU-Neural2-B",
+ "Google.en-AU-Neural2-C",
+ "Google.en-AU-Neural2-D",
+ "Polly.Raveena",
+ "Google.en-IN-Standard-A",
+ "Google.en-IN-Standard-B",
+ "Google.en-IN-Standard-C",
+ "Google.en-IN-Standard-D",
+ "Google.en-IN-Wavenet-A",
+ "Google.en-IN-Wavenet-B",
+ "Google.en-IN-Wavenet-C",
+ "Google.en-IN-Wavenet-D",
+ "Google.en-IN-Neural2-A",
+ "Google.en-IN-Neural2-B",
+ "Google.en-IN-Neural2-C",
+ "Google.en-IN-Neural2-D",
+ "Polly.Niamh-Neural",
+ "Polly.Aria-Neural",
+ "Polly.Ayanda-Neural",
+ "Polly.Amy",
+ "Polly.Brian",
+ "Polly.Emma",
+ "Polly.Amy-Neural",
+ "Polly.Emma-Neural",
+ "Polly.Brian-Neural",
+ "Polly.Arthur-Neural",
+ "Google.en-GB-Standard-A",
+ "Google.en-GB-Standard-B",
+ "Google.en-GB-Standard-C",
+ "Google.en-GB-Standard-D",
+ "Google.en-GB-Standard-F",
+ "Google.en-GB-Wavenet-A",
+ "Google.en-GB-Wavenet-B",
+ "Google.en-GB-Wavenet-C",
+ "Google.en-GB-Wavenet-D",
+ "Google.en-GB-Wavenet-F",
+ "Google.en-GB-Neural2-A",
+ "Google.en-GB-Neural2-B",
+ "Google.en-GB-Neural2-C",
+ "Google.en-GB-Neural2-D",
+ "Google.en-GB-Neural2-F",
+ "Polly.Ivy",
+ "Polly.Joanna",
+ "Polly.Joey",
+ "Polly.Justin",
+ "Polly.Kendra",
+ "Polly.Kimberly",
+ "Polly.Matthew",
+ "Polly.Salli",
+ "child) Polly.Ivy-Neural",
+ "Polly.Joanna-Neural*",
+ "Polly.Kendra-Neural",
+ "child) Polly.Kevin-Neural",
+ "Polly.Kimberly-Neural",
+ "Polly.Salli-Neural",
+ "Polly.Joey-Neural",
+ "child) Polly.Justin-Neural",
+ "Polly.Matthew-Neural*",
+ "Polly.Ruth-Neural",
+ "Polly.Stephen-Neural",
+ "Polly.Gregory-Neural",
+ "Polly.Danielle-Neural",
+ "Google.en-US-Standard-A",
+ "Google.en-US-Standard-B",
+ "Google.en-US-Standard-C",
+ "Google.en-US-Standard-D",
+ "Google.en-US-Standard-E",
+ "Google.en-US-Standard-F",
+ "Google.en-US-Standard-G",
+ "Google.en-US-Standard-H",
+ "Google.en-US-Standard-I",
+ "Google.en-US-Standard-J",
+ "Google.en-US-Wavenet-A",
+ "Google.en-US-Wavenet-B",
+ "Google.en-US-Wavenet-C",
+ "Google.en-US-Wavenet-D",
+ "Google.en-US-Wavenet-E",
+ "Google.en-US-Wavenet-F",
+ "Google.en-US-Wavenet-G",
+ "Google.en-US-Wavenet-H",
+ "Google.en-US-Wavenet-I",
+ "Google.en-US-Wavenet-J",
+ "Google.en-US-Neural2-A",
+ "Google.en-US-Neural2-C",
+ "Google.en-US-Neural2-D",
+ "Google.en-US-Neural2-E",
+ "Google.en-US-Neural2-F",
+ "Google.en-US-Neural2-G",
+ "Google.en-US-Neural2-H",
+ "Google.en-US-Neural2-I",
+ "Google.en-US-Neural2-J",
+ "Polly.Geraint",
+ "Google.fil-PH-Standard-A",
+ "Google.fil-PH-Standard-B",
+ "Google.fil-PH-Standard-C",
+ "Google.fil-PH-Standard-D",
+ "Google.fil-PH-Wavenet-A",
+ "Google.fil-PH-Wavenet-B",
+ "Google.fil-PH-Wavenet-C",
+ "Google.fil-PH-Wavenet-D",
+ "Polly.Suvi-Neural",
+ "Google.fi-FI-Standard-A",
+ "Google.fi-FI-Wavenet-A",
+ "Polly.Isabelle-Neural",
+ "Polly.Chantal",
+ "Polly.Gabrielle-Neural",
+ "Polly.Liam-Neural",
+ "Google.fr-CA-Standard-A",
+ "Google.fr-CA-Standard-B",
+ "Google.fr-CA-Standard-C",
+ "Google.fr-CA-Standard-D",
+ "Google.fr-CA-Wavenet-A",
+ "Google.fr-CA-Wavenet-B",
+ "Google.fr-CA-Wavenet-C",
+ "Google.fr-CA-Wavenet-D",
+ "Google.fr-CA-Neural2-A",
+ "Google.fr-CA-Neural2-B",
+ "Google.fr-CA-Neural2-C",
+ "Google.fr-CA-Neural2-D",
+ "Polly.Céline/Polly.Celine",
+ "Polly.Léa/Polly.Lea",
+ "Polly.Mathieu",
+ "Polly.Lea-Neural",
+ "Polly.Remi-Neural",
+ "Google.fr-FR-Standard-A",
+ "Google.fr-FR-Standard-B",
+ "Google.fr-FR-Standard-C",
+ "Google.fr-FR-Standard-D",
+ "Google.fr-FR-Standard-E",
+ "Google.fr-FR-Wavenet-A",
+ "Google.fr-FR-Wavenet-B",
+ "Google.fr-FR-Wavenet-C",
+ "Google.fr-FR-Wavenet-D",
+ "Google.fr-FR-Wavenet-E",
+ "Google.fr-FR-Neural2-A",
+ "Google.fr-FR-Neural2-B",
+ "Google.fr-FR-Neural2-C",
+ "Google.fr-FR-Neural2-D",
+ "Google.fr-FR-Neural2-E",
+ "Google.gl-ES-Standard-A",
+ "Polly.Hannah-Neural",
+ "Polly.Hans",
+ "Polly.Marlene",
+ "Polly.Vicki",
+ "Polly.Vicki-Neural",
+ "Polly.Daniel-Neural",
+ "Google.de-DE-Standard-A",
+ "Google.de-DE-Standard-B",
+ "Google.de-DE-Standard-C",
+ "Google.de-DE-Standard-D",
+ "Google.de-DE-Standard-E",
+ "Google.de-DE-Standard-F",
+ "Google.de-DE-Wavenet-A",
+ "Google.de-DE-Wavenet-B",
+ "Google.de-DE-Wavenet-C",
+ "Google.de-DE-Wavenet-D",
+ "Google.de-DE-Wavenet-E",
+ "Google.de-DE-Wavenet-F",
+ "Google.de-DE-Neural2-A",
+ "Google.de-DE-Neural2-B",
+ "Google.de-DE-Neural2-C",
+ "Google.de-DE-Neural2-D",
+ "Google.de-DE-Neural2-F",
+ "Google.el-GR-Standard-A",
+ "Google.el-GR-Wavenet-A",
+ "Google.gu-IN-Standard-C",
+ "Google.gu-IN-Standard-D",
+ "Google.gu-IN-Wavenet-C",
+ "Google.gu-IN-Wavenet-D",
+ "Google.he-IL-Standard-A",
+ "Google.he-IL-Standard-B",
+ "Google.he-IL-Standard-C",
+ "Google.he-IL-Standard-D",
+ "Google.he-IL-Wavenet-A",
+ "Google.he-IL-Wavenet-B",
+ "Google.he-IL-Wavenet-C",
+ "Google.he-IL-Wavenet-D",
+ "Polly.Aditi",
+ "Polly.Kajal-Neural",
+ "Google.hi-IN-Standard-A",
+ "Google.hi-IN-Standard-B",
+ "Google.hi-IN-Standard-C",
+ "Google.hi-IN-Standard-D",
+ "Google.hi-IN-Wavenet-A",
+ "Google.hi-IN-Wavenet-B",
+ "Google.hi-IN-Wavenet-C",
+ "Google.hi-IN-Wavenet-D",
+ "Google.hi-IN-Neural2-A",
+ "Google.hi-IN-Neural2-B",
+ "Google.hi-IN-Neural2-C",
+ "Google.hi-IN-Neural2-D",
+ "Google.hu-HU-Standard-A",
+ "Google.hu-HU-Wavenet-A",
+ "Polly.Dóra/Polly.Dora",
+ "Polly.Karl",
+ "Google.is-IS-Standard-A",
+ "Google.id-ID-Standard-A",
+ "Google.id-ID-Standard-B",
+ "Google.id-ID-Standard-C",
+ "Google.id-ID-Standard-D",
+ "Google.id-ID-Wavenet-A",
+ "Google.id-ID-Wavenet-B",
+ "Google.id-ID-Wavenet-C",
+ "Google.id-ID-Wavenet-D",
+ "Polly.Bianca",
+ "Polly.Carla",
+ "Polly.Giorgio",
+ "Polly.Bianca-Neural",
+ "Polly.Adriano-Neural",
+ "Google.it-IT-Standard-B",
+ "Google.it-IT-Standard-C",
+ "Google.it-IT-Standard-D",
+ "Google.it-IT-Wavenet-B",
+ "Google.it-IT-Wavenet-C",
+ "Google.it-IT-Wavenet-D",
+ "Google.it-IT-Neural2-A",
+ "Google.it-IT-Neural2-C",
+ "Polly.Mizuki",
+ "Polly.Takumi",
+ "Polly.Takumi-Neural",
+ "Polly.Kazuha-Neural",
+ "Polly.Tomoko-Neural",
+ "Google.ja-JP-Standard-B",
+ "Google.ja-JP-Standard-C",
+ "Google.ja-JP-Standard-D",
+ "Google.ja-JP-Wavenet-B",
+ "Google.ja-JP-Wavenet-C",
+ "Google.ja-JP-Wavenet-D",
+ "Google.kn-IN-Standard-C",
+ "Google.kn-IN-Standard-D",
+ "Google.kn-IN-Wavenet-C",
+ "Google.kn-IN-Wavenet-D",
+ "Polly.Seoyeon",
+ "Polly.Seoyeon-Neural",
+ "Google.ko-KR-Standard-A",
+ "Google.ko-KR-Standard-B",
+ "Google.ko-KR-Standard-C",
+ "Google.ko-KR-Standard-D",
+ "Google.ko-KR-Wavenet-A",
+ "Google.ko-KR-Wavenet-B",
+ "Google.ko-KR-Wavenet-C",
+ "Google.ko-KR-Wavenet-D",
+ "Google.ko-KR-Neural2-A",
+ "Google.ko-KR-Neural2-B",
+ "Google.ko-KR-Neural2-C",
+ "Google.lv-LV-Standard-A",
+ "Google.lt-LT-Standard-A",
+ "Google.ms-MY-Standard-A",
+ "Google.ms-MY-Standard-B",
+ "Google.ms-MY-Standard-C",
+ "Google.ms-MY-Standard-D",
+ "Google.ms-MY-Wavenet-A",
+ "Google.ms-MY-Wavenet-B",
+ "Google.ms-MY-Wavenet-C",
+ "Google.ms-MY-Wavenet-D",
+ "Google.ml-IN-Wavenet-C",
+ "Google.ml-IN-Wavenet-D",
+ "Google.mr-IN-Standard-A",
+ "Google.mr-IN-Standard-B",
+ "Google.mr-IN-Standard-C",
+ "Google.mr-IN-Wavenet-A",
+ "Google.mr-IN-Wavenet-B",
+ "Google.mr-IN-Wavenet-C",
+ "Polly.Liv",
+ "Polly.Ida-Neural",
+ "Google.nb-NO-Standard-A",
+ "Google.nb-NO-Standard-B",
+ "Google.nb-NO-Standard-C",
+ "Google.nb-NO-Standard-D",
+ "Google.nb-NO-Standard-E",
+ "Google.nb-NO-Wavenet-A",
+ "Google.nb-NO-Wavenet-B",
+ "Google.nb-NO-Wavenet-C",
+ "Google.nb-NO-Wavenet-D",
+ "Google.nb-NO-Wavenet-E",
+ "Polly.Jacek",
+ "Polly.Jan",
+ "Polly.Ewa",
+ "Polly.Maja",
+ "Polly.Ola-Neural",
+ "Google.pl-PL-Standard-A",
+ "Google.pl-PL-Standard-B",
+ "Google.pl-PL-Standard-C",
+ "Google.pl-PL-Standard-D",
+ "Google.pl-PL-Standard-E",
+ "Google.pl-PL-Wavenet-A",
+ "Google.pl-PL-Wavenet-B",
+ "Google.pl-PL-Wavenet-C",
+ "Google.pl-PL-Wavenet-D",
+ "Google.pl-PL-Wavenet-E",
+ "Polly.Camila",
+ "Polly.Ricardo",
+ "Polly.Vitória/Polly.Vitoria",
+ "Polly.Camila-Neural",
+ "Polly.Vitoria-Neural",
+ "Polly.Thiago-Neural",
+ "Google.pt-BR-Standard-B",
+ "Google.pt-BR-Standard-C",
+ "Google.pt-BR-Wavenet-B",
+ "Google.pt-BR-Wavenet-C",
+ "Google.pt-BR-Neural2-A",
+ "Google.pt-BR-Neural2-B",
+ "Google.pt-BR-Neural2-C",
+ "Polly.Cristiano",
+ "Polly.Inês/Polly.Ines",
+ "Polly.Ines-Neural",
+ "Google.pt-PT-Standard-A",
+ "Google.pt-PT-Standard-B",
+ "Google.pt-PT-Standard-C",
+ "Google.pt-PT-Standard-D",
+ "Google.pt-PT-Wavenet-A",
+ "Google.pt-PT-Wavenet-B",
+ "Google.pt-PT-Wavenet-C",
+ "Google.pt-PT-Wavenet-D",
+ "Google.pa-IN-Standard-A",
+ "Google.pa-IN-Standard-B",
+ "Google.pa-IN-Standard-C",
+ "Google.pa-IN-Standard-D",
+ "Google.pa-IN-Wavenet-A",
+ "Google.pa-IN-Wavenet-B",
+ "Google.pa-IN-Wavenet-C",
+ "Google.pa-IN-Wavenet-D",
+ "Polly.Carmen",
+ "Google.ro-RO-Standard-A",
+ "Google.ro-RO-Wavenet-A",
+ "Polly.Maxim",
+ "Polly.Tatyana",
+ "Google.ru-RU-Standard-A",
+ "Google.ru-RU-Standard-B",
+ "Google.ru-RU-Standard-C",
+ "Google.ru-RU-Standard-D",
+ "Google.ru-RU-Standard-E",
+ "Google.ru-RU-Wavenet-A",
+ "Google.ru-RU-Wavenet-B",
+ "Google.ru-RU-Wavenet-C",
+ "Google.ru-RU-Wavenet-D",
+ "Google.ru-RU-Wavenet-E",
+ "Google.sr-RS-Standard-A",
+ "Google.sk-SK-Standard-A",
+ "Google.sk-SK-Wavenet-A",
+ "Polly.Mia",
+ "Polly.Mia-Neural",
+ "Polly.Andres-Neural",
+ "Polly.Conchita",
+ "Polly.Enrique",
+ "Polly.Lucia",
+ "Polly.Lucia-Neural",
+ "Polly.Sergio-Neural",
+ "Google.es-ES-Standard-B",
+ "Google.es-ES-Standard-C",
+ "Google.es-ES-Standard-D",
+ "Google.es-ES-Wavenet-B",
+ "Google.es-ES-Wavenet-C",
+ "Google.es-ES-Wavenet-D",
+ "Google.es-ES-Neural2-A",
+ "Google.es-ES-Neural2-B",
+ "Google.es-ES-Neural2-C",
+ "Google.es-ES-Neural2-D",
+ "Google.es-ES-Neural2-E",
+ "Google.es-ES-Neural2-F",
+ "man",
+ "woman",
+ "Polly.Lupe",
+ "Polly.Miguel",
+ "Polly.Penélope/Polly.Penelope",
+ "Polly.Lupe-Neural",
+ "Polly.Pedro-Neural",
+ "Google.es-US-Standard-A",
+ "Google.es-US-Standard-B",
+ "Google.es-US-Standard-C",
+ "Google.es-US-Wavenet-A",
+ "Google.es-US-Wavenet-B",
+ "Google.es-US-Wavenet-C",
+ "Google.es-US-Neural2-A",
+ "Google.es-US-Neural2-B",
+ "Google.es-US-Neural2-C",
+ "Polly.Astrid",
+ "Polly.Elin-Neural",
+ "Google.sv-SE-Standard-A",
+ "Google.sv-SE-Standard-B",
+ "Google.sv-SE-Standard-C",
+ "Google.sv-SE-Standard-D",
+ "Google.sv-SE-Standard-E",
+ "Google.sv-SE-Wavenet-A",
+ "Google.sv-SE-Wavenet-B",
+ "Google.sv-SE-Wavenet-C",
+ "Google.sv-SE-Wavenet-D",
+ "Google.sv-SE-Wavenet-E",
+ "Google.ta-IN-Standard-C",
+ "Google.ta-IN-Standard-D",
+ "Google.ta-IN-Wavenet-C",
+ "Google.ta-IN-Wavenet-D",
+ "Google.te-IN-Standard-A",
+ "Google.te-IN-Standard-B",
+ "Google.th-TH-Standard-A",
+ "Polly.Filiz",
+ "Google.tr-TR-Standard-A",
+ "Google.tr-TR-Standard-B",
+ "Google.tr-TR-Standard-C",
+ "Google.tr-TR-Standard-D",
+ "Google.tr-TR-Standard-E",
+ "Google.tr-TR-Wavenet-A",
+ "Google.tr-TR-Wavenet-B",
+ "Google.tr-TR-Wavenet-C",
+ "Google.tr-TR-Wavenet-D",
+ "Google.tr-TR-Wavenet-E",
+ "Google.uk-UA-Standard-A",
+ "Google.uk-UA-Wavenet-A",
+ "Google.vi-VN-Standard-A",
+ "Google.vi-VN-Standard-B",
+ "Google.vi-VN-Standard-C",
+ "Google.vi-VN-Standard-D",
+ "Google.vi-VN-Wavenet-A",
+ "Google.vi-VN-Wavenet-B",
+ "Google.vi-VN-Wavenet-C",
+ "Google.vi-VN-Wavenet-D",
+ "Polly.Gwyneth",
+ ],
+ value=bi.twilio_voice,
+ format_func=lambda x: x.capitalize(),
+ key=f"_bi_twilio_voice_{bi.id}",
+ )
+ or "Woman"
+ )
+ bi.twilio_asr_language = (
+ st.selectbox(
+ "##### 🌐 Twilio ASR Language",
+ [
+ "af-ZA",
+ "am-ET",
+ "hy-AM",
+ "az-AZ",
+ "id-ID",
+ "ms-MY",
+ "bn-BD",
+ "bn-IN",
+ "ca-ES",
+ "cs-CZ",
+ "da-DK",
+ "de-DE",
+ "en-AU",
+ "en-CA",
+ "en-GH",
+ "en-GB",
+ "en-IN",
+ "en-IE",
+ "en-KE",
+ "en-NZ",
+ "en-NG",
+ "en-PH",
+ "en-ZA",
+ "en-TZ",
+ "en-US",
+ "es-AR",
+ "es-BO",
+ "es-CL",
+ "es-CO",
+ "es-CR",
+ "es-EC",
+ "es-SV",
+ "es-ES",
+ "es-US",
+ "es-GT",
+ "es-HN",
+ "es-MX",
+ "es-NI",
+ "es-PA",
+ "es-PY",
+ "es-PE",
+ "es-PR",
+ "es-DO",
+ "es-UY",
+ "es-VE",
+ "eu-ES",
+ "fil-PH",
+ "fr-CA",
+ "fr-FR",
+ "gl-ES",
+ "ka-GE",
+ "gu-IN",
+ "hr-HR",
+ "zu-ZA",
+ "is-IS",
+ "it-IT",
+ "jv-ID",
+ "kn-IN",
+ "km-KH",
+ "lo-LA",
+ "lv-LV",
+ "lt-LT",
+ "hu-HU",
+ "ml-IN",
+ "mr-IN",
+ "nl-NL",
+ "ne-NP",
+ "nb-NO",
+ "pl-PL",
+ "pt-BR",
+ "pt-PT",
+ "ro-RO",
+ "si-LK",
+ "sk-SK",
+ "sl-SI",
+ "su-ID",
+ "sw-TZ",
+ "sw-KE",
+ "fi-FI",
+ "sv-SE",
+ "ta-IN",
+ "ta-SG",
+ "ta-LK",
+ "ta-MY",
+ "te-IN",
+ "vi-VN",
+ "tr-TR",
+ "ur-PK",
+ "ur-IN",
+ "el-GR",
+ "bg-BG",
+ "ru-RU",
+ "sr-RS",
+ "uk-UA",
+ "he-IL",
+ "ar-IL",
+ "ar-JO",
+ "ar-AE",
+ "ar-BH",
+ "ar-DZ",
+ "ar-SA",
+ "ar-IQ",
+ "ar-KW",
+ "ar-MA",
+ "ar-TN",
+ "ar-OM",
+ "ar-PS",
+ "ar-QA",
+ "ar-LB",
+ "ar-EG",
+ "fa-IR",
+ "hi-IN",
+ "th-TH",
+ "ko-KR",
+ "cmn-Hant-TW",
+ "yue-Hant-HK",
+ "ja-JP",
+ "cmn-Hans-HK",
+ "cmn-Hans-CN",
+ ],
+ key=f"_bi_twilio_asr_language_{bi.id}",
+ value=bi.twilio_asr_language,
+ )
+ or "en-US"
+ )
+ bi.twilio_default_to_gooey_asr = st.checkbox(
+ "🎤 Default to Gooey ASR",
+ value=bi.twilio_default_to_gooey_asr,
+ key=f"_bi_twilio_default_to_gooey_asr_{bi.id}",
+ disabled=True,
+ )
+ st.caption(
+ "Use Gooey's ASR for transcribing incoming audio messages (must also be enabled on the underlying run)."
+ )
+ bi.twilio_default_to_gooey_tts = st.checkbox(
+ "📢 Default to Gooey TTS",
+ value=bi.twilio_default_to_gooey_tts,
+ key=f"_bi_twilio_default_to_gooey_tts_{bi.id}",
+ )
+ st.caption(
+ "Use Gooey's TTS for converting text to speech in outgoing messages (must also be enabled on the underlying run)."
+ )
+ bi.twilio_initial_text = st.text_area(
+ "###### 📝 Initial Text (said at the beginning of each call)",
+ value=bi.twilio_initial_text,
+ key=f"_bi_twilio_initial_text_{bi.id}",
+ )
+ bi.twilio_initial_audio_url = st.file_uploader(
+ "###### 🔊 Initial Audio (played at the beginning of each call)",
+ accept=["audio/*"],
+ key=f"_bi_twilio_initial_audio_url_{bi.id}",
+ )
+ bi.twilio_waiting_audio_url = st.file_uploader(
+ "###### 🔊 Waiting Audio (played while waiting for a response -- Voice)",
+ accept=["audio/*"],
+ key=f"_bi_twilio_waiting_audio_url_{bi.id}",
+ )
+ bi.twilio_waiting_text = st.text_area(
+ "###### 📝 Waiting Text (texted while waiting for a response -- SMS)",
+ key=f"_bi_twilio_waiting_text_{bi.id}",
+ )
+ bi.twilio_use_missed_call = st.checkbox(
+ "📞 Use Missed Call",
+ value=bi.twilio_use_missed_call,
+ key=f"_bi_twilio_use_missed_call_{bi.id}",
+ disabled=True,
+ )
+ st.caption(
+ "When enabled, immediately hangs up incoming calls and calls back the user so they don't incur charges (depending on their carrier/plan)."
+ )
+
+
def slack_specific_settings(bi: BotIntegration, default_name: str):
if st.session_state.get(f"_bi_reset_{bi.id}"):
st.session_state[f"_bi_name_{bi.id}"] = default_name
@@ -185,20 +903,30 @@ def broadcast_input(bi: BotIntegration):
optional=True,
accept=["audio/*"],
)
- video = st.file_uploader(
- "**🎥 Video**",
- key=key + ":video",
- help="Attach a video to this message.",
- optional=True,
- accept=["video/*"],
- )
- documents = st.file_uploader(
- "**📄 Documents**",
- key=key + ":documents",
- help="Attach documents to this message.",
- accept_multiple_files=True,
- optional=True,
- )
+ video = None
+ documents = None
+ medium = None
+ if bi.platform == Platform.TWILIO:
+ medium = st.selectbox(
+ "###### 📱 Medium",
+ ["Voice Call", "SMS/MMS"],
+ key=key + ":medium",
+ )
+ else:
+ video = st.file_uploader(
+ "**🎥 Video**",
+ key=key + ":video",
+ help="Attach a video to this message.",
+ optional=True,
+ accept=["video/*"],
+ )
+ documents = st.file_uploader(
+ "**📄 Documents**",
+ key=key + ":documents",
+ help="Attach documents to this message.",
+ accept_multiple_files=True,
+ optional=True,
+ )
should_confirm_key = key + ":should_confirm"
confirmed_send_btn = key + ":confirmed_send"
@@ -219,6 +947,7 @@ def broadcast_input(bi: BotIntegration):
documents=documents,
bi=bi,
convo_qs=convos,
+ medium=medium,
)
else:
if not convos.exists():
@@ -257,6 +986,8 @@ def get_bot_test_link(bi: BotIntegration) -> str | None:
),
)
)
+ elif bi.twilio_phone_number_sid:
+ return f"https://console.twilio.com/us1/develop/phone-numbers/manage/incoming/{bi.twilio_phone_number_sid}/calls"
else:
return None
diff --git a/daras_ai_v2/twilio_bot.py b/daras_ai_v2/twilio_bot.py
new file mode 100644
index 000000000..3919a0423
--- /dev/null
+++ b/daras_ai_v2/twilio_bot.py
@@ -0,0 +1,96 @@
+from bots.models import BotIntegration, Platform, Conversation
+from daras_ai_v2.bots import BotInterface, ReplyButton
+
+from daras_ai_v2.asr import run_google_translate, should_translate_lang
+
+
+class TwilioSMS(BotInterface):
+ pass
+
+
+class TwilioVoice(BotInterface):
+ platform = Platform.TWILIO
+
+ def __init__(
+ self,
+ *,
+ incoming_number: str,
+ queue_name: str,
+ text: str | None = None,
+ audio: str | None = None,
+ bi: BotIntegration,
+ ):
+ self.user_msg_id = queue_name
+ self.bot_id = bi.id
+ self.user_id = incoming_number
+
+ try:
+ self.convo = Conversation.objects.get(
+ twilio_phone_number=incoming_number,
+ bot_integration=bi,
+ )
+ except Conversation.DoesNotExist:
+ self.convo = Conversation.objects.get_or_create(
+ twilio_phone_number=incoming_number,
+ bot_integration=bi,
+ )[0]
+
+ if (
+ audio
+ and bi.twilio_default_to_gooey_asr
+ and bi.saved_run.state.get("asr_model")
+ ):
+ self.input_type = "audio"
+ else:
+ self.input_type = "text"
+
+ self._text = text
+ self._audio = audio
+
+ self._unpack_bot_integration()
+ self.bi = bi
+
+ def get_input_text(self) -> str | None:
+ return self._text
+
+ def get_input_audio(self) -> str | None:
+ return self._audio
+
+ def send_msg(
+ self,
+ *,
+ text: str | None = None,
+ audio: str | None = None,
+ video: str | None = None,
+ buttons: list[ReplyButton] = None,
+ documents: list[str] = None,
+ 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"
+ assert update_msg_id is None, "Twilio does not support un-saying things"
+
+ if self.bi.twilio_default_to_gooey_tts and audio:
+ text = None
+ 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]
+
+ return twilio_voice_call_respond(
+ text=text,
+ audio_url=audio,
+ user_phone_number=self.user_id,
+ queue_name=self.user_msg_id,
+ bi=self.bi,
+ )
+
+ def mark_read(self):
+ pass # handled in the webhook
diff --git a/poetry.lock b/poetry.lock
index a9a6f950a..f1bec6c67 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]]
name = "absl-py"
@@ -133,6 +133,20 @@ yarl = ">=1.0,<2.0"
[package.extras]
speedups = ["Brotli", "aiodns", "cchardet"]
+[[package]]
+name = "aiohttp-retry"
+version = "2.8.3"
+description = "Simple retry client for aiohttp"
+optional = false
+python-versions = ">=3.7"
+files = [
+ {file = "aiohttp_retry-2.8.3-py3-none-any.whl", hash = "sha256:3aeeead8f6afe48272db93ced9440cf4eda8b6fd7ee2abb25357b7eb28525b45"},
+ {file = "aiohttp_retry-2.8.3.tar.gz", hash = "sha256:9a8e637e31682ad36e1ff9f8bcba912fcfc7d7041722bc901a4b948da4d71ea9"},
+]
+
+[package.dependencies]
+aiohttp = "*"
+
[[package]]
name = "aiosignal"
version = "1.3.1"
@@ -2928,6 +2942,16 @@ files = [
{file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
{file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
{file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
+ {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
{file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
@@ -4479,6 +4503,7 @@ files = [
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
+ {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
@@ -4486,8 +4511,16 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
+ {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
+ {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
+ {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
+ {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
+ {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
@@ -4504,6 +4537,7 @@ files = [
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
+ {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
@@ -4511,6 +4545,7 @@ files = [
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
+ {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
@@ -5815,6 +5850,23 @@ torchhub = ["filelock", "huggingface-hub (>=0.16.4,<1.0)", "importlib-metadata",
video = ["av (==9.2.0)", "decord (==0.6.0)"]
vision = ["Pillow (<10.0.0)"]
+[[package]]
+name = "twilio"
+version = "9.2.3"
+description = "Twilio API client and TwiML generator"
+optional = false
+python-versions = ">=3.7.0"
+files = [
+ {file = "twilio-9.2.3-py2.py3-none-any.whl", hash = "sha256:76bfc39aa8d854510907cb7f9465814dfdea9e91ec199bb44f0785f05746f4cc"},
+ {file = "twilio-9.2.3.tar.gz", hash = "sha256:da2255b5f3753cb3bf647fc6c50edbdb367ebc3cde6802806f6f863058a65f75"},
+]
+
+[package.dependencies]
+aiohttp = ">=3.8.4"
+aiohttp-retry = ">=2.8.3"
+PyJWT = ">=2.0.0,<3.0.0"
+requests = ">=2.0.0"
+
[[package]]
name = "typing-extensions"
version = "4.8.0"
@@ -6426,4 +6478,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<3.13"
-content-hash = "51cec8cd0df55b484b5d2d36bc08ea69104896c7b043dac50a054f0b5c89645f"
+content-hash = "472b879ca24219285b6a19f69fac52133a7c7b605c377c09dcd7d276d7f9c4b3"
diff --git a/pyproject.toml b/pyproject.toml
index d0b85af5b..d7f1f3b48 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -86,6 +86,7 @@ emoji = "^2.10.1"
pyvespa = "^0.39.0"
anthropic = "^0.25.5"
azure-cognitiveservices-speech = "^1.37.0"
+twilio = "^9.2.3"
[tool.poetry.group.dev.dependencies]
watchdog = "^2.1.9"
diff --git a/recipes/VideoBots.py b/recipes/VideoBots.py
index fa4545aab..e210a83c5 100644
--- a/recipes/VideoBots.py
+++ b/recipes/VideoBots.py
@@ -33,6 +33,7 @@
from daras_ai_v2.bot_integration_widgets import (
general_integration_settings,
slack_specific_settings,
+ twilio_specific_settings,
broadcast_input,
get_bot_test_link,
web_widget_config,
@@ -98,6 +99,7 @@
from recipes.Lipsync import LipsyncPage
from recipes.TextToSpeech import TextToSpeechPage, TextToSpeechSettings
from url_shortener.models import ShortenedURL
+from routers.twilio_api import twilio_connect
DEFAULT_COPILOT_META_IMG = "https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/7a3127ec-1f71-11ef-aa2b-02420a00015d/Copilot.jpg"
INTEGRATION_IMG = "https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/c3ba2392-d6b9-11ee-a67b-6ace8d8c9501/image.png"
@@ -1143,21 +1145,66 @@ def render_integrations_add(self, label: str, run_title: str):
st.newline()
pressed_platform = None
- with (
- st.tag("table", className="d-flex justify-content-center"),
- st.tag("tbody"),
+ if st.session_state.get("__twilio_redirect_url") == "connecting...":
+ st.write(
+ "[Contact Us](sales@gooey.ai) to set up a Twilio phone number for you. Or enter your own [Twilio credentials](twilio.com/console):"
+ )
+
+ st.text_input("Account SID", key="twilio_account_sid", type="password")
+ st.text_input("Auth Token", key="twilio_auth_token", type="password")
+ st.text_input("Phone Number", key="twilio_phone_number")
+ st.text_input("Phone Number SID", key="twilio_phone_number_sid")
+ if st.button(
+ "Connect", key="twilio_connect", type="primary", style={"width": "100%"}
+ ):
+ current_run = self.get_current_sr()
+ published_run = self.get_current_published_run()
+ assert current_run and published_run, "Run should be available"
+ twilio_account_sid = st.session_state.pop("twilio_account_sid")
+ twilio_auth_token = st.session_state.pop("twilio_auth_token")
+ twilio_phone_number = st.session_state.pop("twilio_phone_number")
+ twilio_phone_number_sid = st.session_state.pop(
+ "twilio_phone_number_sid"
+ )
+ bi = twilio_connect(
+ current_run=current_run,
+ published_run=published_run,
+ 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,
+ billing_user=self.request.user,
+ )
+ st.session_state["__twilio_redirect_url"] = self.current_app_url(
+ RecipeTabs.integrations,
+ path_params=dict(integration_id=bi.api_integration_id()),
+ )
+ pressed_platform = Platform.TWILIO
+ else:
+ return
+
+ if pressed_platform == None:
+ with (
+ st.tag("table", className="d-flex justify-content-center"),
+ st.tag("tbody"),
+ ):
+ for choice in connect_choices:
+ with st.tag("tr"):
+ with st.tag("td"):
+ if st.button(
+ f'',
+ className="p-0 border border-1 border-secondary rounded",
+ style=dict(width="160px", height="60px"),
+ ):
+ pressed_platform = choice.platform
+ with st.tag("td", className="ps-3"):
+ st.caption(choice.label)
+
+ if pressed_platform == Platform.TWILIO and not st.session_state.get(
+ "__twilio_redirect_url"
):
- for choice in connect_choices:
- with st.tag("tr"):
- with st.tag("td"):
- if st.button(
- f'',
- className="p-0 border border-1 border-secondary rounded",
- style=dict(width="160px", height="60px"),
- ):
- pressed_platform = choice.platform
- with st.tag("td", className="ps-3"):
- st.caption(choice.label)
+ st.session_state["__twilio_redirect_url"] = "connecting..."
+ st.experimental_rerun()
if pressed_platform:
on_connect = self.current_app_url(RecipeTabs.integrations)
@@ -1178,6 +1225,8 @@ def render_integrations_add(self, label: str, run_title: str):
redirect_url = slack_connect_url(on_connect)
case Platform.FACEBOOK:
redirect_url = fb_connect_url(on_connect)
+ case Platform.TWILIO:
+ redirect_url = st.session_state.pop("__twilio_redirect_url")
case _:
raise ValueError(f"Unsupported platform: {pressed_platform}")
@@ -1253,6 +1302,12 @@ def render_integrations_settings(
unsafe_allow_html=True,
new_tab=True,
)
+ if bi.twilio_phone_number:
+ copy_to_clipboard_button(
+ f' Copy Phone Number',
+ value=bi.twilio_phone_number,
+ type="secondary",
+ )
col1, col2 = st.columns(2, style={"alignItems": "center"})
with col1:
@@ -1272,6 +1327,8 @@ def render_integrations_settings(
unsafe_allow_html=True,
new_tab=True,
)
+ elif bi.platform == Platform.TWILIO and test_link:
+ pass
elif test_link:
st.anchor(
f"{icon} Message {bi.get_display_name()}",
@@ -1282,6 +1339,24 @@ def render_integrations_settings(
else:
st.write("Message quicklink not available.")
+ if bi.twilio_phone_number:
+ st.anchor(
+ ' Start Voice Call',
+ f"tel:{bi.twilio_phone_number}",
+ unsafe_allow_html=True,
+ new_tab=True,
+ )
+ st.anchor(
+ ' Send SMS',
+ f"sms:{bi.twilio_phone_number}",
+ unsafe_allow_html=True,
+ new_tab=True,
+ )
+ elif bi.platform == Platform.TWILIO:
+ st.write(
+ "Phone number incorrectly configured. Please re-add the integration and double check spelling. [Contact Us](support@gooey.ai) if you need help."
+ )
+
if bi.platform == Platform.WEB:
embed_code = get_web_widget_embed_code(bi)
copy_to_clipboard_button(
@@ -1310,6 +1385,13 @@ def render_integrations_settings(
),
new_tab=True,
)
+ if bi.platform == Platform.TWILIO and test_link:
+ st.anchor(
+ f"{icon} View Calls/Messages",
+ test_link,
+ unsafe_allow_html=True,
+ new_tab=True,
+ )
if bi.platform == Platform.WHATSAPP and bi.wa_business_waba_id:
col1, col2 = st.columns(2, style={"alignItems": "center"})
@@ -1344,9 +1426,11 @@ def render_integrations_settings(
with st.expander("Configure Settings 🛠️"):
if bi.platform == Platform.SLACK:
slack_specific_settings(bi, run_title)
+ if bi.platform == Platform.TWILIO:
+ twilio_specific_settings(bi)
general_integration_settings(bi, self.request.user)
- if bi.platform in [Platform.SLACK, Platform.WHATSAPP]:
+ if bi.platform in [Platform.SLACK, Platform.WHATSAPP, Platform.TWILIO]:
st.newline()
broadcast_input(bi)
st.write("---")
@@ -1631,4 +1715,9 @@ class ConnectChoice(typing.NamedTuple):
img="https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/9f201a92-1e9d-11ef-884b-02420a000134/thumbs/image_400x400.png",
label="Connect to a Facebook Page you own. [Help Guide](https://gooey.ai/docs/guides/copilot/deploy-to-facebook)",
),
+ ConnectChoice(
+ platform=Platform.TWILIO,
+ img="https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/6b30b13a-3980-11ef-b8f4-02420a000119/Twilio-logo-red.svg.png",
+ label="Connect a Twilio phone number to chat via SMS or Voice calls.",
+ ),
]
diff --git a/routers/twilio_api.py b/routers/twilio_api.py
new file mode 100644
index 000000000..1269938b6
--- /dev/null
+++ b/routers/twilio_api.py
@@ -0,0 +1,382 @@
+from twilio.rest import Client
+from twilio.twiml.voice_response import VoiceResponse, Gather
+from twilio.twiml.messaging_response import MessagingResponse
+
+from app_users.models import AppUser
+from bots.models import Conversation, BotIntegration, SavedRun, PublishedRun, Platform
+from daras_ai_v2 import settings
+from starlette.background import BackgroundTasks
+
+from furl import furl
+from fastapi import APIRouter, Response
+from daras_ai_v2.fastapi_tricks import fastapi_request_urlencoded_body
+import base64
+
+router = APIRouter()
+
+
+@router.post("/__/twilio/voice/")
+def twilio_voice_call(data: dict = fastapi_request_urlencoded_body):
+ """Handle incoming Twilio voice call."""
+
+ # data = {'AccountSid': ['XXXX'], 'ApiVersion': ['2010-04-01'], 'CallSid': ['XXXX'], 'CallStatus': ['ringing'], 'CallToken': ['XXXX'], 'Called': ['XXXX'], 'CalledCity': ['XXXX'], 'CalledCountry': ['XXXX'], 'CalledState': ['XXXX'], 'CalledZip': ['XXXX'], 'Caller': ['XXXX'], 'CallerCity': ['XXXX'], 'CallerCountry': ['XXXX'], 'CallerState': ['XXXX'], 'CallerZip': ['XXXX'], 'Direction': ['inbound'], 'From': ['XXXX'], 'FromCity': ['XXXX'], 'FromCountry': ['XXXX'], 'FromState': ['XXXX'], 'FromZip': ['XXXX'], 'StirVerstat': ['XXXX'], 'To': ['XXXX'], 'ToCity': ['XXXX'], 'ToCountry': ['XXXX'], 'ToState': ['XXXX'], 'ToZip': ['XXXX']}
+
+ account_sid = data["AccountSid"][0]
+ phone_number = data["To"][0]
+
+ try:
+ bi = BotIntegration.objects.get(
+ twilio_account_sid=account_sid, twilio_phone_number=phone_number
+ )
+ except BotIntegration.DoesNotExist:
+ return Response(status_code=404)
+
+ if bi.twilio_use_missed_call:
+ resp = VoiceResponse()
+ resp.reject()
+
+ # TODO: use start_voice_call to call the user back after a short delay
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+ resp = VoiceResponse()
+
+ gather = Gather(
+ 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(twilio_voice_call_ask), # the URL to send the user's question to
+ method="POST",
+ finish_on_key="0", # user can press 0 to end the input
+ language=bi.twilio_asr_language,
+ speech_model="phone_call", # optimized for phone call audio
+ enhanced=True, # only phone_call model supports enhanced
+ )
+
+ # say the initial text/audio while allowing the user to interrupt with their question if they want
+ if bi.twilio_initial_text.strip():
+ gather.say(bi.twilio_initial_text, voice=bi.twilio_voice)
+ if bi.twilio_initial_audio_url.strip():
+ gather.play(bi.twilio_initial_audio_url)
+ if not bi.twilio_initial_text and not bi.twilio_initial_audio_url:
+ gather.say(
+ f"Welcome to {bi.name}! Please ask your question and press 0 if the end of your question isn't detected.",
+ voice=bi.twilio_voice,
+ )
+
+ resp.append(gather)
+
+ # if the user doesn't say anything, we'll ask them to call back in a quieter environment
+ resp.say(
+ "Sorry, I didn't get that. Please call again in a more quiet environment.",
+ voice=bi.twilio_voice,
+ )
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+
+@router.post("/__/twilio/voice/listen/{bi_id}/")
+def twilio_voice_call_listen(bi_id: int):
+ """Listen for a response. Not the initial call, but has just responded to a question."""
+
+ try:
+ bi = BotIntegration.objects.get(id=bi_id)
+ except BotIntegration.DoesNotExist:
+ return Response(status_code=404)
+
+ resp = VoiceResponse()
+
+ resp.gather(
+ 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(twilio_voice_call_ask), # the URL to send the user's question to
+ method="POST",
+ finish_on_key="0", # user can press 0 to end the input
+ language=bi.twilio_asr_language,
+ speech_model="phone_call", # optimized for phone call audio
+ enhanced=True, # only phone_call model supports enhanced
+ )
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+
+@router.post("/__/twilio/voice/ask/")
+def twilio_voice_call_ask(
+ background_tasks: BackgroundTasks, data: dict = fastapi_request_urlencoded_body
+):
+ """After the initial call, the user has been asked a question. Handle their question."""
+ from daras_ai_v2.bots import msg_handler
+ from daras_ai_v2.twilio_bot import TwilioVoice
+
+ # data = {'AccountSid': ['XXXX'], 'ApiVersion': ['2010-04-01'], 'CallSid': ['XXXX'], 'CallStatus': ['in-progress'], 'Called': ['XXXX'], 'CalledCity': ['XXXX'], 'CalledCountry': ['XXXX'], 'CalledState': ['XXXX'], 'CalledZip': ['XXXX'], 'Caller': ['XXXX'], 'CallerCity': ['XXXX'], 'CallerCountry': ['XXXX'], 'CallerState': ['XXXX'], 'CallerZip': ['XXXX'], 'Confidence': ['0.9128386'], 'Direction': ['inbound'], 'From': ['XXXX'], 'FromCity': ['XXXX'], 'FromCountry': ['XXXX'], 'FromState': ['XXXX'], 'FromZip': ['XXXX'], 'Language': ['en-US'], 'SpeechResult': ['Hello.'], 'To': ['XXXX'], 'ToCity': ['XXXX'], 'ToCountry': ['XXXX'], 'ToState': ['XXXX'], 'ToZip': ['XXXX']}
+
+ account_sid = data["AccountSid"][0]
+ user_phone_number = data["From"][0]
+ phone_number = data["To"][0]
+ text = data["SpeechResult"][0]
+
+ try:
+ bi = BotIntegration.objects.get(
+ twilio_account_sid=account_sid, twilio_phone_number=phone_number
+ )
+ except BotIntegration.DoesNotExist:
+ return Response(status_code=404)
+
+ # start processing the user's question
+ queue_name = f"Queue for {bi.name} ({user_phone_number})"
+ bot = TwilioVoice(
+ incoming_number=user_phone_number,
+ queue_name=queue_name,
+ text=text,
+ audio=None,
+ bi=bi,
+ )
+ background_tasks.add_task(msg_handler, bot)
+
+ # send back waiting audio
+ resp = VoiceResponse()
+ resp.say("I heard " + text, voice=bi.twilio_voice)
+
+ resp.enqueue(
+ name=queue_name,
+ wait_url=url_for(twilio_voice_call_wait, bi_id=bi.id),
+ wait_url_method="POST",
+ )
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+
+@router.post("/__/twilio/voice/wait/{bi_id}/")
+def twilio_voice_call_wait(bi_id: int):
+ """Play the waiting audio for the user in the queue."""
+
+ try:
+ bi = BotIntegration.objects.get(id=bi_id)
+ except BotIntegration.DoesNotExist:
+ return Response(status_code=404)
+
+ resp = VoiceResponse()
+
+ if bi.twilio_waiting_audio_url:
+ resp.play(bi.twilio_waiting_audio_url)
+ else:
+ resp.play("http://com.twilio.sounds.music.s3.amazonaws.com/ClockworkWaltz.mp3")
+ resp.play("http://com.twilio.sounds.music.s3.amazonaws.com/BusyStrings.mp3")
+ resp.play(
+ "http://com.twilio.sounds.music.s3.amazonaws.com/oldDog_-_endless_goodbye_%28instr.%29.mp3"
+ )
+ resp.play(
+ "http://com.twilio.sounds.music.s3.amazonaws.com/Mellotroniac_-_Flight_Of_Young_Hearts_Flute.mp3"
+ )
+ resp.play(
+ "http://com.twilio.sounds.music.s3.amazonaws.com/MARKOVICHAMP-Borghestral.mp3"
+ )
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+
+def twilio_voice_call_respond(
+ text: str | None,
+ audio_url: str | None,
+ queue_name: str,
+ user_phone_number: 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("Front").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, data: dict = fastapi_request_urlencoded_body
+):
+ """Response is ready, user has been dequeued, send the response."""
+
+ print("Twilio voice response", data)
+
+ text = base64.b64decode(text).decode() if text != "N" else ""
+ audio_url = base64.b64decode(audio_url).decode() if audio_url != "N" else ""
+
+ resp = VoiceResponse()
+
+ if text:
+ resp.say(text)
+ if audio_url:
+ resp.play(audio_url)
+
+ resp.redirect(url_for(twilio_voice_call_listen, bi_id=bi_id))
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+
+# used for debugging, can be removed
+@router.post("/__/twilio/voice/status/")
+def twilio_voice_call_status(data: dict = fastapi_request_urlencoded_body):
+ """Handle incoming Twilio voice call status update."""
+
+ print("Twilio status update", data)
+
+ return Response(status_code=204)
+
+
+@router.post("/__/twilio/voice/error/")
+def twilio_voice_call_error():
+ """If an unhandled error occurs in the voice call webhook, return a generic error message."""
+
+ resp = VoiceResponse()
+ resp.say(
+ "Sorry. This number has been incorrectly configured. Contact the bot integration owner or try again later."
+ )
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+
+@router.post("/__/twilio/sms/")
+def twilio_sms(data: dict = fastapi_request_urlencoded_body):
+ """Handle incoming Twilio SMS."""
+ print("Twilio SMS", data)
+
+ resp = MessagingResponse()
+ resp.message("Thank you for your message!")
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+
+@router.post("/__/twilio/sms/error/")
+def twilio_sms_error():
+ """If an unhandled error occurs in the SMS webhook, return a generic error message."""
+
+ resp = MessagingResponse()
+ resp.message(
+ "Sorry. This number has been incorrectly configured. Contact the bot integration owner or try again later."
+ )
+
+ return Response(str(resp), headers={"Content-Type": "text/xml"})
+
+
+def start_voice_call(convo: Conversation, text: str | None, audio_url: str | None):
+ """Start a new voice call saying the given text and audio URL. Useful for notifications."""
+
+ assert (
+ convo.twilio_phone_number
+ ), "This is not a Twilio conversation, it has no phone number."
+
+ voice = convo.bot_integration.twilio_voice
+
+ account_sid = convo.bot_integration.twilio_account_sid
+ auth_token = convo.bot_integration.twilio_auth_token
+ client = Client(account_sid, auth_token)
+
+ resp = VoiceResponse()
+ if text:
+ resp.say(text, voice=voice)
+ if audio_url:
+ resp.play(audio_url)
+
+ call = client.calls.create(
+ twiml=str(resp),
+ to=convo.twilio_phone_number,
+ from_=convo.bot_integration.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",
+ 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,
+ )
diff --git a/server.py b/server.py
index 2d8cc5630..21bc876b7 100644
--- a/server.py
+++ b/server.py
@@ -45,6 +45,7 @@
stripe,
broadcast_api,
bots_api,
+ twilio_api,
)
import url_shortener.routers as url_shortener
@@ -62,6 +63,7 @@
app.include_router(url_shortener.app, include_in_schema=False)
app.include_router(paypal.router, include_in_schema=False)
app.include_router(stripe.router, include_in_schema=False)
+app.include_router(twilio_api.router, include_in_schema=False)
app.add_middleware(
CORSMiddleware,