open source
This commit is contained in:
parent
70b6e17c69
commit
a93bfc1ec9
61 changed files with 4013 additions and 126 deletions
0
vocode/streaming/telephony/__init__.py
Normal file
0
vocode/streaming/telephony/__init__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
from redis import Redis
|
||||
|
||||
from vocode.streaming.models.telephony import CallConfig
|
||||
|
||||
|
||||
class BaseConfigManager:
|
||||
def save_config(self, conversation_id: str, config: CallConfig):
|
||||
raise NotImplementedError
|
||||
|
||||
def get_config(self, conversation_id) -> Optional[CallConfig]:
|
||||
raise NotImplementedError
|
||||
|
||||
def delete_config(self, conversation_id):
|
||||
raise NotImplementedError
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import logging
|
||||
import os
|
||||
from typing import Optional
|
||||
from redis import Redis
|
||||
|
||||
from vocode.streaming.models.telephony import CallConfig
|
||||
from vocode.streaming.telephony.config_manager.base_config_manager import (
|
||||
BaseConfigManager,
|
||||
)
|
||||
|
||||
|
||||
class RedisConfigManager(BaseConfigManager):
|
||||
def __init__(self, logger: Optional[logging.Logger] = None):
|
||||
self.redis = Redis(
|
||||
host=os.environ.get("REDISHOST", "localhost"),
|
||||
port=int(os.environ.get("REDISPORT", 6379)),
|
||||
db=0,
|
||||
decode_responses=True,
|
||||
)
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
|
||||
def save_config(self, conversation_id: str, config: CallConfig):
|
||||
self.logger.debug(f"Saving config for {conversation_id}")
|
||||
self.redis.set(conversation_id, config.json())
|
||||
|
||||
def get_config(self, conversation_id) -> Optional[CallConfig]:
|
||||
self.logger.debug(f"Getting config for {conversation_id}")
|
||||
raw_config = self.redis.get(conversation_id)
|
||||
if raw_config:
|
||||
return CallConfig.parse_raw(self.redis.get(conversation_id))
|
||||
|
||||
def delete_config(self, conversation_id):
|
||||
self.logger.debug(f"Deleting config for {conversation_id}")
|
||||
self.redis.delete(conversation_id)
|
||||
5
vocode/streaming/telephony/constants.py
Normal file
5
vocode/streaming/telephony/constants.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from vocode.streaming.models.audio_encoding import AudioEncoding
|
||||
|
||||
DEFAULT_SAMPLING_RATE = 8000
|
||||
DEFAULT_AUDIO_ENCODING = AudioEncoding.MULAW
|
||||
DEFAULT_CHUNK_SIZE = 20 * 160
|
||||
170
vocode/streaming/telephony/conversation/call.py
Normal file
170
vocode/streaming/telephony/conversation/call.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
from fastapi import WebSocket
|
||||
import base64
|
||||
from enum import Enum
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from vocode.streaming.agent.base_agent import BaseAgent
|
||||
from vocode.streaming.factory import (
|
||||
create_agent,
|
||||
create_synthesizer,
|
||||
create_transcriber,
|
||||
)
|
||||
|
||||
from vocode.streaming.streaming_conversation import StreamingConversation
|
||||
from vocode.streaming.models.telephony import CallConfig, TwilioConfig
|
||||
from vocode.streaming.output_device.twilio_output_device import TwilioOutputDevice
|
||||
from vocode.streaming.models.synthesizer import (
|
||||
AzureSynthesizerConfig,
|
||||
)
|
||||
from vocode.streaming.models.transcriber import (
|
||||
DeepgramTranscriberConfig,
|
||||
PunctuationEndpointingConfig,
|
||||
)
|
||||
from vocode.streaming.synthesizer.azure_synthesizer import AzureSynthesizer
|
||||
from vocode.streaming.synthesizer.base_synthesizer import BaseSynthesizer
|
||||
from vocode.streaming.telephony.config_manager.base_config_manager import (
|
||||
BaseConfigManager,
|
||||
)
|
||||
from vocode.streaming.telephony.twilio import create_twilio_client
|
||||
from vocode.streaming.models.audio_encoding import AudioEncoding
|
||||
from vocode.streaming.streaming_conversation import StreamingConversation
|
||||
from vocode.streaming.transcriber.base_transcriber import BaseTranscriber
|
||||
from vocode.streaming.transcriber.deepgram_transcriber import DeepgramTranscriber
|
||||
|
||||
|
||||
class PhoneCallAction(Enum):
|
||||
CLOSE_WEBSOCKET = 1
|
||||
|
||||
|
||||
class Call(StreamingConversation):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
config_manager: BaseConfigManager,
|
||||
agent: BaseAgent,
|
||||
twilio_config: TwilioConfig,
|
||||
transcriber: Optional[BaseTranscriber] = None,
|
||||
synthesizer: Optional[BaseSynthesizer] = None,
|
||||
twilio_sid=None,
|
||||
conversation_id: Optional[str] = None,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.config_manager = config_manager
|
||||
self.output_device = TwilioOutputDevice()
|
||||
self.twilio_config = twilio_config
|
||||
self.twilio_client = create_twilio_client(twilio_config)
|
||||
super().__init__(
|
||||
self.output_device,
|
||||
transcriber
|
||||
or DeepgramTranscriber(
|
||||
DeepgramTranscriberConfig(
|
||||
sampling_rate=8000,
|
||||
audio_encoding=AudioEncoding.MULAW,
|
||||
chunk_size=self.CHUNK_SIZE,
|
||||
model="voicemail",
|
||||
endpointing_config=PunctuationEndpointingConfig(),
|
||||
),
|
||||
logger=logger,
|
||||
),
|
||||
agent,
|
||||
synthesizer
|
||||
or AzureSynthesizer(
|
||||
AzureSynthesizerConfig(
|
||||
sampling_rate=8000, audio_encoding=AudioEncoding.MULAW
|
||||
)
|
||||
),
|
||||
conversation_id=conversation_id,
|
||||
per_chunk_allowance_seconds=0.01,
|
||||
logger=logger,
|
||||
)
|
||||
self.twilio_sid = twilio_sid
|
||||
self.latest_media_timestamp = 0
|
||||
|
||||
@staticmethod
|
||||
def from_call_config(
|
||||
base_url: str,
|
||||
call_config: CallConfig,
|
||||
config_manager: BaseConfigManager,
|
||||
conversation_id: str,
|
||||
logger: logging.Logger,
|
||||
):
|
||||
return Call(
|
||||
base_url=base_url,
|
||||
logger=logger,
|
||||
config_manager=config_manager,
|
||||
agent=create_agent(call_config.agent_config),
|
||||
transcriber=create_transcriber(call_config.transcriber_config),
|
||||
synthesizer=create_synthesizer(call_config.synthesizer_config),
|
||||
twilio_config=call_config.twilio_config,
|
||||
twilio_sid=call_config.twilio_sid,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
async def attach_ws_and_start(self, ws: WebSocket):
|
||||
self.logger.debug("Trying to attach WS to outbound call")
|
||||
self.output_device.ws = ws
|
||||
self.logger.debug("Attached WS to outbound call")
|
||||
|
||||
twilio_call = self.twilio_client.calls(self.twilio_sid).fetch()
|
||||
|
||||
if twilio_call.answered_by in ("machine_start", "fax"):
|
||||
self.logger.info(f"Call answered by {twilio_call.answered_by}")
|
||||
twilio_call.update(status="completed")
|
||||
else:
|
||||
await self.wait_for_twilio_start(ws)
|
||||
await super().start()
|
||||
while self.active:
|
||||
message = await ws.receive_text()
|
||||
response = await self.handle_ws_message(message)
|
||||
if response == PhoneCallAction.CLOSE_WEBSOCKET:
|
||||
break
|
||||
self.tear_down()
|
||||
|
||||
async def wait_for_twilio_start(self, ws: WebSocket):
|
||||
while True:
|
||||
message = await ws.receive_text()
|
||||
if not message:
|
||||
continue
|
||||
data = json.loads(message)
|
||||
if data["event"] == "start":
|
||||
self.logger.debug(
|
||||
f"Media WS: Received event '{data['event']}': {message}"
|
||||
)
|
||||
self.output_device.stream_sid = data["start"]["streamSid"]
|
||||
break
|
||||
|
||||
async def handle_ws_message(self, message) -> PhoneCallAction:
|
||||
if message is None:
|
||||
return PhoneCallAction.CLOSE_WEBSOCKET
|
||||
|
||||
data = json.loads(message)
|
||||
if data["event"] == "media":
|
||||
media = data["media"]
|
||||
chunk = base64.b64decode(media["payload"])
|
||||
if self.latest_media_timestamp + 20 < int(media["timestamp"]):
|
||||
bytes_to_fill = 8 * (
|
||||
int(media["timestamp"]) - (self.latest_media_timestamp + 20)
|
||||
)
|
||||
self.logger.debug(f"Filling {bytes_to_fill} bytes of silence")
|
||||
# NOTE: 0xff is silence for mulaw audio
|
||||
self.receive_audio(b"\xff" * bytes_to_fill)
|
||||
self.latest_media_timestamp = int(media["timestamp"])
|
||||
self.receive_audio(chunk)
|
||||
elif data["event"] == "stop":
|
||||
self.logger.debug(f"Media WS: Received event 'stop': {message}")
|
||||
self.logger.debug("Stopping...")
|
||||
return PhoneCallAction.CLOSE_WEBSOCKET
|
||||
|
||||
def end_twilio_call(self) -> bool:
|
||||
response = self.twilio_client.calls(self.twilio_sid).update(status="completed")
|
||||
return response.status == "completed"
|
||||
|
||||
def mark_terminated(self):
|
||||
super().mark_terminated()
|
||||
self.end_twilio_call()
|
||||
self.config_manager.delete_config(self.id)
|
||||
|
||||
def tear_down(self):
|
||||
self.terminate()
|
||||
110
vocode/streaming/telephony/conversation/outbound_call.py
Normal file
110
vocode/streaming/telephony/conversation/outbound_call.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
from twilio.rest import Client
|
||||
|
||||
from vocode.streaming.models.agent import AgentConfig
|
||||
from vocode.streaming.models.synthesizer import (
|
||||
AzureSynthesizerConfig,
|
||||
SynthesizerConfig,
|
||||
)
|
||||
from vocode.streaming.models.telephony import CallConfig, TwilioConfig
|
||||
from vocode.streaming.models.transcriber import (
|
||||
DeepgramTranscriberConfig,
|
||||
PunctuationEndpointingConfig,
|
||||
TranscriberConfig,
|
||||
)
|
||||
from vocode.streaming.telephony.config_manager.base_config_manager import (
|
||||
BaseConfigManager,
|
||||
)
|
||||
from vocode.streaming.telephony.constants import (
|
||||
DEFAULT_AUDIO_ENCODING,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_SAMPLING_RATE,
|
||||
)
|
||||
from vocode.streaming.telephony.twilio import create_twilio_client
|
||||
from vocode.streaming.utils import create_conversation_id
|
||||
|
||||
|
||||
class OutboundCall:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
to_phone: str,
|
||||
from_phone: str,
|
||||
config_manager: BaseConfigManager,
|
||||
agent_config: AgentConfig,
|
||||
twilio_config: TwilioConfig,
|
||||
transcriber_config: Optional[TranscriberConfig] = None,
|
||||
synthesizer_config: Optional[SynthesizerConfig] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.to_phone = to_phone
|
||||
self.from_phone = from_phone
|
||||
self.config_manager = config_manager
|
||||
self.agent_config = agent_config
|
||||
self.transcriber_config = transcriber_config or DeepgramTranscriberConfig(
|
||||
sampling_rate=DEFAULT_SAMPLING_RATE,
|
||||
audio_encoding=DEFAULT_AUDIO_ENCODING,
|
||||
chunk_size=DEFAULT_CHUNK_SIZE,
|
||||
model="voicemail",
|
||||
endpointing_config=PunctuationEndpointingConfig(),
|
||||
)
|
||||
self.synthesizer_config = synthesizer_config or AzureSynthesizerConfig(
|
||||
sampling_rate=DEFAULT_SAMPLING_RATE, audio_encoding=DEFAULT_AUDIO_ENCODING
|
||||
)
|
||||
self.conversation_id = conversation_id or create_conversation_id()
|
||||
self.logger = logger
|
||||
self.twilio_config = twilio_config
|
||||
self.twilio_client = create_twilio_client(twilio_config)
|
||||
self.twilio_sid = None
|
||||
|
||||
def create_twilio_call(
|
||||
self, to_phone: str, from_phone: str, digits: str = ""
|
||||
) -> str:
|
||||
twilio_call = self.twilio_client.calls.create(
|
||||
url=f"https://{self.base_url}/twiml/initiate_call/{self.conversation_id}",
|
||||
to=to_phone,
|
||||
from_=from_phone,
|
||||
send_digits=digits,
|
||||
)
|
||||
return twilio_call.sid
|
||||
|
||||
def validate_outbound_call(
|
||||
self,
|
||||
to_phone: str,
|
||||
from_phone: str,
|
||||
mobile_only: bool = True,
|
||||
):
|
||||
if len(to_phone) < 8:
|
||||
raise ValueError("Invalid 'to' phone")
|
||||
|
||||
if not mobile_only:
|
||||
return
|
||||
line_type_intelligence = (
|
||||
self.twilio_client.lookups.v2.phone_numbers(to_phone)
|
||||
.fetch(fields="line_type_intelligence")
|
||||
.line_type_intelligence
|
||||
)
|
||||
if not line_type_intelligence or (
|
||||
line_type_intelligence and line_type_intelligence["type"] != "mobile"
|
||||
):
|
||||
raise ValueError("Can only call mobile phones")
|
||||
|
||||
def start(self):
|
||||
self.logger.debug("Starting outbound call")
|
||||
self.validate_outbound_call(self.to_phone, self.from_phone)
|
||||
self.twilio_sid = self.create_twilio_call(self.to_phone, self.from_phone)
|
||||
call_config = CallConfig(
|
||||
transcriber_config=self.transcriber_config,
|
||||
agent_config=self.agent_config,
|
||||
synthesizer_config=self.synthesizer_config,
|
||||
twilio_config=self.twilio_config,
|
||||
twilio_sid=self.twilio_sid,
|
||||
)
|
||||
self.config_manager.save_config(self.conversation_id, call_config)
|
||||
|
||||
def end(self):
|
||||
response = self.twilio_client.calls(self.twilio_sid).update(status="completed")
|
||||
return response.status == "completed"
|
||||
73
vocode/streaming/telephony/conversation/zoom_dial_in.py
Normal file
73
vocode/streaming/telephony/conversation/zoom_dial_in.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
from twilio.rest import Client
|
||||
from vocode.streaming.agent.base_agent import BaseAgent
|
||||
from vocode.streaming.models.agent import AgentConfig
|
||||
from vocode.streaming.models.synthesizer import SynthesizerConfig
|
||||
from vocode.streaming.models.telephony import CallConfig, TwilioConfig
|
||||
from vocode.streaming.models.transcriber import TranscriberConfig
|
||||
from vocode.streaming.synthesizer.base_synthesizer import BaseSynthesizer
|
||||
from vocode.streaming.telephony.config_manager.base_config_manager import (
|
||||
BaseConfigManager,
|
||||
)
|
||||
from vocode.streaming.telephony.conversation.outbound_call import OutboundCall
|
||||
from vocode.streaming.transcriber.base_transcriber import BaseTranscriber
|
||||
from vocode.streaming.utils import create_conversation_id
|
||||
|
||||
|
||||
class ZoomDialIn(OutboundCall):
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
zoom_number: str,
|
||||
zoom_meeting_id: str,
|
||||
zoom_meeting_password: Optional[str],
|
||||
from_phone: str,
|
||||
config_manager: BaseConfigManager,
|
||||
twilio_config: TwilioConfig,
|
||||
agent_config: AgentConfig,
|
||||
transcriber_config: TranscriberConfig,
|
||||
synthesizer_config: SynthesizerConfig,
|
||||
conversation_id: Optional[str] = None,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
):
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
to_phone=zoom_number,
|
||||
from_phone=from_phone,
|
||||
config_manager=config_manager,
|
||||
transcriber_config=transcriber_config,
|
||||
agent_config=agent_config,
|
||||
synthesizer_config=synthesizer_config,
|
||||
twilio_config=twilio_config,
|
||||
conversation_id=conversation_id,
|
||||
logger=logger,
|
||||
)
|
||||
self.zoom_number = zoom_number
|
||||
self.zoom_meeting_id = zoom_meeting_id
|
||||
self.zoom_meeting_password = zoom_meeting_password
|
||||
self.from_phone = from_phone
|
||||
|
||||
def start(self):
|
||||
self.validate_outbound_call(
|
||||
self.zoom_number,
|
||||
self.from_phone,
|
||||
mobile_only=False,
|
||||
)
|
||||
digits = f"ww{self.zoom_meeting_id}#"
|
||||
if self.zoom_meeting_password:
|
||||
digits += f"wwww*{self.zoom_meeting_password}#"
|
||||
self.logger.debug("Sending digits %s to the call", digits)
|
||||
twilio_sid = self.create_twilio_call(
|
||||
self.zoom_number,
|
||||
self.from_phone,
|
||||
digits=digits,
|
||||
)
|
||||
call_config = CallConfig(
|
||||
transcriber_config=self.transcriber_config,
|
||||
agent_config=self.agent_config,
|
||||
synthesizer_config=self.synthesizer_config,
|
||||
twilio_config=self.twilio_config,
|
||||
twilio_sid=twilio_sid,
|
||||
)
|
||||
self.config_manager.save_config(self.conversation_id, call_config)
|
||||
62
vocode/streaming/telephony/hosted/inbound_call_server.py
Normal file
62
vocode/streaming/telephony/hosted/inbound_call_server.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from fastapi import FastAPI, Response, Form
|
||||
from typing import Optional
|
||||
import requests
|
||||
import uvicorn
|
||||
|
||||
import vocode
|
||||
from vocode.streaming.models.transcriber import TranscriberConfig
|
||||
from vocode.streaming.models.synthesizer import SynthesizerConfig
|
||||
from vocode.streaming.models.agent import AgentConfig
|
||||
from vocode.streaming.models.telephony import (
|
||||
CreateInboundCall,
|
||||
TwilioConfig,
|
||||
TwilioConfig,
|
||||
)
|
||||
|
||||
|
||||
class InboundCallServer:
|
||||
def __init__(
|
||||
self,
|
||||
agent_config: AgentConfig,
|
||||
transcriber_config: Optional[TranscriberConfig] = None,
|
||||
synthesizer_config: Optional[SynthesizerConfig] = None,
|
||||
response_on_rate_limit: Optional[str] = None,
|
||||
twilio_config: Optional[TwilioConfig] = None,
|
||||
):
|
||||
self.agent_config = agent_config
|
||||
self.transcriber_config = transcriber_config
|
||||
self.synthesizer_config = synthesizer_config
|
||||
self.app = FastAPI()
|
||||
self.app.post("/vocode")(self.handle_call)
|
||||
self.response_on_rate_limit = (
|
||||
response_on_rate_limit
|
||||
or "The line is really busy right now, check back later!"
|
||||
)
|
||||
self.twilio_config = twilio_config
|
||||
self.vocode_inbound_call_url = f"https://{vocode.base_url}/create_inbound_call"
|
||||
|
||||
async def handle_call(self, twilio_sid: str = Form(alias="CallSid")):
|
||||
response = requests.post(
|
||||
self.vocode_inbound_call_url,
|
||||
headers={"Authorization": f"Bearer {vocode.api_key}"},
|
||||
json=CreateInboundCall(
|
||||
agent_config=self.agent_config,
|
||||
twilio_sid=twilio_sid,
|
||||
transcriber_config=self.transcriber_config,
|
||||
synthesizer_config=self.synthesizer_config,
|
||||
twilio_config=self.twilio_config,
|
||||
).dict(),
|
||||
)
|
||||
if response.status_code == 429:
|
||||
return Response(
|
||||
f"<Response><Say>{self.response_on_rate_limit}</Say></Response>",
|
||||
media_type="application/xml",
|
||||
)
|
||||
assert response.ok, response.text
|
||||
return Response(
|
||||
response.text,
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
def run(self, host="localhost", port=3000):
|
||||
uvicorn.run(self.app, host=host, port=port)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
from typing import Optional, Union
|
||||
from vocode.streaming.models.telephony import TwilioConfig
|
||||
from vocode.streaming.telephony.hosted.inbound_call_server import InboundCallServer
|
||||
from vocode.streaming.models.agent import (
|
||||
RESTfulAgentEnd,
|
||||
RESTfulAgentInput,
|
||||
RESTfulAgentText,
|
||||
RESTfulUserImplementedAgentConfig,
|
||||
)
|
||||
from vocode.streaming.models.transcriber import (
|
||||
TranscriberConfig,
|
||||
)
|
||||
from vocode.streaming.models.synthesizer import SynthesizerConfig
|
||||
|
||||
|
||||
class InboundCallUserAgentServer(InboundCallServer):
|
||||
def __init__(
|
||||
self,
|
||||
agent_config: RESTfulUserImplementedAgentConfig,
|
||||
transcriber_config: Optional[TranscriberConfig] = None,
|
||||
synthesizer_config: Optional[SynthesizerConfig] = None,
|
||||
response_on_rate_limit: Optional[str] = None,
|
||||
twilio_config: Optional[TwilioConfig] = None,
|
||||
):
|
||||
super().__init__(
|
||||
agent_config=agent_config,
|
||||
transcriber_config=transcriber_config,
|
||||
synthesizer_config=synthesizer_config,
|
||||
response_on_rate_limit=response_on_rate_limit,
|
||||
twilio_config=twilio_config,
|
||||
)
|
||||
assert isinstance(
|
||||
agent_config, RESTfulUserImplementedAgentConfig
|
||||
), "agent_config must be a RESTfulUserImplementedAgentConfig"
|
||||
self.app.post("/respond")(self.respond_rest)
|
||||
|
||||
async def respond(
|
||||
self, human_input, conversation_id
|
||||
) -> Union[RESTfulAgentText, RESTfulAgentEnd]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def respond_rest(
|
||||
self, request: RESTfulAgentInput
|
||||
) -> Union[RESTfulAgentText, RESTfulAgentEnd]:
|
||||
return await self.respond(request.human_input, request.conversation_id)
|
||||
68
vocode/streaming/telephony/hosted/outbound_call.py
Normal file
68
vocode/streaming/telephony/hosted/outbound_call.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from typing import Optional
|
||||
import requests
|
||||
|
||||
import vocode
|
||||
from vocode.streaming.models.agent import AgentConfig
|
||||
from vocode.streaming.models.synthesizer import SynthesizerConfig
|
||||
from vocode.streaming.models.transcriber import TranscriberConfig
|
||||
from vocode.streaming.models.telephony import (
|
||||
CallEntity,
|
||||
CreateOutboundCall,
|
||||
EndOutboundCall,
|
||||
TwilioConfig,
|
||||
)
|
||||
|
||||
|
||||
class OutboundCall:
|
||||
def __init__(
|
||||
self,
|
||||
recipient: CallEntity,
|
||||
caller: CallEntity,
|
||||
agent_config: AgentConfig,
|
||||
transcriber_config: Optional[TranscriberConfig] = None,
|
||||
synthesizer_config: Optional[SynthesizerConfig] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
twilio_config: Optional[TwilioConfig] = None,
|
||||
):
|
||||
self.recipient = recipient
|
||||
self.caller = caller
|
||||
self.agent_config = agent_config
|
||||
self.transcriber_config = transcriber_config
|
||||
self.synthesizer_config = synthesizer_config
|
||||
self.conversation_id = conversation_id
|
||||
self.twilio_config = twilio_config
|
||||
self.vocode_create_outbound_call_url = (
|
||||
f"https://{vocode.base_url}/create_outbound_call"
|
||||
)
|
||||
self.vocode_end_outbound_call_url = (
|
||||
f"https://{vocode.base_url}/end_outbound_call"
|
||||
)
|
||||
|
||||
def start(self) -> str:
|
||||
response = requests.post(
|
||||
self.vocode_create_outbound_call_url,
|
||||
headers={"Authorization": f"Bearer {vocode.api_key}"},
|
||||
json=CreateOutboundCall(
|
||||
recipient=self.recipient,
|
||||
caller=self.caller,
|
||||
agent_config=self.agent_config,
|
||||
transcriber_config=self.transcriber_config,
|
||||
synthesizer_config=self.synthesizer_config,
|
||||
conversation_id=self.conversation_id,
|
||||
twilio_config=self.twilio_config,
|
||||
).dict(),
|
||||
)
|
||||
assert response.ok, response.text
|
||||
data = response.json()
|
||||
self.conversation_id = data["id"]
|
||||
|
||||
def end(self) -> str:
|
||||
response = requests.post(
|
||||
self.vocode_end_outbound_call_url,
|
||||
headers={"Authorization": f"Bearer {vocode.api_key}"},
|
||||
json=EndOutboundCall(
|
||||
call_id=self.conversation_id,
|
||||
twilio_config=self.twilio_config,
|
||||
).dict(),
|
||||
)
|
||||
assert response.ok or response.status_code == 404, response.text
|
||||
60
vocode/streaming/telephony/hosted/zoom_dial_in.py
Normal file
60
vocode/streaming/telephony/hosted/zoom_dial_in.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from typing import Optional
|
||||
import requests
|
||||
|
||||
import vocode
|
||||
from vocode.streaming.models.agent import AgentConfig
|
||||
from vocode.streaming.models.synthesizer import SynthesizerConfig
|
||||
from vocode.streaming.models.transcriber import TranscriberConfig
|
||||
from vocode.streaming.telephony.hosted.outbound_call import OutboundCall
|
||||
from vocode.streaming.models.telephony import (
|
||||
CallEntity,
|
||||
DialIntoZoomCall,
|
||||
TwilioConfig,
|
||||
)
|
||||
|
||||
|
||||
class ZoomDialIn(OutboundCall):
|
||||
def __init__(
|
||||
self,
|
||||
recipient: CallEntity,
|
||||
caller: CallEntity,
|
||||
zoom_meeting_id: str,
|
||||
zoom_meeting_password: str,
|
||||
agent_config: AgentConfig,
|
||||
transcriber_config: Optional[TranscriberConfig] = None,
|
||||
synthesizer_config: Optional[SynthesizerConfig] = None,
|
||||
conversation_id: Optional[str] = None,
|
||||
twilio_config: Optional[TwilioConfig] = None,
|
||||
):
|
||||
super().__init__(
|
||||
recipient=recipient,
|
||||
caller=caller,
|
||||
agent_config=agent_config,
|
||||
transcriber_config=transcriber_config,
|
||||
synthesizer_config=synthesizer_config,
|
||||
conversation_id=conversation_id,
|
||||
twilio_config=twilio_config,
|
||||
)
|
||||
self.zoom_meeting_id = zoom_meeting_id
|
||||
self.zoom_meeting_password = zoom_meeting_password
|
||||
self.vocode_zoom_dial_in_url = f"https://{vocode.base_url}/dial_into_zoom_call"
|
||||
|
||||
def start(self) -> str:
|
||||
response = requests.post(
|
||||
self.vocode_zoom_dial_in_url,
|
||||
headers={"Authorization": f"Bearer {vocode.api_key}"},
|
||||
json=DialIntoZoomCall(
|
||||
recipient=self.recipient,
|
||||
caller=self.caller,
|
||||
zoom_meeting_id=self.zoom_meeting_id,
|
||||
zoom_meeting_password=self.zoom_meeting_password,
|
||||
agent_config=self.agent_config,
|
||||
transcriber_config=self.transcriber_config,
|
||||
synthesizer_config=self.synthesizer_config,
|
||||
conversation_id=self.conversation_id,
|
||||
twilio_config=self.twilio_config,
|
||||
).dict(),
|
||||
)
|
||||
assert response.ok, response.text
|
||||
data = response.json()
|
||||
self.conversation_id = data["id"]
|
||||
143
vocode/streaming/telephony/server/base.py
Normal file
143
vocode/streaming/telephony/server/base.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Form, Response
|
||||
from pydantic import BaseModel
|
||||
from vocode.streaming.agent.base_agent import BaseAgent
|
||||
from vocode.streaming.models.agent import AgentConfig
|
||||
from vocode.streaming.models.synthesizer import (
|
||||
AzureSynthesizerConfig,
|
||||
SynthesizerConfig,
|
||||
)
|
||||
from vocode.streaming.models.transcriber import (
|
||||
DeepgramTranscriberConfig,
|
||||
PunctuationEndpointingConfig,
|
||||
TranscriberConfig,
|
||||
)
|
||||
from vocode.streaming.synthesizer.base_synthesizer import BaseSynthesizer
|
||||
from vocode.streaming.telephony.config_manager.base_config_manager import (
|
||||
BaseConfigManager,
|
||||
)
|
||||
from vocode.streaming.telephony.constants import (
|
||||
DEFAULT_AUDIO_ENCODING,
|
||||
DEFAULT_CHUNK_SIZE,
|
||||
DEFAULT_SAMPLING_RATE,
|
||||
)
|
||||
|
||||
from vocode.streaming.telephony.server.router.calls import CallsRouter
|
||||
from vocode.streaming.telephony.server.router.twiml import TwiMLRouter
|
||||
from vocode.streaming.models.telephony import (
|
||||
CallConfig,
|
||||
CallEntity,
|
||||
CreateOutboundCall,
|
||||
CreateInboundCall,
|
||||
DialIntoZoomCall,
|
||||
EndOutboundCall,
|
||||
TwilioConfig,
|
||||
)
|
||||
from twilio.rest import Client
|
||||
|
||||
from vocode.streaming.telephony.conversation.call import Call
|
||||
from vocode.streaming.telephony.templates import Templater
|
||||
from vocode.streaming.transcriber.base_transcriber import BaseTranscriber
|
||||
from vocode.streaming.utils import create_conversation_id
|
||||
|
||||
|
||||
class InboundCallConfig(BaseModel):
|
||||
url: str
|
||||
agent_config: AgentConfig
|
||||
twilio_config: TwilioConfig
|
||||
transcriber_config: Optional[TranscriberConfig] = None
|
||||
synthesizer_config: Optional[SynthesizerConfig] = None
|
||||
|
||||
|
||||
class TelephonyServer:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
config_manager: BaseConfigManager,
|
||||
inbound_call_configs: list[InboundCallConfig] = [],
|
||||
logger: Optional[logging.Logger] = None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.router = APIRouter()
|
||||
self.config_manager = config_manager
|
||||
self.templater = Templater()
|
||||
self.router.include_router(
|
||||
CallsRouter(
|
||||
base_url=base_url,
|
||||
templater=self.templater,
|
||||
config_manager=self.config_manager,
|
||||
logger=self.logger,
|
||||
).get_router()
|
||||
)
|
||||
self.router.include_router(
|
||||
TwiMLRouter(
|
||||
base_url=base_url, templater=self.templater, logger=self.logger
|
||||
).get_router()
|
||||
)
|
||||
for config in inbound_call_configs:
|
||||
self.router.add_api_route(
|
||||
config.url,
|
||||
self.create_inbound_route(
|
||||
agent_config=config.agent_config,
|
||||
twilio_config=config.twilio_config,
|
||||
transcriber_config=config.transcriber_config,
|
||||
synthesizer_config=config.synthesizer_config,
|
||||
),
|
||||
methods=["POST"],
|
||||
)
|
||||
logger.info(f"Set up inbound call TwiML at https://{base_url}{config.url}")
|
||||
|
||||
def create_inbound_route(
|
||||
self,
|
||||
agent_config: AgentConfig,
|
||||
twilio_config: TwilioConfig,
|
||||
transcriber_config: Optional[TranscriberConfig] = None,
|
||||
synthesizer_config: Optional[SynthesizerConfig] = None,
|
||||
):
|
||||
def route(twilio_sid: str = Form(alias="CallSid")) -> Response:
|
||||
call_config = CallConfig(
|
||||
transcriber_config=transcriber_config
|
||||
or DeepgramTranscriberConfig(
|
||||
sampling_rate=DEFAULT_SAMPLING_RATE,
|
||||
audio_encoding=DEFAULT_AUDIO_ENCODING,
|
||||
chunk_size=DEFAULT_CHUNK_SIZE,
|
||||
model="voicemail",
|
||||
endpointing_config=PunctuationEndpointingConfig(),
|
||||
),
|
||||
agent_config=agent_config,
|
||||
synthesizer_config=synthesizer_config
|
||||
or AzureSynthesizerConfig(
|
||||
sampling_rate=DEFAULT_SAMPLING_RATE,
|
||||
audio_encoding=DEFAULT_AUDIO_ENCODING,
|
||||
),
|
||||
twilio_config=twilio_config,
|
||||
twilio_sid=twilio_sid,
|
||||
)
|
||||
|
||||
conversation_id = create_conversation_id()
|
||||
self.config_manager.save_config(conversation_id, call_config)
|
||||
return self.templater.get_connection_twiml(
|
||||
base_url=self.base_url, call_id=conversation_id
|
||||
)
|
||||
|
||||
return route
|
||||
|
||||
async def end_outbound_call(self, conversation_id: str):
|
||||
# TODO validation via twilio_client
|
||||
call_config = self.config_manager.get_config(conversation_id)
|
||||
if not call_config:
|
||||
raise ValueError("Call not found")
|
||||
call = Call.from_call_config(
|
||||
self.base_url,
|
||||
call_config,
|
||||
self.config_manager,
|
||||
conversation_id,
|
||||
self.logger,
|
||||
)
|
||||
call.end_twilio_call()
|
||||
return {"id": call.id}
|
||||
|
||||
def get_router(self) -> APIRouter:
|
||||
return self.router
|
||||
45
vocode/streaming/telephony/server/router/calls.py
Normal file
45
vocode/streaming/telephony/server/router/calls.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, WebSocket
|
||||
from vocode.streaming.telephony.config_manager.base_config_manager import (
|
||||
BaseConfigManager,
|
||||
)
|
||||
|
||||
from vocode.streaming.telephony.conversation.call import Call
|
||||
from vocode.streaming.telephony.templates import Templater
|
||||
|
||||
|
||||
class CallsRouter:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
templater: Templater,
|
||||
config_manager: BaseConfigManager,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.base_url = base_url
|
||||
self.templater = templater
|
||||
self.config_manager = config_manager
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.router = APIRouter()
|
||||
self.router.websocket("/connect_call/{id}")(self.connect_call)
|
||||
|
||||
async def connect_call(self, websocket: WebSocket, id: str):
|
||||
await websocket.accept()
|
||||
self.logger.debug("Phone WS connection opened for chat {}".format(id))
|
||||
call_config = self.config_manager.get_config(id)
|
||||
if not call_config:
|
||||
raise HTTPException(status_code=400, detail="No active phone call")
|
||||
|
||||
call: Call = Call.from_call_config(
|
||||
self.base_url, call_config, self.config_manager, id, self.logger
|
||||
)
|
||||
|
||||
await call.attach_ws_and_start(websocket)
|
||||
self.config_manager.delete_config(call.id)
|
||||
self.logger.debug("Phone WS connection closed for chat {}".format(id))
|
||||
|
||||
def get_router(self) -> APIRouter:
|
||||
return self.router
|
||||
29
vocode/streaming/telephony/server/router/twiml.py
Normal file
29
vocode/streaming/telephony/server/router/twiml.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from vocode.streaming.telephony.templates import Templater
|
||||
|
||||
|
||||
class TwiMLRouter:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
templater: Templater,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.base_url = base_url
|
||||
self.templater = templater
|
||||
self.logger = logger or logging.getLogger(__name__)
|
||||
self.router = APIRouter()
|
||||
self.router.add_api_route(
|
||||
"/twiml/initiate_call/{id}", self.call_twiml, methods=["POST"]
|
||||
)
|
||||
|
||||
def call_twiml(self, id: str):
|
||||
return self.templater.get_connection_twiml(base_url=self.base_url, call_id=id)
|
||||
|
||||
def get_router(self) -> APIRouter:
|
||||
return self.router
|
||||
20
vocode/streaming/telephony/templates.py
Normal file
20
vocode/streaming/telephony/templates.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import os
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from fastapi import Response
|
||||
|
||||
|
||||
class Templater:
|
||||
def __init__(self):
|
||||
self.templates = Environment(
|
||||
loader=FileSystemLoader("%s/templates/" % os.path.dirname(__file__))
|
||||
)
|
||||
|
||||
def render_template(self, template_name: str, **kwargs):
|
||||
template = self.templates.get_template(template_name)
|
||||
return template.render(**kwargs)
|
||||
|
||||
def get_connection_twiml(self, call_id: str, base_url: str):
|
||||
return Response(
|
||||
self.render_template("connect_call.xml", base_url=base_url, id=call_id),
|
||||
media_type="application/xml",
|
||||
)
|
||||
6
vocode/streaming/telephony/templates/connect_call.xml
Normal file
6
vocode/streaming/telephony/templates/connect_call.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="wss://{{ base_url }}/connect_call/{{ id }}" />
|
||||
</Connect>
|
||||
</Response>
|
||||
12
vocode/streaming/telephony/twilio.py
Normal file
12
vocode/streaming/telephony/twilio.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import os
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
from twilio.rest import Client
|
||||
|
||||
from vocode.streaming.models.telephony import TwilioConfig
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def create_twilio_client(twilio_config: TwilioConfig):
|
||||
return Client(twilio_config.account_sid, twilio_config.auth_token)
|
||||
Loading…
Add table
Add a link
Reference in a new issue