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,107 @@
<!-- @format -->
# Daily PSTN dial-in simple chatbot
This project demonstrates how to create a voice bot that can receive phone calls via Dailys PSTN capabilities to enable voice conversations.
## How It Works
1. Daily receives an incoming call to your phone number.
2. Daily calls your webhook server (`/start` endpoint).
3. The server creates a Daily room with dial-in 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. Daily forwards the call to the Daily room
8. The caller and the bot are connected, and the bot handles the conversation
## Prerequisites
- A Daily account with an API key
- An OpenAI API key for the bot's intelligence
- A 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. Buy a phone number
Instructions on how to do that can be found at this [docs link:](https://docs.daily.co/reference/rest-api/phone-numbers/buy-phone-number)
4. Set up the dial-in config
Instructions on how to do that can be found at this [docs link:](https://docs.daily.co/reference/rest-api/domainDialinConfig)
5. For local testing, use ngrok to expose your local server
```bash
ngrok http 7860
# Then use the provided URL (e.g., https://abc123.ngrok.io/start) in Twilio
```
## Running the Server
Start the webhook server:
```bash
python server.py
```
## Testing
Call the purchased 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`.
## Multiple SIP Endpoints
For PSTN calls, you only need one SIP endpoint.
## Daily SIP Configuration
The bot configures Daily rooms with SIP capabilities using these settings:
```python
sip_params = DailyRoomSipParams(
display_name="phone-user", # This will show up in the Daily UI; optional display the dialer's number
video=False, # Audio-only call
sip_mode="dial-in", # For receiving calls (vs. dial-out)
num_endpoints=1, # Number of SIP endpoints to create
)
```
## Troubleshooting
### Call is not being answered
- Check that your dial-in config is correctly configured to point towards your ngrok server and correct endpoint
- Make sure the server.py file is running
- Make sure ngrok is correctly setup and pointing to the correct port
### 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,218 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""simple_dialin.py.
Daily PSTN Dial-in Bot.
"""
import argparse
import asyncio
import json
import os
import sys
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 DailyDialinSettings, DailyParams, DailyTransport
# Setup logging
load_dotenv()
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 all([body_data.get("callId"), body_data.get("callDomain")]):
logger.error("Call ID and Call Domain are required in the body.")
return None
call_id = body_data.get("callId")
call_domain = body_data.get("callDomain")
logger.debug(f"Call ID: {call_id}")
logger.debug(f"Call Domain: {call_domain}")
if not call_id or not call_domain:
logger.error("Call ID and Call Domain are required for dial-in.")
sys.exit(1)
daily_dialin_settings = DailyDialinSettings(call_id=call_id, call_domain=call_domain)
logger.debug(f"Dial-in settings: {daily_dialin_settings}")
transport_params = DailyParams(
api_url=daily_api_url,
api_key=daily_api_key,
dialin_settings=daily_dialin_settings,
audio_in_enabled=True,
audio_out_enabled=True,
video_out_enabled=False,
vad_analyzer=SileroVADAnalyzer(),
transcription_enabled=True,
)
logger.debug("setup transport params")
# Initialize transport with Daily
transport = DailyTransport(
room_url,
token,
"Simple Dial-in Bot",
transport_params,
)
logger.debug("setup transport")
# Initialize TTS
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY", ""),
voice_id="b7d50908-b17c-442d-ad8d-810c63997ed9", # Use Helpful Woman voice by default
)
logger.debug("setup tts")
# ------------ LLM AND CONTEXT SETUP ------------
# Set up the system instruction for the LLM
# Initialize LLM
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
logger.debug("setup llm")
# 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)
logger.debug("setup context")
context_aggregator = llm.create_context_aggregator(context)
logger.debug("setup context aggregator")
# ------------ PIPELINE SETUP ------------
# Build the 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
]
)
logger.debug("setup pipeline")
# Create pipeline task
task = PipelineTask(
pipeline,
params=PipelineParams(
allow_interruptions=True # Enable barge-in so callers can interrupt the bot
),
)
logger.debug("setup task")
# ------------ EVENT HANDLERS ------------
@transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(transport, participant):
logger.debug(f"First participant joined: {participant['id']}")
await transport.capture_participant_transcription(participant["id"])
await task.queue_frames([context_aggregator.user().get_context_frame()])
@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()
@transport.event_handler("on_dialin_ready")
async def on_dialin_ready(transport, cdata):
logger.debug(f"Dial-in ready: {cdata}")
@transport.event_handler("on_dialin_connected")
async def on_dialin_connected(transport, data):
logger.debug(f"Dial-in connected: {data}")
@transport.event_handler("on_dialin_stopped")
async def on_dialin_stopped(transport, data):
logger.debug(f"Dial-in stopped: {data}")
@transport.event_handler("on_dialin_error")
async def on_dialin_error(transport, data):
logger.error(f"Dial-in error: {data}")
# If there is an error, the bot should leave the call
# This may be also handled in on_participant_left with
# await task.cancel()
@transport.event_handler("on_dialin_warning")
async def on_dialin_warning(transport, data):
logger.warning(f"Dial-in warning: {data}")
# Run the pipeline
runner = PipelineRunner()
await runner.run(task)
async def main():
"""Parse command line arguments and run the bot."""
parser = argparse.ArgumentParser(description="Simple Dial-in Bot")
parser.add_argument("-u", "--url", type=str, help="Daily room URL")
parser.add_argument("-t", "--token", type=str, help="Daily 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,openai,silero]
fastapi==0.115.6
uvicorn
python-dotenv
python-multipart
aiohttp

View File

@@ -0,0 +1,116 @@
#
# 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)
@app.post("/start")
async def handle_incoming_daily_webhook(request: Request) -> JSONResponse:
"""Handle incoming Daily call webhook."""
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 all(key in data for key in ["From", "To", "callId", "callDomain"]):
raise HTTPException(
status_code=400, detail="Missing properties 'From', 'To', 'callId', 'callDomain'"
)
# Extract the caller's phone number
caller_phone = str(data.get("From"))
print(f"Processing call from {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