diff --git a/examples/phone-chatbot-daily-twilio-sip/README.md b/examples/phone-chatbot-daily-twilio-sip/README.md new file mode 100644 index 000000000..ca45f02eb --- /dev/null +++ b/examples/phone-chatbot-daily-twilio-sip/README.md @@ -0,0 +1,88 @@ +# Daily + Twilio SIP Voice Bot + +This project demonstrates how to create a voice bot that can receive phone calls via Twilio and use Daily's SIP capabilities to enable voice conversations. + +## How It Works + +1. Twilio receives an incoming call to your phone number +2. Twilio calls your webhook server (`/call` endpoint) +3. The server creates a Daily room with SIP capabilities +4. The server starts the bot process with the room details +5. The caller is put on hold with music +6. The bot joins the Daily room and signals readiness +7. Twilio forwards the call to Daily's SIP endpoint +8. The caller and bot are connected, and the bot handles the conversation + +## Prerequisites + +- A Daily account with an API key +- A Twilio account with a phone number that supports voice +- OpenAI API key for the bot's intelligence +- ElevenLabs API key for text-to-speech (optional but recommended) + +## Setup + +1. Create a virtual environment and install dependencies + +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt +``` + +2. Set up environment variables + +Copy the example file and fill in your API keys: + +```bash +cp .env.example .env +# Edit .env with your API keys +``` + +3. Configure your Twilio webhook + +In the Twilio console: + +- Go to your phone number's configuration +- Set the webhook for "A Call Comes In" to your server's URL + "/call" +- For local testing, you can use ngrok to expose your local server + +```bash +ngrok http 8000 +# Then use the provided URL (e.g., https://abc123.ngrok.io/call) in Twilio +``` + +## Running the Server + +Start the webhook server: + +```bash +python server.py +``` + +## Testing + +Call your Twilio phone number. The system should answer the call, put you on hold briefly, then connect you with the bot. + +## Customizing the Bot + +You can customize the bot's behavior by modifying the system prompt in `bot.py`. + +## Troubleshooting + +### Call is not being answered + +- Check that your Twilio webhook is correctly configured +- Verify your Twilio account has sufficient funds +- Check the logs of both the server and bot processes + +### Call connects but no bot is heard + +- Ensure your Daily API key is correct and has SIP capabilities +- Check that the SIP endpoint is being correctly passed to the bot +- Verify that the ElevenLabs API key and voice ID are correct + +### Bot starts but disconnects immediately + +- Check the Daily and Twilio logs for any error messages +- Ensure your server has stable internet connectivity diff --git a/examples/phone-chatbot-daily-twilio-sip/bot.py b/examples/phone-chatbot-daily-twilio-sip/bot.py new file mode 100644 index 000000000..5a7e07398 --- /dev/null +++ b/examples/phone-chatbot-daily-twilio-sip/bot.py @@ -0,0 +1,153 @@ +"""Twilio + Daily voice bot implementation.""" + +import argparse +import asyncio +import os +import sys + +from dotenv import load_dotenv +from loguru import logger +from twilio.rest import Client + +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext +from pipecat.services.cartesia.tts import CartesiaTTSService +from pipecat.services.openai.llm import OpenAILLMService +from pipecat.transports.services.daily import DailyParams, DailyTransport + +# Setup logging +load_dotenv() +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + +# Initialize Twilio client +twilio_client = Client(os.getenv("TWILIO_ACCOUNT_SID"), os.getenv("TWILIO_AUTH_TOKEN")) + + +async def run_bot(room_url: str, token: str, call_id: str, sip_uri: str) -> None: + """Run the voice bot with the given parameters. + + Args: + room_url: The Daily room URL + token: The Daily room token + call_id: The Twilio call ID + sip_uri: The Daily SIP URI for forwarding the call + """ + logger.info(f"Starting bot with room: {room_url}") + logger.info(f"SIP endpoint: {sip_uri}") + + # Setup the Daily transport + transport = DailyTransport( + room_url, + token, + "Phone Bot", + DailyParams( + audio_in_enabled=True, + audio_out_enabled=True, + transcription_enabled=True, + vad_analyzer=SileroVADAnalyzer(), + ), + ) + + # Setup TTS service + tts = CartesiaTTSService( + api_key=os.getenv("CARTESIA_API_KEY"), + voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady + ) + + # Setup LLM service + llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY")) + + # Initialize LLM context with system prompt + messages = [ + { + "role": "system", + "content": ( + "You are a friendly phone assistant. Your responses will be read aloud, " + "so keep them concise and conversational. Avoid special characters or " + "formatting. Begin by greeting the caller and asking how you can help them today." + ), + }, + ] + + # Setup the conversational context + context = OpenAILLMContext(messages) + context_aggregator = llm.create_context_aggregator(context) + + # Build the pipeline + pipeline = Pipeline( + [ + transport.input(), + context_aggregator.user(), + llm, + tts, + transport.output(), + context_aggregator.assistant(), + ] + ) + + # Create the pipeline task + task = PipelineTask( + pipeline, + params=PipelineParams( + allow_interruptions=True # Enable barge-in so callers can interrupt the bot + ), + ) + + # Handle participant joining + @transport.event_handler("on_first_participant_joined") + async def on_first_participant_joined(transport, participant): + logger.info(f"First participant joined: {participant['id']}") + await transport.capture_participant_transcription(participant["id"]) + await task.queue_frames([context_aggregator.user().get_context_frame()]) + + # Handle participant leaving + @transport.event_handler("on_participant_left") + async def on_participant_left(transport, participant, reason): + logger.info(f"Participant left: {participant['id']}, reason: {reason}") + await task.cancel() + + # Handle call ready to forward + @transport.event_handler("on_dialin_ready") + async def on_dialin_ready(transport, cdata): + logger.info(f"Forwarding call {call_id} to {sip_uri}") + + try: + # Update the Twilio call with TwiML to forward to the Daily SIP endpoint + twilio_client.calls(call_id).update( + twiml=f"{sip_uri}" + ) + logger.info("Call forwarded successfully") + except Exception as e: + logger.error(f"Failed to forward call: {str(e)}") + raise + + # Run the pipeline + runner = PipelineRunner() + await runner.run(task) + + +async def main(): + """Parse command line arguments and run the bot.""" + parser = argparse.ArgumentParser(description="Daily + Twilio Voice Bot") + parser.add_argument("-u", type=str, required=True, help="Daily room URL") + parser.add_argument("-t", type=str, required=True, help="Daily room token") + parser.add_argument("-i", type=str, required=True, help="Twilio call ID") + parser.add_argument("-s", type=str, required=True, help="Daily SIP URI") + + args = parser.parse_args() + + # Validate required arguments + if not all([args.u, args.t, args.i, args.s]): + logger.error("All arguments (-u, -t, -i, -s) are required") + parser.print_help() + sys.exit(1) + + await run_bot(args.u, args.t, args.i, args.s) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/phone-chatbot-daily-twilio-sip/env.example b/examples/phone-chatbot-daily-twilio-sip/env.example new file mode 100644 index 000000000..98589e916 --- /dev/null +++ b/examples/phone-chatbot-daily-twilio-sip/env.example @@ -0,0 +1,11 @@ +# Daily credentials +DAILY_API_KEY=your_daily_api_key +DAILY_API_URL=https://api.daily.co/v1 + +# Twilio credentials +TWILIO_ACCOUNT_SID=your_twilio_account_sid +TWILIO_AUTH_TOKEN=your_twilio_auth_token + +# Service keys +OPENAI_API_KEY=your_openai_api_key +CARTESIA_API_KEY=your_cartesia_api_key \ No newline at end of file diff --git a/examples/phone-chatbot-daily-twilio-sip/requirements.txt b/examples/phone-chatbot-daily-twilio-sip/requirements.txt new file mode 100644 index 000000000..623893a63 --- /dev/null +++ b/examples/phone-chatbot-daily-twilio-sip/requirements.txt @@ -0,0 +1,5 @@ +pipecat-ai[daily,elevenlabs,openai,silero] +fastapi==0.115.6 +uvicorn +python-dotenv +twilio diff --git a/examples/phone-chatbot-daily-twilio-sip/server.py b/examples/phone-chatbot-daily-twilio-sip/server.py new file mode 100644 index 000000000..68fc621a1 --- /dev/null +++ b/examples/phone-chatbot-daily-twilio-sip/server.py @@ -0,0 +1,111 @@ +"""Webhook server to handle Twilio calls and start the voice bot.""" + +import os +import shlex +import subprocess +from contextlib import asynccontextmanager + +import aiohttp +import uvicorn +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import PlainTextResponse +from twilio.twiml.voice_response import VoiceResponse +from utils.daily_helpers import create_sip_room + +# Load environment variables +load_dotenv() + + +# Initialize FastAPI app with aiohttp session +@asynccontextmanager +async def lifespan(app: FastAPI): + # Create aiohttp session to be used for Daily API calls + app.session = aiohttp.ClientSession() + yield + # Close session when shutting down + await app.session.close() + + +app = FastAPI(lifespan=lifespan) + + +@app.post("/call", response_class=PlainTextResponse) +async def handle_call(request: Request): + """Handle incoming Twilio call webhook.""" + print("Received call webhook from Twilio") + + try: + # Get form data from Twilio webhook + form_data = await request.form() + data = dict(form_data) + + # Extract call ID (required to forward the call later) + call_sid = data.get("CallSid") + if not call_sid: + raise HTTPException(status_code=400, detail="Missing CallSid in request") + + print(f"Processing call with ID: {call_sid}") + + # Create a Daily room with SIP capabilities + try: + room_details = await create_sip_room(request.app.session) + except Exception as e: + print(f"Error creating Daily room: {e}") + raise HTTPException(status_code=500, detail=f"Failed to create Daily room: {str(e)}") + + # Extract necessary details + room_url = room_details["room_url"] + token = room_details["token"] + sip_endpoint = room_details["sip_endpoint"] + + # Make sure we have a SIP endpoint + if not sip_endpoint: + raise HTTPException(status_code=500, detail="No SIP endpoint provided by Daily") + + # Start the bot process + bot_cmd = f"python bot.py -u {room_url} -t {token} -i {call_sid} -s {sip_endpoint}" + try: + # Use shlex to properly split the command for subprocess + cmd_parts = shlex.split(bot_cmd) + + # CHANGE: Keep stdout/stderr for debugging + # Start the bot in the background but capture output + subprocess.Popen( + cmd_parts, + # Don't redirect output so we can see logs + # stdout=subprocess.DEVNULL, + # stderr=subprocess.DEVNULL + ) + print(f"Started bot process with command: {bot_cmd}") + except Exception as e: + print(f"Error starting bot: {e}") + raise HTTPException(status_code=500, detail=f"Failed to start bot: {str(e)}") + + # Generate TwiML response to put the caller on hold with music + resp = VoiceResponse() + resp.play( + url="http://com.twilio.sounds.music.s3.amazonaws.com/MARKOVICHAMP-Borghestral.mp3", + loop=10, + ) + + return str(resp) + + except HTTPException: + raise + except Exception as e: + print(f"Unexpected error: {str(e)}") + raise HTTPException(status_code=500, detail=f"Server error: {str(e)}") + + +@app.get("/health") +async def health_check(): + """Simple health check endpoint.""" + return {"status": "healthy"} + + +if __name__ == "__main__": + # Run the server + port = int(os.getenv("PORT", "8000")) + print(f"Starting server on port {port}") + uvicorn.run("server:app", host="0.0.0.0", port=port, reload=True) diff --git a/examples/phone-chatbot-daily-twilio-sip/utils/daily_helpers.py b/examples/phone-chatbot-daily-twilio-sip/utils/daily_helpers.py new file mode 100644 index 000000000..46412aedf --- /dev/null +++ b/examples/phone-chatbot-daily-twilio-sip/utils/daily_helpers.py @@ -0,0 +1,66 @@ +"""Helper functions for interacting with the Daily API.""" + +import os +from typing import Any, Dict, Optional + +import aiohttp +from dotenv import load_dotenv + +from pipecat.transports.services.helpers.daily_rest import ( + DailyRESTHelper, + DailyRoomParams, + DailyRoomProperties, + DailyRoomSipParams, +) + +load_dotenv() + + +# Initialize Daily API helper +async def get_daily_helper(session: Optional[aiohttp.ClientSession] = None) -> DailyRESTHelper: + """Get a Daily REST helper with the configured API key.""" + if session is None: + session = aiohttp.ClientSession() + + return DailyRESTHelper( + daily_api_key=os.getenv("DAILY_API_KEY", ""), + daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"), + aiohttp_session=session, + ) + + +async def create_sip_room(session: Optional[aiohttp.ClientSession] = None) -> Dict[str, str]: + """Create a Daily room with SIP capabilities for phone calls.""" + daily_helper = await get_daily_helper(session) + + # Configure SIP parameters + sip_params = DailyRoomSipParams( + display_name="phone-user", + video=False, + sip_mode="dial-in", + num_endpoints=1, + ) + + # Create room properties with SIP enabled + properties = DailyRoomProperties( + sip=sip_params, + enable_dialout=True, # Needed for outbound calls if you expand the bot + enable_chat=False, # No need for chat in a voice bot + start_video_off=True, # Voice only + ) + + # Create room parameters + params = DailyRoomParams(properties=properties) + + # Create the room + try: + room = await daily_helper.create_room(params=params) + print(f"Created room: {room.url} with SIP endpoint: {room.config.sip_endpoint}") + + # Get token for the bot to join + token = await daily_helper.get_token(room.url, 24 * 60 * 60) # 24 hours validity + + return {"room_url": room.url, "token": token, "sip_endpoint": room.config.sip_endpoint} + except Exception as e: + print(f"Error creating room: {e}") + raise