Add Exotel support to the development runner

This commit is contained in:
Mark Backman
2025-08-12 13:01:25 -04:00
parent 5f8433476c
commit a40327305c
3 changed files with 57 additions and 10 deletions

View File

@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added Exotel support to Pipecat's development runner. You can now connect
using the runner with `uv run bot.py -t exotel` and an ngrok connection to
HTTP port 7860.
- Added `enable_direct_mode` argument to `FrameProcessor`. The direct mode is - Added `enable_direct_mode` argument to `FrameProcessor`. The direct mode is
for processors which require very little I/O or compute resources, that is for processors which require very little I/O or compute resources, that is
processors that can perform their task almost immediately. These type of processors that can perform their task almost immediately. These type of

View File

@@ -53,7 +53,7 @@ Supported transports:
- Daily - Creates rooms and tokens, runs bot as participant - Daily - Creates rooms and tokens, runs bot as participant
- WebRTC - Provides local WebRTC interface with prebuilt UI - WebRTC - Provides local WebRTC interface with prebuilt UI
- Telephony - Handles webhook and WebSocket connections for Twilio, Telnyx, Plivo - Telephony - Handles webhook and WebSocket connections for Twilio, Telnyx, Plivo, Exotel
To run locally: To run locally:
@@ -62,6 +62,7 @@ To run locally:
- Daily (server): `python bot.py -t daily` - Daily (server): `python bot.py -t daily`
- Daily (direct, testing only): `python bot.py -d` - Daily (direct, testing only): `python bot.py -d`
- Telephony: `python bot.py -t twilio -x your_username.ngrok.io` - Telephony: `python bot.py -t twilio -x your_username.ngrok.io`
- Exotel: `python bot.py -t exotel` (no proxy needed, but ngrok connection to HTTP 7860 is required)
""" """
import argparse import argparse
@@ -168,7 +169,7 @@ def _create_server_app(
_setup_webrtc_routes(app, esp32_mode=esp32_mode, host=host) _setup_webrtc_routes(app, esp32_mode=esp32_mode, host=host)
elif transport_type == "daily": elif transport_type == "daily":
_setup_daily_routes(app) _setup_daily_routes(app)
elif transport_type in ["twilio", "telnyx", "plivo"]: elif transport_type in ["twilio", "telnyx", "plivo", "exotel"]:
_setup_telephony_routes(app, transport_type, proxy) _setup_telephony_routes(app, transport_type, proxy)
else: else:
logger.warning(f"Unknown transport type: {transport_type}") logger.warning(f"Unknown transport type: {transport_type}")
@@ -333,7 +334,7 @@ def _setup_daily_routes(app: FastAPI):
def _setup_telephony_routes(app: FastAPI, transport_type: str, proxy: str): def _setup_telephony_routes(app: FastAPI, transport_type: str, proxy: str):
"""Set up telephony-specific routes.""" """Set up telephony-specific routes."""
# XML response templates # XML response templates (Exotel doesn't use XML webhooks)
XML_TEMPLATES = { XML_TEMPLATES = {
"twilio": f"""<?xml version="1.0" encoding="UTF-8"?> "twilio": f"""<?xml version="1.0" encoding="UTF-8"?>
<Response> <Response>
@@ -358,9 +359,18 @@ def _setup_telephony_routes(app: FastAPI, transport_type: str, proxy: str):
@app.post("/") @app.post("/")
async def start_call(): async def start_call():
"""Handle telephony webhook and return XML response.""" """Handle telephony webhook and return XML response."""
logger.debug(f"POST {transport_type.upper()} XML") if transport_type == "exotel":
xml_content = XML_TEMPLATES.get(transport_type, "<Response></Response>") # Exotel doesn't use POST webhooks - redirect to proper documentation
return HTMLResponse(content=xml_content, media_type="application/xml") logger.debug("POST Exotel endpoint - not used")
return {
"error": "Exotel doesn't use POST webhooks",
"websocket_url": f"wss://{proxy}/ws",
"note": "Configure the WebSocket URL above in your Exotel App Bazaar Voicebot Applet",
}
else:
logger.debug(f"POST {transport_type.upper()} XML")
xml_content = XML_TEMPLATES.get(transport_type, "<Response></Response>")
return HTMLResponse(content=xml_content, media_type="application/xml")
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
@@ -436,7 +446,7 @@ def main():
Args: Args:
--host: Server host address (default: localhost) --host: Server host address (default: localhost)
--port: Server port (default: 7860) --port: Server port (default: 7860)
-t/--transport: Transport type (daily, webrtc, twilio, telnyx, plivo) -t/--transport: Transport type (daily, webrtc, twilio, telnyx, plivo, exotel)
-x/--proxy: Public proxy hostname for telephony webhooks -x/--proxy: Public proxy hostname for telephony webhooks
--esp32: Enable SDP munging for ESP32 compatibility (requires --host with IP address) --esp32: Enable SDP munging for ESP32 compatibility (requires --host with IP address)
-d/--direct: Connect directly to Daily room (automatically sets transport to daily) -d/--direct: Connect directly to Daily room (automatically sets transport to daily)
@@ -451,7 +461,7 @@ def main():
"-t", "-t",
"--transport", "--transport",
type=str, type=str,
choices=["daily", "webrtc", "twilio", "telnyx", "plivo"], choices=["daily", "webrtc", "twilio", "telnyx", "plivo", "exotel"],
default="webrtc", default="webrtc",
help="Transport type", help="Transport type",
) )

View File

@@ -77,6 +77,17 @@ def _detect_transport_type_from_message(message_data: dict) -> str:
logger.trace("Auto-detected: PLIVO") logger.trace("Auto-detected: PLIVO")
return "plivo" return "plivo"
# Exotel detection
if (
message_data.get("event") == "start"
and "start" in message_data
and "stream_sid" in message_data.get("start", {})
and "call_sid" in message_data.get("start", {})
and "account_sid" in message_data.get("start", {})
):
logger.trace("Auto-detected: EXOTEL")
return "exotel"
logger.trace("Auto-detection failed - unknown format") logger.trace("Auto-detection failed - unknown format")
return "unknown" return "unknown"
@@ -91,6 +102,7 @@ async def parse_telephony_websocket(websocket: WebSocket):
- Twilio: {"stream_id": str, "call_id": str} - Twilio: {"stream_id": str, "call_id": str}
- Telnyx: {"stream_id": str, "call_control_id": str, "outbound_encoding": str} - Telnyx: {"stream_id": str, "call_control_id": str, "outbound_encoding": str}
- Plivo: {"stream_id": str, "call_id": str} - Plivo: {"stream_id": str, "call_id": str}
- Exotel: {"stream_id": str, "call_id": str, "account_sid": str}
Example usage:: Example usage::
@@ -160,6 +172,14 @@ async def parse_telephony_websocket(websocket: WebSocket):
"call_id": start_data.get("callId"), "call_id": start_data.get("callId"),
} }
elif transport_type == "exotel":
start_data = call_data_raw.get("start", {})
call_data = {
"stream_id": start_data.get("stream_sid"),
"call_id": start_data.get("call_sid"),
"account_sid": start_data.get("account_sid"),
}
else: else:
call_data = {} call_data = {}
@@ -379,10 +399,17 @@ async def _create_telephony_transport(
auth_id=os.getenv("PLIVO_AUTH_ID", ""), auth_id=os.getenv("PLIVO_AUTH_ID", ""),
auth_token=os.getenv("PLIVO_AUTH_TOKEN", ""), auth_token=os.getenv("PLIVO_AUTH_TOKEN", ""),
) )
elif transport_type == "exotel":
from pipecat.serializers.exotel import ExotelFrameSerializer
params.serializer = ExotelFrameSerializer(
stream_sid=call_data["stream_id"],
call_sid=call_data["call_id"],
)
else: else:
raise ValueError( raise ValueError(
f"Unsupported telephony provider: {transport_type}. " f"Unsupported telephony provider: {transport_type}. "
f"Supported providers: twilio, telnyx, plivo" f"Supported providers: twilio, telnyx, plivo, exotel"
) )
return FastAPIWebsocketTransport(websocket=websocket, params=params) return FastAPIWebsocketTransport(websocket=websocket, params=params)
@@ -399,7 +426,7 @@ async def create_transport(
Args: Args:
runner_args: Arguments from the runner. runner_args: Arguments from the runner.
transport_params: Dict mapping transport names to parameter factory functions. transport_params: Dict mapping transport names to parameter factory functions.
Keys should be: "daily", "webrtc", "twilio", "telnyx", "plivo" Keys should be: "daily", "webrtc", "twilio", "telnyx", "plivo", "exotel"
Values should be functions that return transport parameters when called. Values should be functions that return transport parameters when called.
Returns: Returns:
@@ -440,6 +467,12 @@ async def create_transport(
vad_analyzer=SileroVADAnalyzer(), vad_analyzer=SileroVADAnalyzer(),
# add_wav_header and serializer will be set automatically # add_wav_header and serializer will be set automatically
), ),
"exotel": lambda: FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
# add_wav_header and serializer will be set automatically
),
} }
transport = await create_transport(runner_args, transport_params) transport = await create_transport(runner_args, transport_params)