Add simplified pstn examples (#1822)

* Add simplified pstn examples
* Add daily_twilio_sip_dial_out example
This commit is contained in:
Dominic Stewart
2025-06-02 14:50:21 +09:00
committed by GitHub
parent 6d24514ace
commit 70951b1198
52 changed files with 3348 additions and 2324 deletions

View File

@@ -0,0 +1,152 @@
<!-- @format -->
# Daily + Twilio SIP dial-out Voice Bot
This project demonstrates how to create a voice bot that can make phone calls via Twilio and use Daily's SIP capabilities to enable voice conversations.
## How it works
1. The server file receives a curl request with the SIP uri to dial out to
2. The server creates a Daily room with SIP capabilities
3. The server starts the bot process with the room details
4. When the bot has joined, it starts the dial-out process and dials out to the SIP uri provided in the curl request
5. Twilio receives the request, and the provided TWIML processes the SIP uri
6. Twilio then rings the number found within the SIP uri
7. When the user answers the phone, the user is brought into the call
8. The end user and the 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 and a correctly configured SIP domain
- OpenAI API key for the bot's intelligence
- Cartesia API key for text-to-speech
## 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. Create a TwiML Bin
Visit this link to create your [TwiML Bin](https://www.twilio.com/docs/serverless/twiml-bins)
- Login to the account that has your purchased Twilio phone number
- Press the plus button on the TwiML Bin dashboard to write a new TwiML that Twilio will host for you
- Give it a friendly name. For example "daily sip uri twiml bin"
- For the TWIML code, use something like:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Dial callerId="+1234567890">{{#e164}}{{To}}{{/e164}}</Dial>
</Response>
```
- callerId must be a valid number that you own on [Twilio](https://console.twilio.com/us1/develop/phone-numbers/manage/incoming)
- Save the file. We will use this when creating the SIP domain
4. Create and configure a programmable SIP domain
- Visit this link to [create a new SIP domain:](https://console.twilio.com/us1/develop/voice/manage/sip-domains?frameUrl=%2Fconsole%2Fvoice%2Fsip%2Fendpoints%3Fx-target-region%3Dus1)
- Press the plus button to create a new SIP domain
- Give the SIP domain a friendly name. For example "Daily SIP domain"
- Specify a SIP URI, for example "daily.sip.twilio.com"
- Under "Voice Authentication", press the plus button next to IP Access Control Lists. We are going to white list the entire IP spectrum
- Give it a friendly name such as "first half"
- For CIDR Network Address specify 0.0.0.0 and for the subnet specify 1
- Again, specify "first half" for the friendly name and click "Create ACL"
- Now let's do the same again and add another IP Access Control List by pressing the plus button
- Give it a friendly name such as "second half".
- For the CIDR Network Address specify 128.0.0.0 and for the subnet specify 1
- Lastly, specify the friendly name "second half" again
- Make sure both IP Access control list appears selected in the dropdown
- Under "Call Control Configuration", specify the following:
- Configure with: Webhooks, TwiML Bins, Functions, Studio, Proxy
- A call comes in: TwiML Bin > Select the name of the TwiML bin you made earlier
- Leave everything else blank and scroll to the bottom of the page. Click save
## Running the Server
Start the webhook server:
```bash
python server.py
```
## Testing
With server.py running, send the following curl command from your terminal:
```bash
curl -X POST "http://127.0.0.1:7860/start" \
-H "Content-Type: application/json" \
-d '{
"dialout_settings": {
"sip_uri": "sip:+1234567891@daily.sip.twilio.com"
}
}'
```
- Replace the phone number (Starting with +1) with the phone number you want to ring
- Replace daily with the SIP domain you configured previously
The server should make a room. The bot will join the room and then dial out to the SIP URI provided. Answer the call to speak with the bot.
## Customizing the Bot
You can customize the bot's behavior by modifying the system prompt in `bot.py`.
## Handling Multiple SIP Endpoints
Note that normally calls only require a single SIP endpoint. If you are planning to forward the call to a different number, you will need to set up 2 SIP endpoints: one for the initial call and one for the forwarded call.
## Daily dial-out configuration
The bot configures the Daily rooms with dial-out capabilities using these settings. Note: You also need dial-out to be enabled on the domain, as mentioned earlier on in the README.
```python
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
)
```
## Troubleshooting
### I get an error about dial-out not being enabled
- Check that your room has `enable_dialout=True` set
- Check that your meeting token is an owner token (The bot does this for you automatically)
- Check that the SIP URI is correct
- Check that the phone number you are trying to ring is correct
### I'm stuck setting up my Twilio account
- You can reference this [Notion doc](https://dailyco.notion.site/PUBLIC-Doc-Integration-Twilio-PSTN-Daily-s-SIP-Dialout-1cfdaed630f5458d9d4fc0e3f29ec559) to find more information on how to set up Twilio, as well as use webhooks instead of TwiML Bins
### Call connects but no bot is heard
- Ensure your Daily API key is correct and has SIP capabilities
- Verify that the Cartesia API key and voice ID are correct
### Bot starts but disconnects immediately
- Check the Daily logs for any error messages
- Ensure your server has stable internet connectivity

View File

@@ -0,0 +1,228 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""simple_dialout.py.
Simple Dial-out Bot.
"""
import argparse
import asyncio
import json
import os
import sys
from typing import Any
from dotenv import load_dotenv
from loguru import logger
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
load_dotenv(override=True)
logger.remove(0)
logger.add(sys.stderr, level="DEBUG")
daily_api_key = os.getenv("DAILY_API_KEY", "")
daily_api_url = os.getenv("DAILY_API_URL", "https://api.daily.co/v1")
async def run_bot(
room_url: str,
token: str,
body: dict,
) -> None:
"""Run the voice bot with the given parameters.
Args:
room_url: The Daily room URL
token: The Daily room token
body: Body passed to the bot from the webhook
"""
# ------------ CONFIGURATION AND SETUP ------------
logger.info(f"Starting bot with room: {room_url}")
logger.info(f"Token: {token}")
logger.info(f"Body: {body}")
# Parse the body to get the dial-in settings
body_data = json.loads(body)
# Check if the body contains dial-in settings
logger.debug(f"Body data: {body_data}")
if not body_data.get("dialout_settings"):
logger.error("Dial-out settings not found in the body data")
return
dialout_settings = body_data["dialout_settings"]
if not dialout_settings.get("sip_uri"):
logger.error("Dial-out sip_uri not found in the dial-out settings")
return
# Extract sip_uri
sip_uri = dialout_settings["sip_uri"]
# ------------ TRANSPORT SETUP ------------
transport_params = DailyParams(
api_url=daily_api_url,
api_key=daily_api_key,
audio_in_enabled=True,
audio_out_enabled=True,
video_out_enabled=False,
vad_analyzer=SileroVADAnalyzer(),
transcription_enabled=True,
)
# Initialize transport with Daily
transport = DailyTransport(
room_url,
token,
"Phone Bot",
transport_params,
)
# Initialize TTS
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY", ""),
voice_id="b7d50908-b17c-442d-ad8d-810c63997ed9", # Use Helpful Woman voice by default
)
# ------------ LLM AND CONTEXT SETUP ------------
# Initialize LLM
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
# Create system message and initialize messages list
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."
),
},
]
# Initialize LLM context and aggregator
context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context)
# ------------ PIPELINE SETUP ------------
# Build pipeline
pipeline = Pipeline(
[
transport.input(), # Transport user input
context_aggregator.user(), # User responses
llm, # LLM
tts, # TTS
transport.output(), # Transport bot output
context_aggregator.assistant(), # Assistant spoken responses
]
)
# Create pipeline task
task = PipelineTask(pipeline, params=PipelineParams(allow_interruptions=True))
# ------------ RETRY LOGIC VARIABLES ------------
max_retries = 5
retry_count = 0
dialout_successful = False
# Build dialout parameters conditionally
dialout_params = {"sipUri": sip_uri}
logger.debug(f"Dialout parameters: {dialout_params}")
async def attempt_dialout():
"""Attempt to start dialout with retry logic."""
nonlocal retry_count, dialout_successful
if retry_count < max_retries and not dialout_successful:
retry_count += 1
logger.info(f"Attempting dialout (attempt {retry_count}/{max_retries}) to: {sip_uri}")
await transport.start_dialout(dialout_params)
else:
logger.error(f"Maximum retry attempts ({max_retries}) reached. Giving up on dialout.")
# ------------ EVENT HANDLERS ------------
@transport.event_handler("on_joined")
async def on_joined(transport, data):
# Start initial dialout attempt
logger.debug(f"Dialout settings detected; starting dialout to number: {sip_uri}")
await attempt_dialout()
@transport.event_handler("on_dialout_connected")
async def on_dialout_connected(transport, data):
logger.debug(f"Dial-out connected: {data}")
@transport.event_handler("on_dialout_answered")
async def on_dialout_answered(transport, data):
nonlocal dialout_successful
logger.debug(f"Dial-out answered: {data}")
dialout_successful = True # Mark as successful to stop retries
# Automatically start capturing transcription for the participant
await transport.capture_participant_transcription(data["sessionId"])
# The bot will wait to hear the user before the bot speaks
@transport.event_handler("on_dialout_error")
async def on_dialout_error(transport, data: Any):
logger.error(f"Dial-out error (attempt {retry_count}/{max_retries}): {data}")
if retry_count < max_retries:
logger.info(f"Retrying dialout")
await attempt_dialout()
else:
logger.error(f"All {max_retries} dialout attempts failed. Stopping bot.")
await task.cancel()
@transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(transport, participant):
logger.debug(f"First participant joined: {participant['id']}")
@transport.event_handler("on_participant_left")
async def on_participant_left(transport, participant, reason):
logger.debug(f"Participant left: {participant}, reason: {reason}")
await task.cancel()
# ------------ RUN PIPELINE ------------
runner = PipelineRunner()
await runner.run(task)
async def main():
"""Parse command line arguments and run the bot."""
parser = argparse.ArgumentParser(description="Simple Dial-out Bot")
parser.add_argument("-u", "--url", type=str, help="Room URL")
parser.add_argument("-t", "--token", type=str, help="Room Token")
parser.add_argument("-b", "--body", type=str, help="JSON configuration string")
args = parser.parse_args()
logger.debug(f"url: {args.url}")
logger.debug(f"token: {args.token}")
logger.debug(f"body: {args.body}")
if not all([args.url, args.token, args.body]):
logger.error("All arguments (-u, -t, -b) are required")
parser.print_help()
sys.exit(1)
await run_bot(args.url, args.token, args.body)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,7 @@
# Daily credentials
DAILY_API_KEY=your_daily_api_key
DAILY_API_URL=https://api.daily.co/v1
# Service keys
OPENAI_API_KEY=your_openai_api_key
CARTESIA_API_KEY=your_cartesia_api_key

View File

@@ -0,0 +1,6 @@
pipecat-ai[daily,cartesia,elevenlabs,deepgram,openai,silero]
fastapi==0.115.6
uvicorn
python-dotenv
python-multipart
aiohttp

View File

@@ -0,0 +1,140 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""server.py.
Webhook server to handle webhook coming from Daily, create a Daily room and start the bot.
"""
import json
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 JSONResponse
from utils.daily_helpers import create_daily_room
load_dotenv()
# ----------------- API ----------------- #
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create aiohttp session to be used for Daily API calls
app.state.session = aiohttp.ClientSession()
yield
# Close session when shutting down
await app.state.session.close()
app = FastAPI(lifespan=lifespan)
def extract_phone_from_sip_uri(sip_uri):
"""Extract phone number from SIP URI.
Args:
sip_uri: SIP URI in format "sip:+17868748498@daily-twilio-integration.sip.twilio.com"
Returns:
Phone number string (e.g., "+17868748498") or None if invalid format
"""
if not sip_uri or not isinstance(sip_uri, str):
return None
if sip_uri.startswith("sip:") and "@" in sip_uri:
phone_part = sip_uri[4:] # Remove 'sip:' prefix
caller_phone = phone_part.split("@")[0] # Get everything before '@'
return caller_phone
return None
@app.post("/start")
async def handle_incoming_daily_webhook(request: Request) -> JSONResponse:
"""Handle dial-out request."""
print("Received webhook from Daily")
# Get the dial-in properties from the request
try:
data = await request.json()
if "test" in data:
# Pass through any webhook checks
return JSONResponse({"test": True})
if not data["dialout_settings"]:
raise HTTPException(
status_code=400, detail="Missing 'dialout_settings' in the request body"
)
if not data["dialout_settings"].get("sip_uri"):
raise HTTPException(status_code=400, detail="Missing 'sip_uri' in dialout_settings")
# Extract the phone number we want to dial out to
sip_uri = str(data["dialout_settings"]["sip_uri"])
caller_phone = extract_phone_from_sip_uri(sip_uri)
print(f"SIP URI: {sip_uri}")
print(f"Processing sip call to {caller_phone}")
# Create a Daily room with dial-in capabilities
try:
room_details = await create_daily_room(request.app.state.session, caller_phone)
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)}")
room_url = room_details["room_url"]
token = room_details["token"]
print(f"Created Daily room: {room_url} with token: {token}")
body_json = json.dumps(data)
bot_cmd = f"python3 -m bot -u {room_url} -t {token} -b {shlex.quote(body_json)}"
try:
# CHANGE: Keep stdout/stderr for debugging
# Start the bot in the background but capture output
subprocess.Popen(
bot_cmd,
shell=True,
# 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)}")
except HTTPException:
raise
except Exception as e:
print(f"Unexpected error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Server error: {str(e)}")
# Grab a token for the user to join with
return JSONResponse({"room_url": room_url, "token": token})
@app.get("/health")
async def health_check():
"""Simple health check endpoint."""
return {"status": "healthy"}
# ----------------- Main ----------------- #
if __name__ == "__main__":
# Run the server
port = int(os.getenv("PORT", "7860"))
print(f"Starting server on port {port}")
uvicorn.run("server:app", host="0.0.0.0", port=port, reload=True)

View File

@@ -0,0 +1,76 @@
"""Helper functions for interacting with the Daily API."""
import os
from typing import 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_daily_room(
session: Optional[aiohttp.ClientSession] = None, caller_phone: str = "unknown-caller"
) -> Dict[str, str]:
"""Create a Daily room with SIP capabilities for phone calls.
Args:
session: Optional aiohttp session to use for API calls
caller_phone: The phone number of the caller to use in display name
Returns:
Dictionary with room URL, token, and SIP endpoint
"""
daily_helper = await get_daily_helper(session)
# Configure SIP parameters
sip_params = DailyRoomSipParams(
display_name=caller_phone,
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