-
Notifications
You must be signed in to change notification settings - Fork 493
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
3dc1d49
commit 2bbe9fd
Showing
13 changed files
with
505 additions
and
9 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,138 @@ | ||
from __future__ import annotations | ||
|
||
import asyncio | ||
import base64 | ||
import json | ||
from typing import List, Optional, Union | ||
|
||
from fastapi import WebSocket | ||
from fastapi.websockets import WebSocketState | ||
from loguru import logger | ||
from pydantic import BaseModel | ||
|
||
from vocode.streaming.output_device.abstract_output_device import AbstractOutputDevice | ||
from vocode.streaming.output_device.audio_chunk import AudioChunk, ChunkState | ||
from vocode.streaming.telephony.constants import DEFAULT_SAMPLING_RATE, EXOTEL_AUDIO_ENCODING | ||
from vocode.streaming.utils.create_task import asyncio_create_task | ||
from vocode.streaming.utils.dtmf_utils import DTMFToneGenerator, KeypadEntry | ||
from vocode.streaming.utils.worker import InterruptibleEvent | ||
|
||
|
||
class ChunkFinishedMarkMessage(BaseModel): | ||
chunk_id: str | ||
|
||
|
||
MarkMessage = Union[ChunkFinishedMarkMessage] # space for more mark messages | ||
|
||
|
||
class ExotelOutputDevice(AbstractOutputDevice): | ||
def __init__(self, ws: Optional[WebSocket] = None, stream_sid: Optional[str] = None): | ||
super().__init__(sampling_rate=DEFAULT_SAMPLING_RATE, audio_encoding=EXOTEL_AUDIO_ENCODING) | ||
self.ws = ws | ||
self.stream_sid = stream_sid | ||
self.active = True | ||
|
||
self._exotel_events_queue: asyncio.Queue[str] = asyncio.Queue() | ||
self._mark_message_queue: asyncio.Queue[MarkMessage] = asyncio.Queue() | ||
self._unprocessed_audio_chunks_queue: asyncio.Queue[InterruptibleEvent[AudioChunk]] = ( | ||
asyncio.Queue() | ||
) | ||
|
||
def consume_nonblocking(self, item: InterruptibleEvent[AudioChunk]): | ||
if not item.is_interrupted(): | ||
self._send_audio_chunk_and_mark( | ||
chunk=item.payload.data, chunk_id=str(item.payload.chunk_id) | ||
) | ||
self._unprocessed_audio_chunks_queue.put_nowait(item) | ||
else: | ||
audio_chunk = item.payload | ||
audio_chunk.on_interrupt() | ||
audio_chunk.state = ChunkState.INTERRUPTED | ||
|
||
def interrupt(self): | ||
self._send_clear_message() | ||
|
||
def enqueue_mark_message(self, mark_message: MarkMessage): | ||
self._mark_message_queue.put_nowait(mark_message) | ||
|
||
def send_dtmf_tones(self, keypad_entries: List[KeypadEntry]): | ||
tone_generator = DTMFToneGenerator() | ||
for keypad_entry in keypad_entries: | ||
logger.info(f"Sending DTMF tone {keypad_entry.value}") | ||
dtmf_tone = tone_generator.generate( | ||
keypad_entry, sampling_rate=self.sampling_rate, audio_encoding=self.audio_encoding | ||
) | ||
dtmf_message = { | ||
"event": "media", | ||
"stream_sid": self.stream_sid, | ||
"media": {"payload": base64.b64encode(dtmf_tone).decode("utf-8")}, | ||
} | ||
self._exotel_events_queue.put_nowait(json.dumps(dtmf_message)) | ||
|
||
async def _send_exotel_messages(self): | ||
while True: | ||
try: | ||
exotel_event = await self._exotel_events_queue.get() | ||
except asyncio.CancelledError: | ||
return | ||
if self.ws.application_state == WebSocketState.DISCONNECTED: | ||
break | ||
await self.ws.send_text(exotel_event) | ||
|
||
async def _process_mark_messages(self): | ||
while True: | ||
try: | ||
# mark messages are tagged with the chunk ID that is attached to the audio chunk | ||
# but they are guaranteed to come in the same order as the audio chunks, and we | ||
# don't need to build resiliency there | ||
mark_message = await self._mark_message_queue.get() | ||
item = await self._unprocessed_audio_chunks_queue.get() | ||
except asyncio.CancelledError: | ||
return | ||
|
||
self.interruptible_event = item | ||
audio_chunk = item.payload | ||
|
||
if mark_message.chunk_id != str(audio_chunk.chunk_id): | ||
logger.error( | ||
f"Received a mark message out of order with chunk ID {mark_message.chunk_id}" | ||
) | ||
|
||
if item.is_interrupted(): | ||
audio_chunk.on_interrupt() | ||
audio_chunk.state = ChunkState.INTERRUPTED | ||
continue | ||
|
||
audio_chunk.on_play() | ||
audio_chunk.state = ChunkState.PLAYED | ||
|
||
self.interruptible_event.is_interruptible = False | ||
|
||
async def _run_loop(self): | ||
send_exotel_messages_task = asyncio_create_task(self._send_exotel_messages()) | ||
process_mark_messages_task = asyncio_create_task(self._process_mark_messages()) | ||
await asyncio.gather(send_exotel_messages_task, process_mark_messages_task) | ||
|
||
def _send_audio_chunk_and_mark(self, chunk: bytes, chunk_id: str): | ||
media_message = { | ||
"event": "media", | ||
"stream_sid": self.stream_sid, | ||
"media": {"payload": base64.b64encode(chunk).decode("utf-8")}, | ||
} | ||
self._exotel_events_queue.put_nowait(json.dumps(media_message)) | ||
|
||
mark_message = { | ||
"event": "mark", | ||
"stream_sid": self.stream_sid, | ||
"mark": { | ||
"name": chunk_id, | ||
}, | ||
} | ||
self._exotel_events_queue.put_nowait(json.dumps(mark_message)) | ||
|
||
def _send_clear_message(self): | ||
clear_message = { | ||
"event": "clear", | ||
"stream_sid": self.stream_sid, | ||
} | ||
self._exotel_events_queue.put_nowait(json.dumps(clear_message)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,81 @@ | ||
import os | ||
from typing import Dict, Optional | ||
|
||
import aiohttp | ||
import xmltodict | ||
from loguru import logger | ||
from vocode.streaming.models.telephony import ExotelConfig | ||
from vocode.streaming.telephony.client.abstract_telephony_client import AbstractTelephonyClient | ||
from vocode.streaming.utils.async_requester import AsyncRequestor | ||
|
||
|
||
class ExotelBadRequestException(ValueError): | ||
pass | ||
|
||
|
||
class ExotelException(ValueError): | ||
pass | ||
|
||
|
||
class ExotelClient(AbstractTelephonyClient): | ||
def __init__( | ||
self, | ||
base_url: str, | ||
maybe_exotel_config: Optional[ExotelConfig] = None, | ||
): | ||
self.exotel_config = maybe_exotel_config or ExotelConfig( | ||
account_sid=os.environ["EXOTEL_ACCOUNT_SID"], | ||
subdomain=os.environ["EXOTEL_SUBDOMAIN"], | ||
api_key=os.environ["EXOTEL_API_KEY"], | ||
api_token=os.environ["EXOTEL_API_TOKEN"], | ||
app_id=os.environ["EXOTEL_APP_ID"], | ||
) | ||
self.auth = aiohttp.BasicAuth(login=self.exotel_config.api_key, password=self.exotel_config.api_token) | ||
super().__init__(base_url=base_url) | ||
|
||
def get_telephony_config(self): | ||
return self.exotel_config | ||
|
||
@staticmethod | ||
def create_call_exotel(base_url, conversation_id, is_outbound: bool = False): | ||
return {"url": f"wss://{base_url}/connect_call/{conversation_id}"} | ||
|
||
async def create_call( | ||
self, | ||
conversation_id: str, | ||
to_phone: str, | ||
from_phone: str, | ||
record: bool = False, # currently no-op | ||
digits: Optional[str] = None, # currently no-op | ||
telephony_params: Optional[Dict[str, str]] = None, | ||
) -> str: | ||
data = { | ||
'From': to_phone, | ||
'CallerId': from_phone, | ||
'Url': f'http://my.exotel.com/{self.exotel_config.account_sid}/exoml/start_voice/{self.exotel_config.app_id}', | ||
'CustomField': conversation_id | ||
} | ||
async with AsyncRequestor().get_session().post( | ||
f'https://{self.exotel_config.subdomain}/v1/Accounts/{self.exotel_config.account_sid}/Calls/connect', | ||
auth=self.auth, | ||
data=data | ||
) as response: | ||
if not response.ok: | ||
if response.status == 400: | ||
logger.warning( | ||
f"Failed to create call: {response.status} {response.reason} {await response.json()}" | ||
) | ||
raise ExotelBadRequestException( | ||
"Telephony provider rejected call; this is usually due to a bad/malformed number. " | ||
) | ||
else: | ||
raise ExotelException( | ||
f"Twilio failed to create call: {response.status} {response.reason}" | ||
) | ||
xml_data = await response.text() | ||
exotel_response = xmltodict.parse(xml_data) | ||
call_sid = exotel_response['TwilioResponse']['Call']['Sid'] | ||
return call_sid | ||
|
||
async def end_call(self, twilio_sid): | ||
pass |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.