From a40327305ce5c6c8c890d7a30d44aa469df1e3f3 Mon Sep 17 00:00:00 2001 From: Mark Backman Date: Tue, 12 Aug 2025 13:01:25 -0400 Subject: [PATCH] Add Exotel support to the development runner --- CHANGELOG.md | 4 ++++ src/pipecat/runner/run.py | 26 ++++++++++++++++++-------- src/pipecat/runner/utils.py | 37 +++++++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb28c6536..3f7bd7428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 for processors which require very little I/O or compute resources, that is processors that can perform their task almost immediately. These type of diff --git a/src/pipecat/runner/run.py b/src/pipecat/runner/run.py index 719bb0496..e08465c0c 100644 --- a/src/pipecat/runner/run.py +++ b/src/pipecat/runner/run.py @@ -53,7 +53,7 @@ Supported transports: - Daily - Creates rooms and tokens, runs bot as participant - 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: @@ -62,6 +62,7 @@ To run locally: - Daily (server): `python bot.py -t daily` - Daily (direct, testing only): `python bot.py -d` - 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 @@ -168,7 +169,7 @@ def _create_server_app( _setup_webrtc_routes(app, esp32_mode=esp32_mode, host=host) elif transport_type == "daily": _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) else: 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): """Set up telephony-specific routes.""" - # XML response templates + # XML response templates (Exotel doesn't use XML webhooks) XML_TEMPLATES = { "twilio": f""" @@ -358,9 +359,18 @@ def _setup_telephony_routes(app: FastAPI, transport_type: str, proxy: str): @app.post("/") async def start_call(): """Handle telephony webhook and return XML response.""" - logger.debug(f"POST {transport_type.upper()} XML") - xml_content = XML_TEMPLATES.get(transport_type, "") - return HTMLResponse(content=xml_content, media_type="application/xml") + if transport_type == "exotel": + # Exotel doesn't use POST webhooks - redirect to proper documentation + 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, "") + return HTMLResponse(content=xml_content, media_type="application/xml") @app.websocket("/ws") async def websocket_endpoint(websocket: WebSocket): @@ -436,7 +446,7 @@ def main(): Args: --host: Server host address (default: localhost) --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 --esp32: Enable SDP munging for ESP32 compatibility (requires --host with IP address) -d/--direct: Connect directly to Daily room (automatically sets transport to daily) @@ -451,7 +461,7 @@ def main(): "-t", "--transport", type=str, - choices=["daily", "webrtc", "twilio", "telnyx", "plivo"], + choices=["daily", "webrtc", "twilio", "telnyx", "plivo", "exotel"], default="webrtc", help="Transport type", ) diff --git a/src/pipecat/runner/utils.py b/src/pipecat/runner/utils.py index e6cb0dc60..ec9bc352a 100644 --- a/src/pipecat/runner/utils.py +++ b/src/pipecat/runner/utils.py @@ -77,6 +77,17 @@ def _detect_transport_type_from_message(message_data: dict) -> str: logger.trace("Auto-detected: 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") return "unknown" @@ -91,6 +102,7 @@ async def parse_telephony_websocket(websocket: WebSocket): - Twilio: {"stream_id": str, "call_id": str} - Telnyx: {"stream_id": str, "call_control_id": str, "outbound_encoding": str} - Plivo: {"stream_id": str, "call_id": str} + - Exotel: {"stream_id": str, "call_id": str, "account_sid": str} Example usage:: @@ -160,6 +172,14 @@ async def parse_telephony_websocket(websocket: WebSocket): "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: call_data = {} @@ -379,10 +399,17 @@ async def _create_telephony_transport( auth_id=os.getenv("PLIVO_AUTH_ID", ""), 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: raise ValueError( 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) @@ -399,7 +426,7 @@ async def create_transport( Args: runner_args: Arguments from the runner. 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. Returns: @@ -440,6 +467,12 @@ async def create_transport( vad_analyzer=SileroVADAnalyzer(), # 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)