Merge branch 'main' into p2p_ios_demo
This commit is contained in:
17
CHANGELOG.md
17
CHANGELOG.md
@@ -9,24 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added new processors `ProducerProcessor` and `ConsumerProcessor`. The producer
|
- Added new processors `ProducerProcessor` and `ConsumerProcessor`. The
|
||||||
processor processes frames from the pipeline and decides whether the consumers
|
producer processor processes frames from the pipeline and decides whether the
|
||||||
should consume it or not. If so, the same frame that is received by the
|
consumers should consume it or not. If so, the same frame that is received by
|
||||||
producer is sent to the consumer. There can be multiple consumers per
|
the producer is sent to the consumer. There can be multiple consumers per
|
||||||
producer. These processors can be useful to push frames from one part of a
|
producer. These processors can be useful to push frames from one part of a
|
||||||
pipeline to a different one (e.g. when using `ParallelPipeline`).
|
pipeline to a different one (e.g. when using `ParallelPipeline`).
|
||||||
|
|
||||||
- Improvements for the `SmallWebRTCTransport`:
|
- Improvements for the `SmallWebRTCTransport`:
|
||||||
- Wait until the pipeline is ready before triggering the `connected` event.
|
- Wait until the pipeline is ready before triggering the `connected` event.
|
||||||
- Queue messages if the data channel is not ready.
|
- Queue messages if the data channel is not ready.
|
||||||
- Update the aiortc dependency to fix an issue where the 'video/rtx' MIME type
|
- Update the aiortc dependency to fix an issue where the 'video/rtx' MIME
|
||||||
was incorrectly handled as a codec retransmission.
|
type was incorrectly handled as a codec retransmission.
|
||||||
- Avoid initial video delays.
|
- Avoid initial video delays.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an issue in the Azure TTS services where the language was being set
|
||||||
|
incorrectly.
|
||||||
|
|
||||||
- Fixed `SmallWebRTCTransport` to support dynamic values for
|
- Fixed `SmallWebRTCTransport` to support dynamic values for
|
||||||
`TransportParams.audio_out_10ms_chunks`. Previously, it only worked with 20ms
|
`TransportParams.audio_out_10ms_chunks`. Previously, it only worked with 20ms
|
||||||
chunks.
|
chunks.
|
||||||
|
|
||||||
- Fixed an issue where `LLMAssistantContextAggregator` would prevent a
|
- Fixed an issue where `LLMAssistantContextAggregator` would prevent a
|
||||||
|
|||||||
178
examples/deployment/pipecat-cloud-daily-pstn-server/README.md
Normal file
178
examples/deployment/pipecat-cloud-daily-pstn-server/README.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Handling PSTN/SIP Dial-in on Pipecat Cloud
|
||||||
|
|
||||||
|
This repository contains two server implementations for handling
|
||||||
|
the pinless dial-in workflow in Pipecat Cloud. This is the companion to the
|
||||||
|
Pipecat Cloud [pstn_sip starter image](https://github.com/daily-co/pipecat-cloud-images/tree/main/pipecat-starters/pstn_sip).
|
||||||
|
In addition you can use `/api/dial` to trigger dial-out, and
|
||||||
|
eventually, call-transfers.
|
||||||
|
|
||||||
|
1. [FastAPI Server](fastapi-webhook-server/README.md) -
|
||||||
|
A FastAPI implementation that handles PSTN (Public Switched Telephone
|
||||||
|
Network) and SIP (Session Initiation Protocol) calls using the Daily API.
|
||||||
|
|
||||||
|
2. [Next.js Serverless](nextjs-webhook-server/README.md) -
|
||||||
|
A Next.js API implementation designed for deployment on Vercel's
|
||||||
|
serverless platform.
|
||||||
|
|
||||||
|
Both implementations provide:
|
||||||
|
|
||||||
|
- HMAC signature validation for pinless webhook
|
||||||
|
- Structured logging
|
||||||
|
- Support for dial-in and dial-out settings
|
||||||
|
- Voicemail detection and call transfer functionality (coming soon)
|
||||||
|
- Test request handling
|
||||||
|
|
||||||
|
## Choosing an Implementation
|
||||||
|
|
||||||
|
- Use the **FastAPI Server** if you:
|
||||||
|
|
||||||
|
- Need a standalone server
|
||||||
|
- Prefer Python and FastAPI
|
||||||
|
- Want to deploy to traditional hosting platforms
|
||||||
|
|
||||||
|
- Use the **Next.js Serverless** implementation if you:
|
||||||
|
- Want serverless deployment
|
||||||
|
- Prefer JavaScript/TypeScript
|
||||||
|
- Already use Next.js and Vercel for other projects
|
||||||
|
- Need quick scaling and zero maintenance
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Both implementations require similar environment variables:
|
||||||
|
|
||||||
|
- `PIPECAT_CLOUD_API_KEY`: Pipecat Cloud API Key, begins with pk\_\*
|
||||||
|
- `AGENT_NAME`: Your Daily agent name
|
||||||
|
- `PINLESS_HMAC_SECRET`: Your HMAC secret for request verification
|
||||||
|
- `LOG_LEVEL`: (Optional) Logging level (defaults to 'info')
|
||||||
|
|
||||||
|
See the individual README files in each implementation directory for
|
||||||
|
specific setup instructions.
|
||||||
|
|
||||||
|
### Phone number setup
|
||||||
|
|
||||||
|
You can buy a phone number through the Pipecat Cloud Dashboard:
|
||||||
|
|
||||||
|
1. Go to `Settings` > `Telephony`
|
||||||
|
2. Follow the UI to purchase a phone number
|
||||||
|
3. Configure the webhook URL to receive incoming calls (e.g. `https://my-webhook-url.com/api/dial`)
|
||||||
|
|
||||||
|
Or purchase the number using Daily's
|
||||||
|
[PhoneNumbers API](https://docs.daily.co/reference/rest-api/phone-numbers).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --request POST \
|
||||||
|
--url https://api.daily.co/v1/domain-dialin-config \
|
||||||
|
--header 'Authorization: Bearer $TOKEN' \
|
||||||
|
--header 'Content-Type: application/json' \
|
||||||
|
--data-raw '{
|
||||||
|
"type": "pinless_dialin",
|
||||||
|
"name_prefix": "Customer1",
|
||||||
|
"phone_number": "+1PURCHASED_NUM",
|
||||||
|
"room_creation_api": "https://example.com/api/dial",
|
||||||
|
"hold_music_url": "https://example.com/static/ringtone.mp3",
|
||||||
|
"timeout_config": {
|
||||||
|
"message": "No agent is available right now"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will return a static SIP URI (`sip_uri`) that can be called
|
||||||
|
from other SIP services.
|
||||||
|
|
||||||
|
### `room_creation_api`
|
||||||
|
|
||||||
|
To make and receive calls currently you have to host a server that
|
||||||
|
handles incoming calls. In the coming weeks, incoming calls will be
|
||||||
|
directly handled within Daily and we will expose an endpoint similar
|
||||||
|
to `{service}/start` that will manage this for you.
|
||||||
|
|
||||||
|
In the meantime, the server described below serves as the webhook
|
||||||
|
handler for the `room_creation_api`. Configure your pinless phone
|
||||||
|
number or SIP interconnect to the `ngrok` tunnel or
|
||||||
|
the actual server URL, append `/api/dial` to the webhook URL.
|
||||||
|
|
||||||
|
## Example curl commands
|
||||||
|
|
||||||
|
Note: Replace `http://localhost:3000` with your actual server URL and
|
||||||
|
phone numbers with valid values for your use case.
|
||||||
|
|
||||||
|
### Dialin Request
|
||||||
|
|
||||||
|
The server will receive a request when a call is received from Daily.
|
||||||
|
|
||||||
|
### Dialout Request
|
||||||
|
|
||||||
|
Dial a number, will use any purchased number
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/dial \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"dialout_settings": [
|
||||||
|
{
|
||||||
|
"phoneNumber": "+1234567890",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Dial a number with callerId, which is the UUID of a purchased number.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/dial \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"dialout_settings": [
|
||||||
|
{
|
||||||
|
"phoneNumber": "+1234567890",
|
||||||
|
"callerId": "purchased_phone_uuid"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Dial a number
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/dial \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"dialout_settings": [
|
||||||
|
{
|
||||||
|
"phoneNumber": "+1234567890",
|
||||||
|
"callerId": "purchased_phone_uuid"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Request with Voicemail Detection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/dial \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"To": "+1234567890",
|
||||||
|
"From": "+1987654321",
|
||||||
|
"callId": "call-uuid-123",
|
||||||
|
"callDomain": "domain-uuid-456",
|
||||||
|
"dialout_settings": [
|
||||||
|
{
|
||||||
|
"phoneNumber": "+1234567890",
|
||||||
|
"callerId": "purchased_phone_uuid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"voicemail_detection": {
|
||||||
|
"testInPrebuilt": true
|
||||||
|
},
|
||||||
|
"call_transfer": {
|
||||||
|
"mode": "dialout",
|
||||||
|
"speakSummary": true,
|
||||||
|
"storeSummary": true,
|
||||||
|
"operatorNumber": "+1234567890",
|
||||||
|
"testInPrebuilt": true
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# FastAPI server for handling Daily PSTN/SIP Webhook
|
||||||
|
|
||||||
|
A FastAPI server that handles PSTN (Public Switched Telephone Network) and SIP (Session Initiation Protocol) calls using the Daily API.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
|
||||||
|
2. Navigate to the `fastapi-webhook-server` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd fastapi-webhook-server
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Copy `env.example` to `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Update `.env` with your credentials:
|
||||||
|
|
||||||
|
- `AGENT_NAME`: Your Daily agent name
|
||||||
|
- `PIPECAT_CLOUD_API_KEY`: Your Daily API key
|
||||||
|
- `PINLESS_HMAC_SECRET`: Your HMAC secret for request verification
|
||||||
|
|
||||||
|
## Running the Server
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python server.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will run on `http://localhost:7860` and you can expose it via ngrok for testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`ngrok http 7860`
|
||||||
|
```
|
||||||
|
|
||||||
|
> Tip: Use a subdomain for a consistent URL (e.g. `ngrok http -subdomain=mydomain http://localhost:7860`)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /
|
||||||
|
|
||||||
|
Health check endpoint that returns a "Hello, World!" message.
|
||||||
|
|
||||||
|
### POST /api/dial
|
||||||
|
|
||||||
|
Initiates a PSTN/SIP call with the following request body format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"To": "+14152251493",
|
||||||
|
"From": "+14158483432",
|
||||||
|
"callId": "string-contains-uuid",
|
||||||
|
"callDomain": "string-contains-uuid",
|
||||||
|
"dialout_settings": [
|
||||||
|
{
|
||||||
|
"phoneNumber": "+14158483432",
|
||||||
|
"callerId": "+14152251493"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"voicemail_detection": {
|
||||||
|
"testInPrebuilt": true
|
||||||
|
},
|
||||||
|
"call_transfer": {
|
||||||
|
"mode": "dialout",
|
||||||
|
"speakSummary": true,
|
||||||
|
"storeSummary": true,
|
||||||
|
"operatorNumber": "+14152250006",
|
||||||
|
"testInPrebuilt": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Response
|
||||||
|
|
||||||
|
Returns a JSON object containing:
|
||||||
|
|
||||||
|
- `status`: Success/failure status
|
||||||
|
- `data`: Response from Daily API
|
||||||
|
- `room_properties`: Properties of the created Daily room
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- 401: Invalid signature
|
||||||
|
- 400: Invalid authorization header (e.g. missing Daily API key in bot.py)
|
||||||
|
- 405: Method not allowed (e.g. incorrect route on the webhook URL)
|
||||||
|
- 500: Server errors (missing API key, network issues)
|
||||||
|
- Other status codes are passed through from the Daily API
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
AGENT_NAME="your-agent-name"
|
||||||
|
PIPECAT_CLOUD_API_KEY="your-daily-api-key"
|
||||||
|
PINLESS_HMAC_SECRET="hmac-secret-pinless-dialin"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
python-dotenv
|
||||||
|
requests
|
||||||
|
pydantic
|
||||||
|
loguru
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
# server.py
|
||||||
|
|
||||||
|
|
||||||
|
import base64 # for calculating hmac signature
|
||||||
|
import hmac
|
||||||
|
import os # for accessing environment variables
|
||||||
|
import time # for setting expiration time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
class RoomRequest(BaseModel):
|
||||||
|
test: Optional[str] = Field(None, alias="Test", description="Test field")
|
||||||
|
To: Optional[str] = Field(None, alias="to", description="Destination phone number")
|
||||||
|
From: Optional[str] = Field(None, alias="from", description="Source phone number")
|
||||||
|
callId: Optional[str] = Field(None, alias="call_id", description="Unique call identifier")
|
||||||
|
callDomain: Optional[str] = Field(
|
||||||
|
None, alias="call_domain", description="Call domain identifier"
|
||||||
|
)
|
||||||
|
dialout_settings: Optional[List[Dict[str, Any]]] = Field(
|
||||||
|
None, description="An array of phone numbers or SIP URIs to dialout to"
|
||||||
|
)
|
||||||
|
voicemail_detection: Optional[Dict[str, Any]] = Field(
|
||||||
|
None, description="A flag to perform voicemail or answeing-machine detection"
|
||||||
|
)
|
||||||
|
call_transfer: Optional[Dict[str, Any]] = Field(None, description="to initiate a call transfer")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
populate_by_name = True
|
||||||
|
alias_generator = None
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
body can contain any fields, but for handling PSTN/SIP,
|
||||||
|
we recommend sending the following custom values:
|
||||||
|
dialin, dialout, voicemail detection, and call transfer
|
||||||
|
|
||||||
|
|
||||||
|
"To": "+14152251493",
|
||||||
|
"From": "+14158483432",
|
||||||
|
"callId": "string-contains-uuid",
|
||||||
|
"callDomain": "string-contains-uuid"
|
||||||
|
These need to be remapped to dialin_settings
|
||||||
|
|
||||||
|
"dialout_settings": [
|
||||||
|
{"phoneNumber": "+14158483432", "callerId": "+14152251493"},
|
||||||
|
{"sipUri": "sip:username@sip.hostname"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
voicemail_detection:{
|
||||||
|
testInPrebuilt: true
|
||||||
|
},
|
||||||
|
|
||||||
|
"call_transfer": {
|
||||||
|
"mode": "dialout",
|
||||||
|
"speakSummary": true,
|
||||||
|
"storeSummary": true,
|
||||||
|
"operatorNumber": "+14152250006",
|
||||||
|
"testInPrebuilt": true
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def read_root():
|
||||||
|
return {"message": "Hello, World!"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/dial")
|
||||||
|
async def dial(request: RoomRequest, raw_request: Request):
|
||||||
|
logger.info("Incoming request to /dial:")
|
||||||
|
logger.info(f"Headers: {dict(raw_request.headers)}")
|
||||||
|
raw_body = await raw_request.body()
|
||||||
|
raw_body_str = raw_body.decode()
|
||||||
|
logger.info(f"Raw body: {raw_body_str}")
|
||||||
|
logger.info(f"Parsed body: {request.dict()}")
|
||||||
|
|
||||||
|
# calculate signature and compare/verify
|
||||||
|
hmac_secret = os.getenv("PINLESS_HMAC_SECRET")
|
||||||
|
timestamp = raw_request.headers.get("x-pinless-timestamp")
|
||||||
|
signature = raw_request.headers.get("x-pinless-signature")
|
||||||
|
|
||||||
|
if not hmac_secret:
|
||||||
|
logger.debug("Skipping HMAC validation - PINLESS_HMAC_SECRET not set")
|
||||||
|
elif timestamp and signature:
|
||||||
|
message = timestamp + "." + raw_body_str
|
||||||
|
|
||||||
|
base64_decoded_secret = base64.b64decode(hmac_secret)
|
||||||
|
computed_signature = base64.b64encode(
|
||||||
|
hmac.new(base64_decoded_secret, message.encode(), "sha256").digest()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
if computed_signature != signature:
|
||||||
|
logger.error(f"Invalid signature. Expected {signature}, got {computed_signature}")
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||||
|
else:
|
||||||
|
logger.debug("Skipping HMAC validation - no signature headers present")
|
||||||
|
|
||||||
|
if request.test == "test":
|
||||||
|
logger.debug("Test request received")
|
||||||
|
return {"status": "success", "message": "Test request received"}
|
||||||
|
|
||||||
|
dialin_settings = None
|
||||||
|
# these fields are camelCase in the request
|
||||||
|
required_fields = ["To", "From", "callId", "callDomain"]
|
||||||
|
if all(
|
||||||
|
field in request.dict() and request.dict()[field] is not None for field in required_fields
|
||||||
|
):
|
||||||
|
# transform from camelCase to snake_case because daily-python expects snake_case
|
||||||
|
dialin_settings = {
|
||||||
|
"From": request.From,
|
||||||
|
"To": request.To,
|
||||||
|
"call_id": request.callId,
|
||||||
|
"call_domain": request.callDomain,
|
||||||
|
# transform from camelCase to snake_case
|
||||||
|
}
|
||||||
|
logger.debug(f"Populated dialin_settings from request: {dialin_settings}")
|
||||||
|
|
||||||
|
daily_room_properties = {
|
||||||
|
"enable_dialout": request.dialout_settings is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dialin_settings is not None:
|
||||||
|
sip_config = {
|
||||||
|
"display_name": request.From,
|
||||||
|
"sip_mode": "dial-in",
|
||||||
|
"num_endpoints": 2 if request.call_transfer is not None else 1,
|
||||||
|
}
|
||||||
|
daily_room_properties["sip"] = sip_config
|
||||||
|
|
||||||
|
# Setting default expiry to 5 minutes from now
|
||||||
|
daily_room_properties["exp"] = int(time.time()) + (5 * 60)
|
||||||
|
|
||||||
|
logger.debug(f"Daily room properties: {daily_room_properties}")
|
||||||
|
payload = {
|
||||||
|
"createDailyRoom": True,
|
||||||
|
"dailyRoomProperties": daily_room_properties,
|
||||||
|
"body": {
|
||||||
|
"dialin_settings": dialin_settings,
|
||||||
|
"dialout_settings": request.dialout_settings,
|
||||||
|
"voicemail_detection": request.voicemail_detection,
|
||||||
|
"call_transfer": request.call_transfer,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pcc_api_key = os.getenv("PIPECAT_CLOUD_API_KEY")
|
||||||
|
agent_name = os.getenv("AGENT_NAME", "my-first-agent")
|
||||||
|
|
||||||
|
if not pcc_api_key:
|
||||||
|
raise HTTPException(status_code=500, detail="DAILY_API_KEY environment variable is not set")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {pcc_api_key}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
url = f"https://api.pipecat.daily.co/v1/public/{agent_name}/start"
|
||||||
|
|
||||||
|
logger.debug(f"Making API call to Daily: {url} {headers} {payload}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
response_data = response.json()
|
||||||
|
logger.debug(f"Response: {response_data}")
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": response_data,
|
||||||
|
"room_properties": daily_room_properties,
|
||||||
|
}
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
# Pass through the status code and error details from the Daily API
|
||||||
|
status_code = e.response.status_code
|
||||||
|
error_detail = e.response.json() if e.response.content else str(e)
|
||||||
|
logger.error(f"HTTP error: {error_detail}")
|
||||||
|
raise HTTPException(status_code=status_code, detail=error_detail)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"Request error: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=7860)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("Server stopped manually")
|
||||||
53
examples/deployment/pipecat-cloud-daily-pstn-server/nextjs-webhook-server/.gitignore
vendored
Normal file
53
examples/deployment/pipecat-cloud-daily-pstn-server/nextjs-webhook-server/.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Next.js server for handling Daily PSTN/SIP Webhook
|
||||||
|
|
||||||
|
Next.js API routes for handling Daily PSTN/SIP Pipecat requests.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- API endpoint for handling Daily PSTN/SIP Pipecat requests
|
||||||
|
- HMAC signature validation
|
||||||
|
- Structured logging with Pino
|
||||||
|
- Support for dial-in and dial-out settings
|
||||||
|
- Voicemail detection and call transfer functionality
|
||||||
|
- Test request handling
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Clone the repository
|
||||||
|
|
||||||
|
2. Navigate to the `nextjs-webhook-server` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd nextjs-webhook-server
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Create `.env.local` file with your credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp env.local.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Update your `.env` with your secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PIPECAT_CLOUD_API_KEY=pk_*
|
||||||
|
AGENT_NAME=my-first-agent
|
||||||
|
PINLESS_HMAC_SECRET=your_hmac_secret
|
||||||
|
LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running the server
|
||||||
|
|
||||||
|
Run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will run on `http://localhost:7860` and you can expose it via ngrok for testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
`ngrok http 7860`
|
||||||
|
```
|
||||||
|
|
||||||
|
> Tip: Use a subdomain for a consistent URL (e.g. `ngrok http -subdomain=mydomain http://localhost:7860`)
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /api
|
||||||
|
|
||||||
|
Returns a simple "Hello, World!" message with a cute cat emoji to verify the server is running.
|
||||||
|
|
||||||
|
### POST /api/dial
|
||||||
|
|
||||||
|
Handles dial-in and dial-out requests for Pipecat Cloud.
|
||||||
|
|
||||||
|
#### Test Requests
|
||||||
|
|
||||||
|
The endpoint handles test requests when a webhook is configured. Send a request with `"Test": "test"` to verify your setup:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Test": "test"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Production Request Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
// for dial-in from webhook
|
||||||
|
"To": "+14152251493",
|
||||||
|
"From": "+14158483432",
|
||||||
|
"callId": "string-contains-uuid",
|
||||||
|
"callDomain": "string-contains-uuid",
|
||||||
|
// for making a dial out to a phone or SIP
|
||||||
|
"dialout_settings": [
|
||||||
|
{ "phoneNumber": "+14158483432", "callerId": "purchased_phone_uuid" },
|
||||||
|
{ "sipUri": "sip:username@sip.hostname.com" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The application is configured for Vercel deployment:
|
||||||
|
|
||||||
|
1. Push your code to a Git repository
|
||||||
|
2. Import your project in Vercel dashboard
|
||||||
|
3. Configure environment variables:
|
||||||
|
- `PIPECAT_CLOUD_API_KEY`
|
||||||
|
- `AGENT_NAME`
|
||||||
|
- `PINLESS_HMAC_SECRET`
|
||||||
|
- `LOG_LEVEL` (optional, defaults to 'info')
|
||||||
|
4. Deploy!
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- HMAC signature validation for request authentication
|
||||||
|
- Environment variables for sensitive credentials
|
||||||
|
- Method validation (POST only for /dial)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
AGENT_NAME=my-first-agent
|
||||||
|
PIPECAT_CLOUD_API_KEY=your_daily_api_key
|
||||||
|
PINLESS_HMAC_SECRET=your_hmac_secret
|
||||||
|
LOG_LEVEL="info"
|
||||||
5447
examples/deployment/pipecat-cloud-daily-pstn-server/nextjs-webhook-server/package-lock.json
generated
Normal file
5447
examples/deployment/pipecat-cloud-daily-pstn-server/nextjs-webhook-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "my-daily-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 7860",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 7860",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"next": "^14.0.0",
|
||||||
|
"pino": "^8.15.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^8.46.0",
|
||||||
|
"eslint-config-next": "^14.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { logger } from '../../lib/utils';
|
||||||
|
import axios from 'axios';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const validateSignature = (body, signature, timestamp, secret) => {
|
||||||
|
// Skip if any required fields are missing
|
||||||
|
if (!signature || !timestamp || !secret) {
|
||||||
|
logger.warn('Missing required fields for HMAC validation');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decodedSecret = Buffer.from(secret, 'base64');
|
||||||
|
const hmac = crypto.createHmac('sha256', decodedSecret);
|
||||||
|
const signatureData = `${timestamp}.${body}`;
|
||||||
|
const computedSignature = hmac.update(signatureData).digest('base64');
|
||||||
|
|
||||||
|
logger.debug('Signature validation:', {
|
||||||
|
timestamp,
|
||||||
|
signatureData: signatureData.substring(0, 50) + '...',
|
||||||
|
computedSignature,
|
||||||
|
receivedSignature: signature
|
||||||
|
});
|
||||||
|
|
||||||
|
return computedSignature === signature;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error validating signature:', error);
|
||||||
|
return true; // Allow request to proceed on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
// Only allow POST requests
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ error: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Incoming request to /api/dial:');
|
||||||
|
logger.info(`Headers: ${JSON.stringify(req.headers)}`);
|
||||||
|
|
||||||
|
const rawBody = JSON.stringify(req.body);
|
||||||
|
logger.info(`Raw body: ${rawBody}`);
|
||||||
|
|
||||||
|
const signature = req.headers['x-pinless-signature'];
|
||||||
|
const timestamp = req.headers['x-pinless-timestamp'];
|
||||||
|
|
||||||
|
if (signature && timestamp) {
|
||||||
|
logger.info('Validating HMAC signature');
|
||||||
|
if (!validateSignature(rawBody, signature, timestamp, process.env.PINLESS_HMAC_SECRET)) {
|
||||||
|
logger.error('Invalid HMAC signature', { signature, timestamp });
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Invalid signature',
|
||||||
|
message: 'Invalid HMAC signature'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info('Skipping HMAC validation - no signature headers present');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract request data
|
||||||
|
const {
|
||||||
|
Test: test,
|
||||||
|
To,
|
||||||
|
From,
|
||||||
|
callId,
|
||||||
|
callDomain,
|
||||||
|
dialout_settings,
|
||||||
|
voicemail_detection,
|
||||||
|
call_transfer
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Handle test requests when a webhook is configured
|
||||||
|
if (test === 'test') {
|
||||||
|
logger.debug('Test request received');
|
||||||
|
return res.status(200).json({ status: 'success', message: 'Test request received' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process dialin settings
|
||||||
|
let dialin_settings = null;
|
||||||
|
const requiredFields = ['To', 'From', 'callId', 'callDomain'];
|
||||||
|
|
||||||
|
if (requiredFields.every(field => req.body[field] !== undefined && req.body[field] !== null)) {
|
||||||
|
dialin_settings = {
|
||||||
|
// snake_case because pipecat expects this format
|
||||||
|
From,
|
||||||
|
To,
|
||||||
|
call_id: callId,
|
||||||
|
call_domain: callDomain,
|
||||||
|
};
|
||||||
|
logger.debug(`Populated dialin_settings from request: ${JSON.stringify(dialin_settings)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up Daily room properties
|
||||||
|
const daily_room_properties = {
|
||||||
|
enable_dialout: dialout_settings !== undefined && dialout_settings !== null,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + (5 * 60), // 5 minutes from now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure SIP if dialin settings are provided
|
||||||
|
if (dialin_settings !== null) {
|
||||||
|
const sip_config = {
|
||||||
|
display_name: From,
|
||||||
|
sip_mode: 'dial-in',
|
||||||
|
num_endpoints: call_transfer !== null ? 2 : 1,
|
||||||
|
};
|
||||||
|
daily_room_properties.sip = sip_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare payload for {service}/start API call
|
||||||
|
const payload = {
|
||||||
|
createDailyRoom: true,
|
||||||
|
dailyRoomProperties: daily_room_properties,
|
||||||
|
body: {
|
||||||
|
dialin_settings,
|
||||||
|
dialout_settings,
|
||||||
|
voicemail_detection,
|
||||||
|
call_transfer,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.debug(`Daily room properties: ${JSON.stringify(daily_room_properties)}`);
|
||||||
|
|
||||||
|
// Get Daily API key and agent name from environment variables
|
||||||
|
const pccApiKey = process.env.PIPECAT_CLOUD_API_KEY;
|
||||||
|
const agentName = process.env.AGENT_NAME || 'my-first-agent';
|
||||||
|
|
||||||
|
if (!pccApiKey) {
|
||||||
|
throw new Error('PIPECAT_CLOUD_API_KEY environment variable is not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up headers for Daily API call
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${pccApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `https://api.pipecat.daily.co/v1/public/${agentName}/start`;
|
||||||
|
logger.debug(`Making API call to Daily: ${url} ${JSON.stringify(headers)} ${JSON.stringify(payload)}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(url, payload, { headers });
|
||||||
|
logger.debug(`Response: ${JSON.stringify(response.data)}`);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
status: 'success',
|
||||||
|
data: response.data,
|
||||||
|
room_properties: daily_room_properties,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response) {
|
||||||
|
// Pass through status code and error details from the Daily API
|
||||||
|
const statusCode = error.response.status;
|
||||||
|
const errorDetail = error.response.data || error.message;
|
||||||
|
logger.error(`HTTP error: ${JSON.stringify(errorDetail)}`);
|
||||||
|
return res.status(statusCode).json(errorDetail);
|
||||||
|
} else {
|
||||||
|
logger.error(`Request error: ${error.message}`);
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Unexpected error: ${error.message}`);
|
||||||
|
return res.status(500).json({ error: 'Internal server error', message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure body parser to preserve raw body text
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: {
|
||||||
|
sizeLimit: '1mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { logger } from '../../lib/utils';
|
||||||
|
|
||||||
|
export default function handler(req, res) {
|
||||||
|
logger.info('Received request to /api');
|
||||||
|
res.status(200).json({ message: 'Hello, World! from ᓚᘏᗢ' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
version: 2,
|
||||||
|
buildCommand: "next build",
|
||||||
|
outputDirectory: ".next",
|
||||||
|
cleanUrls: true
|
||||||
|
};
|
||||||
@@ -159,8 +159,8 @@ class AzureTTSService(AzureBaseTTSService):
|
|||||||
self._speech_config = SpeechConfig(
|
self._speech_config = SpeechConfig(
|
||||||
subscription=self._api_key,
|
subscription=self._api_key,
|
||||||
region=self._region,
|
region=self._region,
|
||||||
speech_recognition_language=self._settings["language"],
|
|
||||||
)
|
)
|
||||||
|
self._speech_config.speech_synthesis_language = self._settings["language"]
|
||||||
self._speech_config.set_speech_synthesis_output_format(
|
self._speech_config.set_speech_synthesis_output_format(
|
||||||
sample_rate_to_output_format(self.sample_rate)
|
sample_rate_to_output_format(self.sample_rate)
|
||||||
)
|
)
|
||||||
@@ -254,8 +254,8 @@ class AzureHttpTTSService(AzureBaseTTSService):
|
|||||||
self._speech_config = SpeechConfig(
|
self._speech_config = SpeechConfig(
|
||||||
subscription=self._api_key,
|
subscription=self._api_key,
|
||||||
region=self._region,
|
region=self._region,
|
||||||
speech_recognition_language=self._settings["language"],
|
|
||||||
)
|
)
|
||||||
|
self._speech_config.speech_synthesis_language = self._settings["language"]
|
||||||
self._speech_config.set_speech_synthesis_output_format(
|
self._speech_config.set_speech_synthesis_output_format(
|
||||||
sample_rate_to_output_format(self.sample_rate)
|
sample_rate_to_output_format(self.sample_rate)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
voice_config["__experimental_controls"]["emotion"] = self._settings["emotion"]
|
voice_config["__experimental_controls"]["emotion"] = self._settings["emotion"]
|
||||||
|
|
||||||
msg = {
|
msg = {
|
||||||
"transcript": text or " ", # Text must contain at least one character
|
"transcript": text,
|
||||||
"continue": continue_transcript,
|
"continue": continue_transcript,
|
||||||
"context_id": self._context_id,
|
"context_id": self._context_id,
|
||||||
"model_id": self.model_name,
|
"model_id": self.model_name,
|
||||||
@@ -287,7 +287,7 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
|||||||
self._context_id = str(uuid.uuid4())
|
self._context_id = str(uuid.uuid4())
|
||||||
await self.create_audio_context(self._context_id)
|
await self.create_audio_context(self._context_id)
|
||||||
|
|
||||||
msg = self._build_msg(text=text or " ") # Text must contain at least one character
|
msg = self._build_msg(text=text)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._get_websocket().send(msg)
|
await self._get_websocket().send(msg)
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class OpenAITTSService(TTSService):
|
|||||||
extra_body["instructions"] = self._instructions
|
extra_body["instructions"] = self._instructions
|
||||||
|
|
||||||
async with self._client.audio.speech.with_streaming_response.create(
|
async with self._client.audio.speech.with_streaming_response.create(
|
||||||
input=text or " ", # Text must contain at least one character
|
input=text,
|
||||||
model=self.model_name,
|
model=self.model_name,
|
||||||
voice=VALID_VOICES[self._voice_id],
|
voice=VALID_VOICES[self._voice_id],
|
||||||
response_format="pcm",
|
response_format="pcm",
|
||||||
|
|||||||
@@ -269,7 +269,8 @@ class TTSService(AIService):
|
|||||||
filter.reset_interruption()
|
filter.reset_interruption()
|
||||||
text = filter.filter(text)
|
text = filter.filter(text)
|
||||||
|
|
||||||
await self.process_generator(self.run_tts(text))
|
if text:
|
||||||
|
await self.process_generator(self.run_tts(text))
|
||||||
|
|
||||||
await self.stop_processing_metrics()
|
await self.stop_processing_metrics()
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
|||||||
from pipecat.transports.base_transport import TransportParams
|
from pipecat.transports.base_transport import TransportParams
|
||||||
from pipecat.utils.time import nanoseconds_to_seconds
|
from pipecat.utils.time import nanoseconds_to_seconds
|
||||||
|
|
||||||
BOT_VAD_STOP_SECS = 0.3
|
BOT_VAD_STOP_SECS = 0.35
|
||||||
|
|
||||||
|
|
||||||
class BaseOutputTransport(FrameProcessor):
|
class BaseOutputTransport(FrameProcessor):
|
||||||
|
|||||||
Reference in New Issue
Block a user