Merge branch 'main' into feat/gladia-auto-reconnect

This commit is contained in:
Jean-Louis Queguiner
2025-06-18 08:57:21 +02:00
committed by GitHub
180 changed files with 11267 additions and 3343 deletions

View File

@@ -9,6 +9,136 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added Polish support to `AWSTranscribeSTTService`.
- Added new frames `FrameProcessorPauseFrame` and `FrameProcessorResumeFrame`
which allow pausing and resuming frame processing for a given frame
processor. These are control frames, so they are ordered. Pausing frame
processor will keep old frames in the internal queues until resume takes
place. Frames being pushed while a frame processor is paused will be pushed to
the queues. When frame processing is resumed all queued frames will be
processed in order. Also added `FrameProcessorPauseUrgentFrame` and
`FrameProcessorResumeUrgentFrame` which are system frames and therefore they
have high priority.
- Added a property called `has_function_calls_in_progress` in
`LLMAssistantContextAggregator` that exposes whether a function call is in
progress.
### Changed
- Upgraded `daily-python` to 0.19.3.
### Fixed
- Fixed an issue with `GroqTTSService` where it was not properly parsing the
WAV file header.
- Fixed an issue with `GoogleSTTService` where it was constantly reconnecting
before starting to receive audio from the user.
- Fixed an issue where `GoogleLLMService`'s TTFB value was incorrect.
### Other
- Rename `14e-function-calling-gemini.py` to `14e-function-calling-google.py`.
## [0.0.71] - 2025-06-10
### Added
- Adds a parameter called `additional_span_attributes` to PipelineTask that
lets you add any additional attributes you'd like to the conversation span.
### Fixed
- Fixed an issue with `CartesiaSTTService` initialization.
## [0.0.70] - 2025-06-10
### Added
- Added `ExotelFrameSerializer` to handle telephony calls via Exotel.
- Added the option `informal` to `TranslationConfig` on Gladia config.
Allowing to force informal language forms when available.
- Added `CartesiaSTTService` which is a websocket based implementation to
transcribe audio. Added a foundational example in
`13f-cartesia-transcription.py`
- Added an `websocket` example, showing how to use the new Pipecat client
`WebsocketTransport` to connect with Pipecat `FastAPIWebsocketTransport` or
`WebsocketServerTransport`.
- Added language support to `RimeHttpTTSService`. Extended languages to include
German and French for both `RimeTTSService` and `RimeHttpTTSService`.
### Changed
- Upgraded `daily-python` to 0.19.2.
- Make `PipelineTask.add_observer()` synchronous. This allows callers to call it
before doing the work of running the `PipelineTask` (i.e. without invoking
`PipelineTask.set_event_loop()` first).
- Pipecat 0.0.69 forced `uvloop` event loop on Linux on macOS. Unfortunately,
this is causing issue in some systems. So, `uvloop` is not enabled by default
anymore. If you want to use `uvloop` you can just set the `asyncio` event
policy before starting your agent with:
```python
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
```
### Fixed
- Fixed an issue with various TTS services that would cause audio glitches at
the start of every bot turn.
- Fixed an `ElevenLabsTTSService` issue where a context warning was printed
when pushing a `TTSSpeakFrame`.
- Fixed an `AssemblyAISTTService` issue that could cause unexpected behavior
when yielding empty `Frame()`s.
- Fixed an issue where `OutputAudioRawFrame.transport_destination` was being
reset to `None` instead of retaining its intended value before sending the
audio frame to `write_audio_frame`.
- Fixed a typo in Livekit transport that prevented initialization.
## [0.0.69] - 2025-06-02 "AI Engineer World's Fair release" ✨
### Added
- Added a new frame `FunctionCallsStartedFrame`. This frame is pushed both
upstream and downstream from the LLM service to indicate that one or more
function calls are going to be executed.
- Added LLM services `on_function_calls_started` event. This event will be
triggered when the LLM service receives function calls from the model and is
going to start executing them.
- Function calls can now be executed sequentially (in the order received in the
completion) by passing `run_in_parallel=False` when creating your LLM
service. By default, if the LLM completion returns 2 or more function calls
they run concurrently. In both cases, concurrently and sequentially, a new LLM
completion will run when the last function call finishes.
- Added OpenTelemetry tracing for `GeminiMultimodalLiveLLMService` and
`OpenAIRealtimeBetaLLMService`.
- Added initial support for interruption strategies, which determine if the user
should interrupt the bot while the bot is speaking. Interruption strategies
can be based on factors such as audio volume or the number of words spoken by
the user. These can be specified via the new `interruption_strategies` field
in `PipelineParams`. A new `MinWordsInterruptionStrategy` strategy has been
introduced which triggers an interruption if the user has spoken a minimum
number of words. If no interruption strategies are specified, the normal
interruption behavior applies. If multiple strategies are provided, the first
one that evaluates to true will trigger the interruption.
- `BaseInputTransport` now handles `StopFrame`. When a `StopFrame` is received
the transport will pause sending frames downstream until a new `StartFrame` is
received. This allows the transport to be reused (keeping the same connection)
@@ -41,6 +171,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`DailyTransport.stop_transcription()` to be able to start and stop Daily
transcription dynamically (maybe with different settings).
### Changed
- Reverted the default model for `GeminiMultimodalLiveLLMService` back to
`models/gemini-2.0-flash-live-001`.
`gemini-2.5-flash-preview-native-audio-dialog` has inconsistent performance.
You can opt in to using this model by setting the `model` arg.
- Function calls are now cancelled by default if there's an interruption. To
disable this behavior you can set `cancel_on_interruption=False` when
registering the function call. Since function calls are executed as tasks you
can tell if a function call has been cancelled by catching the
`asyncio.CancelledError` exception (and don't forget to raise it again!).
- Updated OpenTelemetry tracing attribute `metrics.ttfb_ms` to `metrics.ttfb`.
The attribute reports TTFB in seconds.
### Deprecated
- `DailyTransport.send_dtmf()` is deprecated, push an `OutputDTMFFrame` or an
@@ -48,6 +194,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed an issue with `ElevenLabsTTSService` where long responses would
continue generating output even after an interruption.
- Fixed an issue with the `OpenAILLMContext` where non-Roman characters were
being incorrectly encoded as Unicode escape sequences. This was a logging
issue and did not impact the actual conversation.
- In `AWSBedrockLLMService`, worked around a possible bug in AWS Bedrock where
a `toolConfig` is required if there has been previous tool use in the
messages array. This workaround includes a no_op factory function call is

View File

@@ -53,7 +53,7 @@ You can connect to Pipecat from any platform using our official SDKs:
| Category | Services |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [Parakeet (NVIDIA)](https://docs.pipecat.ai/server/services/stt/parakeet), [Ultravox](https://docs.pipecat.ai/server/services/stt/ultravox), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) |
| Speech-to-Text | [AssemblyAI](https://docs.pipecat.ai/server/services/stt/assemblyai), [AWS](https://docs.pipecat.ai/server/services/stt/aws), [Azure](https://docs.pipecat.ai/server/services/stt/azure), [Cartesia](https://docs.pipecat.ai/server/services/stt/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/stt/deepgram), [Fal Wizper](https://docs.pipecat.ai/server/services/stt/fal), [Gladia](https://docs.pipecat.ai/server/services/stt/gladia), [Google](https://docs.pipecat.ai/server/services/stt/google), [Groq (Whisper)](https://docs.pipecat.ai/server/services/stt/groq), [OpenAI (Whisper)](https://docs.pipecat.ai/server/services/stt/openai), [Parakeet (NVIDIA)](https://docs.pipecat.ai/server/services/stt/parakeet), [Ultravox](https://docs.pipecat.ai/server/services/stt/ultravox), [Whisper](https://docs.pipecat.ai/server/services/stt/whisper) |
| LLMs | [Anthropic](https://docs.pipecat.ai/server/services/llm/anthropic), [AWS](https://docs.pipecat.ai/server/services/llm/aws), [Azure](https://docs.pipecat.ai/server/services/llm/azure), [Cerebras](https://docs.pipecat.ai/server/services/llm/cerebras), [DeepSeek](https://docs.pipecat.ai/server/services/llm/deepseek), [Fireworks AI](https://docs.pipecat.ai/server/services/llm/fireworks), [Gemini](https://docs.pipecat.ai/server/services/llm/gemini), [Grok](https://docs.pipecat.ai/server/services/llm/grok), [Groq](https://docs.pipecat.ai/server/services/llm/groq), [NVIDIA NIM](https://docs.pipecat.ai/server/services/llm/nim), [Ollama](https://docs.pipecat.ai/server/services/llm/ollama), [OpenAI](https://docs.pipecat.ai/server/services/llm/openai), [OpenRouter](https://docs.pipecat.ai/server/services/llm/openrouter), [Perplexity](https://docs.pipecat.ai/server/services/llm/perplexity), [Qwen](https://docs.pipecat.ai/server/services/llm/qwen), [Together AI](https://docs.pipecat.ai/server/services/llm/together) |
| Text-to-Speech | [AWS](https://docs.pipecat.ai/server/services/tts/aws), [Azure](https://docs.pipecat.ai/server/services/tts/azure), [Cartesia](https://docs.pipecat.ai/server/services/tts/cartesia), [Deepgram](https://docs.pipecat.ai/server/services/tts/deepgram), [ElevenLabs](https://docs.pipecat.ai/server/services/tts/elevenlabs), [FastPitch (NVIDIA)](https://docs.pipecat.ai/server/services/tts/fastpitch), [Fish](https://docs.pipecat.ai/server/services/tts/fish), [Google](https://docs.pipecat.ai/server/services/tts/google), [LMNT](https://docs.pipecat.ai/server/services/tts/lmnt), [MiniMax](https://docs.pipecat.ai/server/services/tts/minimax), [Neuphonic](https://docs.pipecat.ai/server/services/tts/neuphonic), [OpenAI](https://docs.pipecat.ai/server/services/tts/openai), [Piper](https://docs.pipecat.ai/server/services/tts/piper), [PlayHT](https://docs.pipecat.ai/server/services/tts/playht), [Rime](https://docs.pipecat.ai/server/services/tts/rime), [Sarvam](https://docs.pipecat.ai/server/services/tts/sarvam), [XTTS](https://docs.pipecat.ai/server/services/tts/xtts) |
| Speech-to-Speech | [AWS Nova Sonic](https://docs.pipecat.ai/server/services/s2s/aws), [Gemini Multimodal Live](https://docs.pipecat.ai/server/services/s2s/gemini), [OpenAI Realtime](https://docs.pipecat.ai/server/services/s2s/openai) |

View File

@@ -3,11 +3,11 @@ coverage~=7.6.12
grpcio-tools~=1.67.1
pip-tools~=7.4.1
pre-commit~=4.0.1
pyright~=1.1.397
pyright~=1.1.400
pytest~=8.3.4
pytest-asyncio~=0.25.3
pytest-aiohttp==1.1.0
ruff~=0.11.1
ruff~=0.11.13
setuptools~=70.0.0
setuptools_scm~=8.1.0
python-dotenv~=1.0.1

View File

@@ -77,37 +77,36 @@ async def configure_livekit():
async def main():
async with aiohttp.ClientSession() as session:
(url, token, room_name) = await configure_livekit()
(url, token, room_name) = await configure_livekit()
transport = LiveKitTransport(
url=url,
token=token,
room_name=room_name,
params=LiveKitParams(audio_out_enabled=True),
)
transport = LiveKitTransport(
url=url,
token=token,
room_name=room_name,
params=LiveKitParams(audio_out_enabled=True),
)
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
)
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
)
runner = PipelineRunner()
runner = PipelineRunner()
task = PipelineTask(Pipeline([tts, transport.output()]))
task = PipelineTask(Pipeline([tts, transport.output()]))
# Register an event handler so we can play the audio when the
# participant joins.
@transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(transport, participant_id):
await asyncio.sleep(1)
await task.queue_frame(
TextFrame(
"Hello there! How are you doing today? Would you like to talk about the weather?"
)
# Register an event handler so we can play the audio when the
# participant joins.
@transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(transport, participant_id):
await asyncio.sleep(1)
await task.queue_frame(
TextFrame(
"Hello there! How are you doing today? Would you like to talk about the weather?"
)
)
await runner.run(task)
await runner.run(task)
if __name__ == "__main__":

View File

@@ -156,7 +156,7 @@ async def offer(request: dict, background_tasks: BackgroundTasks):
@asynccontextmanager
async def lifespan(app: FastAPI):
yield # Run app
coros = [pc.close() for pc in pcs_map.values()]
coros = [pc.disconnect() for pc in pcs_map.values()]
await asyncio.gather(*coros)
pcs_map.clear()

View File

@@ -9,11 +9,11 @@ import os
import sys
import aiohttp
from daily_runner import configure
from dotenv import load_dotenv
from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.examples.daily_runner import configure
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask

View File

@@ -0,0 +1,71 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.frames.frames import Frame, TranscriptionFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineTask
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.cartesia.stt import CartesiaSTTService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams
from pipecat.transports.services.daily import DailyParams
load_dotenv(override=True)
class TranscriptionLogger(FrameProcessor):
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, TranscriptionFrame):
print(f"Transcription: {frame.text}")
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
# instantiated. The function will be called when the desired transport gets
# selected.
transport_params = {
"daily": lambda: DailyParams(audio_in_enabled=True),
"twilio": lambda: FastAPIWebsocketParams(audio_in_enabled=True),
"webrtc": lambda: TransportParams(audio_in_enabled=True),
}
async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_sigint: bool):
logger.info(f"Starting bot")
stt = CartesiaSTTService(
api_key=os.getenv("CARTESIA_API_KEY"),
base_url=os.getenv("CARTESIA_BASE_URL"),
)
tl = TranscriptionLogger()
pipeline = Pipeline([transport.input(), stt, tl])
task = PipelineTask(pipeline)
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info(f"Client disconnected")
await task.cancel()
runner = PipelineRunner(handle_sigint=handle_sigint)
await runner.run(task)
if __name__ == "__main__":
from pipecat.examples.run import main
main(run_example, transport_params=transport_params)

View File

@@ -30,10 +30,13 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
async def fetch_restaurant_recommendation(params: FunctionCallParams):
await params.result_callback({"name": "The Golden Dragon"})
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
# instantiated. The function will be called when the desired transport gets
# selected.
@@ -71,6 +74,11 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# You can also register a function_name of None to get all functions
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
@@ -88,7 +96,18 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
},
required=["location", "format"],
)
tools = ToolsSchema(standard_tools=[weather_function])
restaurant_function = FunctionSchema(
name="get_restaurant_recommendation",
description="Get a restaurant recommendation",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
required=["location"],
)
tools = ToolsSchema(standard_tools=[weather_function, restaurant_function])
messages = [
{

View File

@@ -33,6 +33,10 @@ async def get_weather(params: FunctionCallParams):
await params.result_callback(f"The weather in {location} is currently 72 degrees and sunny.")
async def fetch_restaurant_recommendation(params: FunctionCallParams):
await params.result_callback({"name": "The Golden Dragon"})
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
# instantiated. The function will be called when the desired transport gets
# selected.
@@ -66,9 +70,11 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
)
llm = AnthropicLLMService(
api_key=os.getenv("ANTHROPIC_API_KEY"), model="claude-3-7-sonnet-latest"
api_key=os.getenv("ANTHROPIC_API_KEY"),
model="claude-3-7-sonnet-latest",
)
llm.register_function("get_weather", get_weather)
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
weather_function = FunctionSchema(
name="get_weather",
@@ -81,7 +87,18 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
},
required=["location"],
)
tools = ToolsSchema(standard_tools=[weather_function])
restaurant_function = FunctionSchema(
name="get_restaurant_recommendation",
description="Get a restaurant recommendation",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
required=["location"],
)
tools = ToolsSchema(standard_tools=[weather_function, restaurant_function])
# todo: test with very short initial user message

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -74,6 +73,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -35,11 +35,14 @@ client_id = ""
async def get_weather(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
location = params.arguments["location"]
await params.result_callback(f"The weather in {location} is currently 72 degrees and sunny.")
async def fetch_restaurant_recommendation(params: FunctionCallParams):
await params.result_callback({"name": "The Golden Dragon"})
async def get_image(params: FunctionCallParams):
question = params.arguments["question"]
logger.debug(f"Requesting image with user_id={client_id}, question={question}")
@@ -93,6 +96,11 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
llm = GoogleLLMService(api_key=os.getenv("GOOGLE_API_KEY"), model="gemini-2.0-flash-001")
llm.register_function("get_weather", get_weather)
llm.register_function("get_image", get_image)
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_weather",
@@ -110,6 +118,17 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
},
required=["location", "format"],
)
restaurant_function = FunctionSchema(
name="get_restaurant_recommendation",
description="Get a restaurant recommendation",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
required=["location"],
)
get_image_function = FunctionSchema(
name="get_image",
description="Get an image from the video stream.",
@@ -121,14 +140,14 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
},
required=["question"],
)
tools = ToolsSchema(standard_tools=[weather_function, get_image_function])
tools = ToolsSchema(standard_tools=[weather_function, get_image_function, restaurant_function])
system_prompt = """\
You are a helpful assistant who converses with a user and answers questions. Respond concisely to general questions.
Your response will be turned into speech so use only simple words and punctuation.
You have access to two tools: get_weather and get_image.
You have access to three tools: get_weather, get_restaurant_recommendation, and get_image.
You can respond to questions about the weather using the get_weather tool.

View File

@@ -31,7 +31,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -74,6 +73,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -75,6 +74,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -74,6 +73,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -72,6 +71,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -71,6 +70,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -71,6 +70,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -75,6 +74,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -71,6 +70,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -76,6 +75,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -30,7 +30,6 @@ load_dotenv(override=True)
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -72,6 +71,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -32,6 +32,10 @@ async def fetch_weather_from_api(params: FunctionCallParams):
await params.result_callback({"conditions": "nice", "temperature": "75"})
async def fetch_restaurant_recommendation(params: FunctionCallParams):
await params.result_callback({"name": "The Golden Dragon"})
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
# instantiated. The function will be called when the desired transport gets
# selected.
@@ -74,6 +78,7 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# You can also register a function_name of None to get all functions
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
weather_function = FunctionSchema(
name="get_current_weather",
@@ -91,7 +96,18 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
},
required=["location", "format"],
)
tools = ToolsSchema(standard_tools=[weather_function])
restaurant_function = FunctionSchema(
name="get_restaurant_recommendation",
description="Get a restaurant recommendation",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
required=["location"],
)
tools = ToolsSchema(standard_tools=[weather_function, restaurant_function])
messages = [
{

View File

@@ -14,10 +14,12 @@ from loguru import logger
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import TranscriptionMessage
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.processors.transcript_processor import TranscriptProcessor
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai_realtime_beta import (
InputAudioNoiseReduction,
@@ -45,6 +47,10 @@ async def fetch_weather_from_api(params: FunctionCallParams):
)
async def fetch_restaurant_recommendation(params: FunctionCallParams):
await params.result_callback({"name": "The Golden Dragon"})
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",
@@ -62,8 +68,20 @@ weather_function = FunctionSchema(
required=["location", "format"],
)
restaurant_function = FunctionSchema(
name="get_restaurant_recommendation",
description="Get a restaurant recommendation",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
required=["location"],
)
# Create tools schema
tools = ToolsSchema(standard_tools=[weather_function])
tools = ToolsSchema(standard_tools=[weather_function, restaurant_function])
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
@@ -100,7 +118,7 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# turn_detection=False,
input_audio_noise_reduction=InputAudioNoiseReduction(type="near_field"),
# tools=tools,
instructions="""Your knowledge cutoff is 2023-10. You are a helpful and friendly AI.
instructions="""You are a helpful and friendly AI.
Act like a human, but remember that you aren't a human and that you can't do human
things in the real world. Your voice and personality should be warm and engaging, with a lively and
@@ -109,10 +127,14 @@ playful tone.
If interacting in a non-English language, start by using the standard accent or dialect familiar to
the user. Talk quickly. You should always call a function if you can. Do not refer to these rules,
even if you're asked about them.
-
You are participating in a voice conversation. Keep your responses concise, short, and to the point
unless specifically asked to elaborate on a topic.
You have access to the following tools:
- get_current_weather: Get the current weather for a given location.
- get_restaurant_recommendation: Get a restaurant recommendation for a given location.
Remember, your responses should be short. Just one or two sentences, usually.""",
)
@@ -125,6 +147,9 @@ Remember, your responses should be short. Just one or two sentences, usually."""
# you can either register a single function for all function calls, or specific functions
# llm.register_function(None, fetch_weather_from_api)
llm.register_function("get_current_weather", fetch_weather_from_api)
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
transcript = TranscriptProcessor()
# Create a standard OpenAI LLM context object using the normal messages format. The
# OpenAIRealtimeBetaLLMService will convert this internally to messages that the
@@ -151,7 +176,9 @@ Remember, your responses should be short. Just one or two sentences, usually."""
transport.input(), # Transport user input
context_aggregator.user(),
llm, # LLM
transcript.user(), # Placed after the LLM, as LLM pushes TranscriptionFrames downstream
transport.output(), # Transport bot output
transcript.assistant(), # After the transcript output, to time with the audio output
context_aggregator.assistant(),
]
)
@@ -177,6 +204,15 @@ Remember, your responses should be short. Just one or two sentences, usually."""
logger.info(f"Client disconnected")
await task.cancel()
# Register event handler for transcript updates
@transcript.event_handler("on_transcript_update")
async def on_transcript_update(processor, frame):
for msg in frame.messages:
if isinstance(msg, TranscriptionMessage):
timestamp = f"[{msg.timestamp}] " if msg.timestamp else ""
line = f"{timestamp}{msg.role}: {msg.content}"
logger.info(f"Transcript: {line}")
runner = PipelineRunner(handle_sigint=handle_sigint)
await runner.run(task)

View File

@@ -43,6 +43,10 @@ async def fetch_weather_from_api(params: FunctionCallParams):
)
async def fetch_restaurant_recommendation(params: FunctionCallParams):
await params.result_callback({"name": "The Golden Dragon"})
# Define weather function using standardized schema
weather_function = FunctionSchema(
name="get_current_weather",
@@ -61,8 +65,20 @@ weather_function = FunctionSchema(
required=["location", "format"],
)
restaurant_function = FunctionSchema(
name="get_restaurant_recommendation",
description="Get a restaurant recommendation",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
required=["location"],
)
# Create tools schema
tools = ToolsSchema(standard_tools=[weather_function])
tools = ToolsSchema(standard_tools=[weather_function, restaurant_function])
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
@@ -98,7 +114,7 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# Or set to False to disable openai turn detection and use transport VAD
# turn_detection=False,
# tools=tools,
instructions="""Your knowledge cutoff is 2023-10. You are a helpful and friendly AI.
instructions="""You are a helpful and friendly AI.
Act like a human, but remember that you aren't a human and that you can't do human
things in the real world. Your voice and personality should be warm and engaging, with a lively and
@@ -111,6 +127,10 @@ even if you're asked about them.
You are participating in a voice conversation. Keep your responses concise, short, and to the point
unless specifically asked to elaborate on a topic.
You have access to the following tools:
- get_current_weather: Get the current weather for a given location.
- get_restaurant_recommendation: Get a restaurant recommendation for a given location.
Remember, your responses should be short. Just one or two sentences, usually.""",
)
@@ -124,6 +144,7 @@ Remember, your responses should be short. Just one or two sentences, usually."""
# you can either register a single function for all function calls, or specific functions
# llm.register_function(None, fetch_weather_from_api)
llm.register_function("get_current_weather", fetch_weather_from_api)
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
# Create a standard OpenAI LLM context object using the normal messages format. The
# OpenAIRealtimeBetaLLMService will convert this internally to messages that the

View File

@@ -198,7 +198,6 @@ class OutputGate(FrameProcessor):
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -245,6 +244,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
tools = [
ChatCompletionToolParam(
type="function",
@@ -309,9 +312,6 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# to start the conversation.
bot_output_gate = OutputGate(notifier=notifier, start_open=True)
async def block_user_stopped_speaking(frame):
return not isinstance(frame, UserStoppedSpeakingFrame)
async def pass_only_llm_trigger_frames(frame):
return (
isinstance(frame, OpenAILLMContextFrame)
@@ -328,11 +328,6 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
stt,
context_aggregator.user(),
ParallelPipeline(
[
# Pass everything except UserStoppedSpeaking to the elements after
# this ParallelPipeline
FunctionFilter(filter=block_user_stopped_speaking),
],
[
# Ignore everything except an OpenAILLMContextFrame. Pass a specially constructed
# LLMMessagesFrame to the statement classifier LLM. The only frame this

View File

@@ -402,7 +402,6 @@ class OutputGate(FrameProcessor):
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -449,6 +448,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
tools = [
ChatCompletionToolParam(
type="function",

View File

@@ -40,11 +40,17 @@ async def fetch_weather_from_api(params: FunctionCallParams):
)
async def fetch_restaurant_recommendation(params: FunctionCallParams):
await params.result_callback({"name": "The Golden Dragon"})
system_instruction = """
You are a helpful assistant who can answer questions and use tools.
You have a tool called "get_current_weather" that can be used to get the current weather. If the user asks
for the weather, call this function.
You have three tools available to you:
1. get_current_weather: Use this tool to get the current weather in a specific location.
2. get_restaurant_recommendation: Use this tool to get a restaurant recommendation in a specific location.
3. google_search: Use this tool to search the web for information.
"""
@@ -101,9 +107,21 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
},
required=["location", "format"],
)
restaurant_function = FunctionSchema(
name="get_restaurant_recommendation",
description="Get a restaurant recommendation",
properties={
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
},
required=["location"],
)
search_tool = {"google_search": {}}
tools = ToolsSchema(
standard_tools=[weather_function], custom_tools={AdapterType.GEMINI: [search_tool]}
standard_tools=[weather_function, restaurant_function],
custom_tools={AdapterType.GEMINI: [search_tool]},
)
llm = GeminiMultimodalLiveLLMService(
@@ -113,6 +131,7 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
)
llm.register_function("get_current_weather", fetch_weather_from_api)
llm.register_function("get_restaurant_recommendation", fetch_restaurant_recommendation)
context = OpenAILLMContext(
[{"role": "user", "content": "Say hello."}],

View File

@@ -82,78 +82,77 @@ transport_params = {
async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_sigint: bool):
logger.info(f"Starting bot")
# Create an HTTP session for API calls
async with aiohttp.ClientSession() as session:
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
messages = [
{
"role": "system",
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Respond to what the user said in a creative and helpful way.",
},
messages = [
{
"role": "system",
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Respond to what the user said in a creative and helpful way.",
},
]
context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context)
action_llm_append_to_messages = create_action_llm_append_to_messages(context_aggregator)
rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
rtvi.register_action(action_llm_append_to_messages)
pipeline = Pipeline(
[
transport.input(),
rtvi,
context_aggregator.user(),
llm,
transport.output(),
context_aggregator.assistant(),
]
)
context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context)
task = PipelineTask(
pipeline,
params=PipelineParams(
allow_interruptions=True,
enable_metrics=True,
),
observers=[RTVIObserver(rtvi)],
)
action_llm_append_to_messages = create_action_llm_append_to_messages(context_aggregator)
rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
rtvi.register_action(action_llm_append_to_messages)
@rtvi.event_handler("on_client_ready")
async def on_client_ready(rtvi):
logger.info("Pipecat client ready.")
await rtvi.set_bot_ready()
pipeline = Pipeline(
[
transport.input(),
rtvi,
context_aggregator.user(),
llm,
transport.output(),
context_aggregator.assistant(),
]
)
# This block is frontend UI specific
# These messages are intended for small webrtc UI to only handle text
# https://github.com/pipecat-ai/small-webrtc-prebuilt
messages = {
"show_text_container": True,
"show_video_container": False,
"show_debug_container": False,
}
task = PipelineTask(
pipeline,
params=PipelineParams(
allow_interruptions=True,
enable_metrics=True,
),
observers=[RTVIObserver(rtvi)],
)
rtvi_frame = RTVIServerMessageFrame(data=messages)
await task.queue_frames([rtvi_frame])
@rtvi.event_handler("on_client_ready")
async def on_client_ready(rtvi):
logger.info("Pipecat client ready.")
await rtvi.set_bot_ready()
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected: {client}")
# Kick off the conversation.
await task.queue_frames([context_aggregator.user().get_context_frame()])
# This block is frontend UI specific
# These messages are intended for small webrtc UI to only handle text
# https://github.com/pipecat-ai/small-webrtc-prebuilt
messages = {
"show_text_container": True,
"show_video_container": False,
"show_debug_container": False,
}
rtvi_frame = RTVIServerMessageFrame(data=messages)
await task.queue_frames([rtvi_frame])
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info(f"Client disconnected")
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected: {client}")
# Kick off the conversation.
await task.queue_frames([context_aggregator.user().get_context_frame()])
@transport.event_handler("on_client_closed")
async def on_client_closed(transport, client):
logger.info(f"Client closed connection")
await task.cancel()
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info(f"Client disconnected")
runner = PipelineRunner(handle_sigint=False)
@transport.event_handler("on_client_closed")
async def on_client_closed(transport, client):
logger.info(f"Client closed connection")
await task.cancel()
runner = PipelineRunner(handle_sigint=False)
await runner.run(task)
await runner.run(task)
if __name__ == "__main__":

View File

@@ -92,85 +92,83 @@ transport_params = {
async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_sigint: bool):
logger.info(f"Starting bot")
# Create an HTTP session for API calls
async with aiohttp.ClientSession() as session:
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), voice_id="71a7ad14-091c-4e8e-a314-022ece01c121"
)
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"), voice_id="71a7ad14-091c-4e8e-a314-022ece01c121"
)
messages = [
{
"role": "system",
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Respond to what the user says in a creative and helpful way. Explain to the User they can speak or type text to communicate with you.",
},
messages = [
{
"role": "system",
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Respond to what the user says in a creative and helpful way. Explain to the User they can speak or type text to communicate with you.",
},
]
context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context)
action_llm_append_to_messages = create_action_llm_append_to_messages(context_aggregator)
rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
rtvi.register_action(action_llm_append_to_messages)
pipeline = Pipeline(
[
transport.input(),
rtvi,
stt,
context_aggregator.user(),
llm,
tts,
transport.output(),
context_aggregator.assistant(),
]
)
context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context)
task = PipelineTask(
pipeline,
params=PipelineParams(
allow_interruptions=True,
enable_metrics=True,
),
observers=[RTVIObserver(rtvi)],
)
action_llm_append_to_messages = create_action_llm_append_to_messages(context_aggregator)
rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
rtvi.register_action(action_llm_append_to_messages)
@rtvi.event_handler("on_client_ready")
async def on_client_ready(rtvi):
logger.info("Pipecat client ready.")
await rtvi.set_bot_ready()
pipeline = Pipeline(
[
transport.input(),
rtvi,
stt,
context_aggregator.user(),
llm,
tts,
transport.output(),
context_aggregator.assistant(),
]
)
# This block is frontend UI specific
# These messages are intended for small webrtc UI to only handle text
# https://github.com/pipecat-ai/small-webrtc-prebuilt
messages = {
"show_text_container": True,
"show_debug_container": False,
}
rtvi_frame = RTVIServerMessageFrame(data=messages)
await task.queue_frames([rtvi_frame])
task = PipelineTask(
pipeline,
params=PipelineParams(
allow_interruptions=True,
enable_metrics=True,
),
observers=[RTVIObserver(rtvi)],
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected: {client}")
# Kick off the conversation.
await task.queue_frames([context_aggregator.user().get_context_frame()])
@rtvi.event_handler("on_client_ready")
async def on_client_ready(rtvi):
logger.info("Pipecat client ready.")
await rtvi.set_bot_ready()
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info(f"Client disconnected")
# This block is frontend UI specific
# These messages are intended for small webrtc UI to only handle text
# https://github.com/pipecat-ai/small-webrtc-prebuilt
messages = {
"show_text_container": True,
"show_debug_container": False,
}
rtvi_frame = RTVIServerMessageFrame(data=messages)
await task.queue_frames([rtvi_frame])
@transport.event_handler("on_client_closed")
async def on_client_closed(transport, client):
logger.info(f"Client closed connection")
await task.cancel()
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected: {client}")
# Kick off the conversation.
await task.queue_frames([context_aggregator.user().get_context_frame()])
runner = PipelineRunner(handle_sigint=False)
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info(f"Client disconnected")
@transport.event_handler("on_client_closed")
async def on_client_closed(transport, client):
logger.info(f"Client closed connection")
await task.cancel()
runner = PipelineRunner(handle_sigint=False)
await runner.run(task)
await runner.run(task)
if __name__ == "__main__":

View File

@@ -0,0 +1,125 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import MinWordsInterruptionStrategy
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.processors.transcript_processor import TranscriptProcessor
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.network.fastapi_websocket import FastAPIWebsocketParams
from pipecat.transports.services.daily import DailyParams
load_dotenv(override=True)
# We store functions so objects (e.g. SileroVADAnalyzer) don't get
# instantiated. The function will be called when the desired transport gets
# selected.
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
"twilio": lambda: FastAPIWebsocketParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
vad_analyzer=SileroVADAnalyzer(),
),
}
async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_sigint: bool):
logger.info(f"Starting bot")
stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY"))
tts = CartesiaTTSService(
api_key=os.getenv("CARTESIA_API_KEY"),
voice_id="71a7ad14-091c-4e8e-a314-022ece01c121", # British Reading Lady
)
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
transcript = TranscriptProcessor()
messages = [
{
"role": "system",
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way.",
},
]
context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context)
pipeline = Pipeline(
[
transport.input(), # Transport user input
stt,
transcript.user(), # User transcripts
context_aggregator.user(), # User responses
llm, # LLM
tts, # TTS
transport.output(), # Transport bot output
context_aggregator.assistant(), # Assistant spoken responses
]
)
task = PipelineTask(
pipeline,
params=PipelineParams(
allow_interruptions=True,
enable_metrics=True,
enable_usage_metrics=True,
report_only_initial_ttfb=True,
interruption_strategies=[MinWordsInterruptionStrategy(min_words=3)],
),
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info(f"Client connected")
# Kick off the conversation.
messages.append({"role": "system", "content": "Please introduce yourself to the user."})
await task.queue_frames([context_aggregator.user().get_context_frame()])
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info(f"Client disconnected")
await task.cancel()
# Register event handler for transcript updates
@transcript.event_handler("on_transcript_update")
async def on_transcript_update(processor, frame):
for message in frame.messages:
logger.info(f"Transcription [{message.role}]: {message.content}")
runner = PipelineRunner(handle_sigint=handle_sigint)
await runner.run(task)
if __name__ == "__main__":
from pipecat.examples.run import main
main(run_example, transport_params=transport_params)

View File

@@ -1,5 +1,5 @@
fastapi
uvicorn
python-dotenv
pipecat-ai[webrtc,deepgram,cartesia]
pipecat-ai[webrtc,daily,deepgram,cartesia]
pipecat-ai-small-webrtc-prebuilt

View File

@@ -49,7 +49,6 @@ if IS_TRACING_ENABLED:
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -93,6 +92,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -46,7 +46,6 @@ if IS_TRACING_ENABLED:
async def fetch_weather_from_api(params: FunctionCallParams):
await params.llm.push_frame(TTSSpeakFrame("Let me check on that."))
await params.result_callback({"conditions": "nice", "temperature": "75"})
@@ -90,6 +89,10 @@ async def run_example(transport: BaseTransport, _: argparse.Namespace, handle_si
# sent to the same callback with an additional function_name parameter.
llm.register_function("get_current_weather", fetch_weather_from_api)
@llm.event_handler("on_function_calls_started")
async def on_function_calls_started(service, function_calls):
await tts.queue_frame(TTSSpeakFrame("Let me check on that."))
weather_function = FunctionSchema(
name="get_current_weather",
description="Get the current weather",

View File

@@ -74,7 +74,7 @@ async def offer(request: dict, background_tasks: BackgroundTasks):
@asynccontextmanager
async def lifespan(app: FastAPI):
yield # Run app
coros = [pc.close() for pc in pcs_map.values()]
coros = [pc.disconnect() for pc in pcs_map.values()]
await asyncio.gather(*coros)
pcs_map.clear()

View File

@@ -2,7 +2,7 @@
accompanistPermissions = "0.34.0"
agp = "8.7.3"
constraintlayoutCompose = "1.1.0"
pipecatClient = "0.3.6"
pipecatClient = "0.3.7"
kotlin = "2.0.20"
coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"

View File

@@ -122,8 +122,8 @@ async def run_bot(webrtc_connection):
pipeline,
params=PipelineParams(
allow_interruptions=True,
observers=[RTVIObserver(rtvi)],
),
observers=[RTVIObserver(rtvi)],
)
@rtvi.event_handler("on_client_ready")

View File

@@ -74,7 +74,7 @@ async def offer(request: dict, background_tasks: BackgroundTasks):
@asynccontextmanager
async def lifespan(app: FastAPI):
yield # Run app
coros = [pc.close() for pc in pcs_map.values()]
coros = [pc.disconnect() for pc in pcs_map.values()]
await asyncio.gather(*coros)
pcs_map.clear()

View File

@@ -69,7 +69,7 @@ async def serve_index():
@asynccontextmanager
async def lifespan(app: FastAPI):
yield # Run app
coros = [pc.close() for pc in pcs_map.values()]
coros = [pc.disconnect() for pc in pcs_map.values()]
await asyncio.gather(*coros)
pcs_map.clear()

View File

@@ -8,505 +8,31 @@
This repository contains examples for building intelligent phone chatbots using AI for various use cases including:
- **Simple dial-in**: Basic incoming call handling
- **Simple dial-out**: Basic outgoing call handling
- **Voicemail detection**: Bot calls a number, detects if it reaches voicemail or a human, and responds appropriately
- **Call transfer**: Bot handles initial customer interaction and transfers to a human operator when needed
- **daily-pstn-dial-in**: Basic incoming call handling
- **daily-pstn-dial-out**: Basic outgoing call handling
- **daily-twilio-sip-dial-in**: Basic incoming call handling using Daily SIP + Twilio
- **daily-twilio-sip-dial-out**: Basic outgoing call handling using Daily SIP + Twilio
- **daily-pstn-simple-voicemail-detection**: Voicemail detection Bot. Bot calls a number, detects if it reaches voicemail or a human, and responds appropriately
- **daily-pstn-advanced-voicemail-detection**: A more advanced example of the voicemail detection bot. Utilises multiple pipelines. The first pipeline uses a much simpler, faster and cheaper LLM to detect the voicemail machine. Then switches to a more powerful LLM if it needs to have a conversation with a user. You should use this one if you want to use different LLMs for different tasks, it also shows how to do audio input for one LLM (multimodal LLM) and then STT for the other one. Switching out methods of sending data to the LLM
- **daily-pstn-simple-call-transfer**: Bot handles initial customer interaction and transfers to a human operator when needed
## Architecture Overview
These examples use the following components:
- 🔁 **Transport**: Daily WebRTC
- 💬 **Speech-to-Text**: Deepgram via Daily transport
- 💬 **Speech-to-Text**: Deepgram via Daily transport, or via separate Deepgram service
- 🤖 **LLMs**: Each example uses a specific LLM (OpenAI GPT-4o or Google Gemini)
- 📞 **SIP/PSTN**: Examples either use Daily PSTN or SIP with a SIP provider such as Twilio
- 🔉 **Text-to-Speech**: Cartesia
## Getting Started
### Prerequisites
1. Create and activate a virtual environment:
```shell
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
2. Install requirements:
```shell
pip install -r requirements.txt
```
3. Set up your environment variables:
```shell
cp env.example .env
```
Edit the `.env` file to include your API keys.
4. Install [ngrok](https://ngrok.com/) to make your local server accessible to external services.
### Phone Number Provider: Daily vs Twilio
If you're starting from scratch, we recommend using Daily to provision phone numbers alongside Daily as a transport for simplicity (this provides automatic call forwarding).
If you already have Twilio numbers and workflows, you can connect them to your Pipecat bots with some additional configuration (`on_dialin_ready` and using the Twilio client to trigger forwarding).
Most examples in this repository show how to use Daily for dial-in/dial-out operations.
## Running the Examples
### 1. Start the Bot Runner Service
The bot runner handles incoming requests and manages bot processes:
```shell
python bot_runner.py --host localhost
```
### 2. Create a Public Endpoint with ngrok
Start ngrok to create a public URL for your local server:
```shell
ngrok http --domain yourdomain.ngrok.app 7860
```
## Example 1: Simple Dial-in
This example demonstrates basic handling of incoming calls without additional features like call transfer.
### Testing in Daily Prebuilt (No Actual Phone Calls)
```shell
curl -X POST "http://localhost:7860/start" \
-H "Content-Type: application/json" \
-d '{
"config": {
"simple_dialin": {
"testInPrebuilt": true
}
}
}'
```
This returns a Daily room URL where you can test the bot's basic conversation capabilities.
## Example 2: Simple Dial-out
This example demonstrates basic handling of outgoing calls without additional features like voicemail detection.
### Testing in Daily Prebuilt (No Actual Phone Calls)
```shell
curl -X POST "http://localhost:7860/start" \
-H "Content-Type: application/json" \
-d '{
"config": {
"simple_dialout": {
"testInPrebuilt": true
}
}
}'
```
This returns a Daily room URL where you can test the bot's basic conversation capabilities.
### Making Actual Phone Calls
```shell
curl -X POST "http://localhost:7860/start" \
-H "Content-Type: application/json" \
-d '{
"config": {
"dialout_settings": [{
"phoneNumber": "+12345678910"
}],
"simple_dialout": {
"testInPrebuilt": false
}
}
}'
```
## Example 3: Voicemail Detection
This example demonstrates a bot that can dial out to a phone number, detect whether it reached a human or voicemail system, and respond appropriately.
### How It Works
1. Bot dials a phone number
2. Bot listens to determine if it's connected to a person or voicemail
3. If it detects voicemail, it leaves a predefined message and hangs up
4. If it detects a human, it engages in conversation
### Testing in Daily Prebuilt (No Actual Phone Calls)
To test without making actual phone calls:
```shell
curl -X POST "http://localhost:7860/start" \
-H "Content-Type: application/json" \
-d '{
"config": {
"voicemail_detection": {
"testInPrebuilt": true
}
}
}'
```
This will return a Daily room URL you can use to test the bot in the browser.
### Making Actual Phone Calls
To have the bot dial out to a real phone number:
```shell
curl -X POST "http://localhost:7860/start" \
-H "Content-Type: application/json" \
-d '{
"config": {
"dialout_settings": [{
"phoneNumber": "+12345678910"
}],
"voicemail_detection": {
"testInPrebuilt": false
}
}
}'
```
> **Note:** To enable dial-out capabilities, you must first:
>
> 1. Contact [help@daily.co](mailto:help@daily.co) to enable dial-out for your domain
> 2. Purchase a phone number to dial out from
> 3. Ensure rooms have dial-out enabled (the bot runner handles this)
> 4. Use an owner token for the bot (also handled by the bot runner)
## Example 4: Call Transfer
This example demonstrates a bot that handles initial customer interaction and can transfer the call to a human operator when requested.
### How It Works
1. Customer calls in and speaks with the bot
2. When the customer asks for a supervisor/manager, the bot initiates a transfer
3. The bot dials out to an appropriate operator
4. When the operator joins, the bot summarizes the conversation
5. The bot remains silent while operator and customer talk
6. When the operator leaves, the bot resumes handling the call
### Testing in Daily Prebuilt (No Actual Phone Calls)
```shell
curl -X POST "http://localhost:7860/start" \
-H "Content-Type: application/json" \
-d '{
"config": {
"call_transfer": {
"mode": "dialout",
"speakSummary": true,
"storeSummary": false,
"operatorNumber": "+12345678910",
"testInPrebuilt": true
}
}
}'
```
This returns a Daily room URL. In the room, the expected flow is:
1. Join the room and speak with the bot
2. Ask to speak with a manager/supervisor
3. The bot will add the "operator" to the call
4. The bot will summarize the conversation and then go silent
5. To simulate the operator, you can mute yourself in Daily Prebuilt and speak as if you're the operator
6. When finished, have the "operator" leave the call
7. The bot will resume speaking and can recall details from the conversation
8. End the call by closing Daily Prebuilt or telling the bot you're done
### Using with Real Phone Calls
For incoming calls from customers, Daily will send a webhook to your `/start` endpoint. This webhook contains:
```json
{
"From": "+CALLERS_PHONE",
"To": "$PURCHASED_PHONE",
"callId": "callid-read-only-string",
"callDomain": "callDomain-read-only-string"
}
```
The system will:
1. Identify the customer based on their phone number
2. Determine the appropriate operator to contact
3. Customize the bot's behavior based on transfer settings
#### Operator Assignment
The `call_connection_manager.py` file contains mappings for:
1. `CUSTOMER_MAP`: Links phone numbers to customer names
2. `OPERATOR_CONTACT_MAP`: Contains operator contact information
3. `CUSTOMER_TO_OPERATOR_MAP`: Defines which operators should handle which customers
You can customize these mappings or integrate with your existing customer database.
## Configuration Options
### Request Body Structure
When making requests to the `/start` endpoint, the config object can include:
```json
{
"config": {
"prompts": [
{
"name": "call_transfer_initial_prompt",
"text": "Your custom prompt here"
},
{
"name": "call_transfer_prompt",
"text": "Your custom prompt here"
},
{
"name": "call_transfer_finished_prompt",
"text": "Your custom prompt here"
},
{
"name": "voicemail_detection_prompt",
"text": "Your custom prompt here"
},
{
"name": "voicemail_prompt",
"text": "Your custom prompt here"
},
{
"name": "human_conversation_prompt",
"text": "Your custom prompt here"
}
],
"dialin_settings": {
"From": "+CALLERS_PHONE",
"To": "$PURCHASED_PHONE",
"callId": "callid-read-only-string",
"callDomain": "callDomain-read-only-string"
},
"dialout_settings": [
{
"phoneNumber": "+12345678910",
"callerId": "caller-id-uuid",
"sipUri": "sip:maria@example.com"
}
],
"call_transfer": {
"mode": "dialout",
"speakSummary": true,
"storeSummary": false,
"operatorNumber": "+12345678910",
"testInPrebuilt": false
},
"voicemail_detection": {
"testInPrebuilt": true
},
"simple_dialin": {
"testInPrebuilt": true
},
"simple_dialout": {
"testInPrebuilt": true
}
}
}
```
### Configuration Parameters
- `prompts`: An array of objects containing prompts that you want the examples to use.
- `dialin_settings`: Information about incoming calls (typically from webhook)
- `dialout_settings`: For outbound calls:
- `phoneNumber`: Number to dial
- `callerId`: UUID of the number to display (optional)
- `sipUri`: SIP URI to connect to (alternative to phoneNumber)
- `call_transfer`: For call transfer example:
- `mode`: Currently only `"dialout"` is supported
- `speakSummary`: Whether the bot should summarize the conversation for the operator
- `storeSummary`: For future implementation
- `operatorNumber`: Operator phone number
- `testInPrebuilt`: Test without actual phone calls
- `voicemail_detection`: For voicemail detection example:
- `testInPrebuilt`: Test without actual phone calls
- `simple_dialin`: For simple dialin example:
- `testInPrebuilt`: Test without actual phone calls
- `simple_dialout`: For simple dialout example:
- `testInPrebuilt`: Test without actual phone calls
## Feature Compatibility
The following table shows which feature combinations are supported when making requests to the `/start` endpoint. The table is organized by use case to help you create the correct configuration.
| Use Case | `call_transfer` | `voicemail_detection` | `simple_dialin` | `simple_dialout` | `dialin_settings` | `dialout_settings` | `operatorNumber` | `testInPrebuilt` | Status |
| --------------------------------------------------------------- | --------------- | --------------------- | --------------- | ---------------- | ----------------- | ------------------ | ---------------- | ---------------- | ---------------- |
| **Basic incoming call handling (simple_dialin)** | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ | ✗ | ✅ Supported |
| **Test mode: Simple dialin in Daily Prebuilt** | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✓ | ✅ Supported |
| **Basic outgoing call handling (simple_dialout)** | ✗ | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ | ✅ Supported |
| **Test mode: Simple dialout in Daily Prebuilt** | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ | ✅ Supported |
| **Standard call transfer (incoming call)** | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✓/✗ | ✗ | ✅ Supported |
| **Standard voicemail detection (outgoing call)** | ✗ | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✅ Supported |
| **Test mode: Call transfer in Daily Prebuilt** | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✓ | ✅ Supported |
| **Test mode: Voicemail detection in Daily Prebuilt** | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✅ Supported |
| Call transfer requires operatorNumber | ✓ | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓/✗ | ❌ Not Supported |
| Voicemail detection requires dialout_settings or testInPrebuilt | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓/✗ | ❌ Not Supported |
| Cannot combine different bot types | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ | ✓/✗ | ❌ Not Supported |
| Call_transfer needs dialin_settings in non-test mode | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ | ✗ | ❌ Not Supported |
| Voicemail_detection needs dialout_settings in non-test mode | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ❌ Not Supported |
| Insufficient configuration | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓/✗ | ❌ Not Supported |
### Legend:
- ✓: Required
- ✗: Not allowed
- ✓/✗: Optional
- ✅: Supported
- ❌: Not Supported
### Notes:
- `dialin_settings` is typically populated automatically from webhook data for incoming calls
- `dialout_settings` must be specified manually for outgoing calls
- `operatorNumber` is specified within the `call_transfer` object (`"call_transfer": {"operatorNumber": "+1234567890", ...}`)
- `testInPrebuilt` is specified within the bot type object (e.g., `"call_transfer": {"testInPrebuilt": true, ...}`)
- For call transfers, `operatorNumber` must be provided to specify which operator to dial. If it is not provided, we will base it off of the operator map in call_connection_manager.py
- In test mode (`testInPrebuilt: true`), some requirements are relaxed to allow testing in Daily Prebuilt
- Multiple customers to dial out to can be specified by providing an array of objects in `dialout_settings`
- Bot types are mutually exclusive - you cannot combine multiple bot types in a single configuration
### Configuration Examples
#### Standard call transfer (incoming call):
```json
{
"config": {
"dialin_settings": {
"from": "+12345678901",
"to": "+19876543210",
"call_id": "call-id-string",
"call_domain": "domain-string"
},
"call_transfer": {
"mode": "dialout",
"speakSummary": true,
"operatorNumber": "+12345678910"
}
}
}
```
#### Test mode: Call transfer in Daily Prebuilt:
```json
{
"config": {
"call_transfer": {
"mode": "dialout",
"speakSummary": true,
"operatorNumber": "+12345678910",
"testInPrebuilt": true
}
}
}
```
#### Test mode: Voicemail detection in Daily Prebuilt:
```json
{
"config": {
"voicemail_detection": {
"testInPrebuilt": true
}
}
}
```
#### Standard voicemail detection:
```json
{
"config": {
"dialout_settings": [
{
"phoneNumber": "+12345678910"
}
],
"voicemail_detection": {
"testInPrebuilt": false
}
}
}
```
#### Simple dialin (incoming call):
```json
{
"config": {
"dialin_settings": {
"from": "+12345678901",
"to": "+19876543210",
"call_id": "call-id-string",
"call_domain": "domain-string"
},
"simple_dialin": {}
}
}
```
#### Test mode: Simple dialin in Daily Prebuilt:
```json
{
"config": {
"simple_dialin": {
"testInPrebuilt": true
}
}
}
```
#### Simple dialout (outgoing call):
```json
{
"config": {
"dialout_settings": [
{
"phoneNumber": "+12345678910"
}
],
"simple_dialout": {}
}
}
```
#### Test mode: Simple dialout in Daily Prebuilt:
```json
{
"config": {
"simple_dialout": {
"testInPrebuilt": true
}
}
}
```
The Twilio dial-out example shows you how to configure the SIP URI domain and TwiML bins.
## Deployment
@@ -518,61 +44,24 @@ We also have a great, easy to use quickstart guide here: https://docs.pipecat.da
Each example in this repository is implemented with a specific LLM provider:
- **Simple dial-in**: Uses OpenAI
- **Simple dial-out**: Uses OpenAI
- **Voicemail detection**: Uses Google Gemini
- **Call transfer**: Uses OpenAI
- **daily-pstn-dial-in**: Uses OpenAI
- **daily-pstn-dial-out**: Uses OpenAI
- **daily-twilio-sip-dial-in**: Uses OpenAI
- **daily-twilio-sip-dial-out**: Uses OpenAI
- **daily-pstn-simple-voicemail-detection**: Uses Google Gemini Flash 2.0
- **daily-pstn-simple-voicemail-detection**: Uses Google Gemini Flash Lite 2.0 and Flash 2.0
- **daily-pstn-simple-call-transfer**: Uses OpenAI
If you want to implement one of these examples with a different LLM provider than what's provided:
- To implement **call_transfer** with **Gemini**, reference the `voicemail_detection.py` file for how to structure LLM context, function calling, and other Gemini-specific implementations.
- To implement **voicemail_detection** with **OpenAI**, reference the `call_transfer.py` file for OpenAI-specific implementation details.
- To implement **call_transfer** with **Gemini**, reference the `bot.py` file inside the voicemail detection example for how to structure LLM context, function calling, and other Gemini-specific implementations.
- To implement **voicemail_detection** with **OpenAI**, reference the `bot.py` file inside the call_transfer example for OpenAI-specific implementation details.
The key differences between implementations involve how context is managed, function calling syntax, and message formatting. Looking at both implementations side-by-side provides a good template for adapting any example to your preferred LLM provider.
## Customizing Bot Prompts
All examples include default prompts that work well for standard use cases. However, you can customize how the bot behaves by providing your own prompts in the request body.
### Available Prompt Types
- `call_transfer_initial_prompt`: The initial prompt the bot uses when greeting a customer
- `call_transfer_prompt`: Instructions for the bot when summarizing the conversation for an operator
- `call_transfer_finished_prompt`: Instructions for when the operator leaves the call
- `voicemail_detection_prompt`: Instructions for detecting whether a call connected to voicemail
- `voicemail_prompt`: The message to leave when voicemail is detected
- `human_conversation_prompt`: Instructions for conversation when a human is detected
### Customization Example
```shell
curl -X POST "http://localhost:7860/start" \
-H "Content-Type: application/json" \
-d '{
"config": {
"prompts": [
{
"name": "voicemail_prompt",
"text": "Hello, this is ACME Corporation calling. Please call us back at 555-123-4567 regarding your recent order. Thank you!"
}
],
"dialout_settings": [{
"phoneNumber": "+12345678910"
}],
"voicemail_detection": {
"testInPrebuilt": false
}
}
}'
```
This example would use all default prompts except for the voicemail message, which would be replaced with your custom message.
### Template Variables
Some prompts support template variables that are automatically replaced:
- `{customer_name}`: Will be replaced with the customer's name if available
All examples include default prompts that work well for standard use cases.
## Advanced Usage

View File

@@ -1,23 +0,0 @@
# bot_constants.py
"""Constants used across the bot runner application."""
# Maximum session time
MAX_SESSION_TIME = 5 * 60 # 5 minutes
# Required environment variables
REQUIRED_ENV_VARS = [
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"DAILY_API_KEY",
"CARTESIA_API_KEY",
"DEEPGRAM_API_KEY",
]
# Default example to use when handling dialin webhooks - determines which bot type to run
DEFAULT_DIALIN_EXAMPLE = "call_transfer" # Options: call_transfer, simple_dialin
# Call transfer configuration constants
DEFAULT_CALLTRANSFER_MODE = "dialout"
DEFAULT_SPEAK_SUMMARY = True # Speak a summary of the call to the operator
DEFAULT_STORE_SUMMARY = False # Store summary of the call (for future implementation)
DEFAULT_TEST_IN_PREBUILT = False # Test in prebuilt mode (bypasses need to dial in/out)

View File

@@ -1,55 +0,0 @@
# bot_definitions.py
"""Definitions of different bot types for the bot registry."""
from bot_registry import BotRegistry, BotType
from bot_runner_helpers import (
create_call_transfer_settings,
create_simple_dialin_settings,
create_simple_dialout_settings,
)
# Create and configure the bot registry
bot_registry = BotRegistry()
# Register bot types
bot_registry.register(
BotType(
name="call_transfer",
settings_creator=create_call_transfer_settings,
required_settings=["dialin_settings"],
incompatible_with=["simple_dialin", "simple_dialout", "voicemail_detection"],
auto_add_settings={"dialin_settings": {}},
)
)
bot_registry.register(
BotType(
name="simple_dialin",
settings_creator=create_simple_dialin_settings,
required_settings=["dialin_settings"],
incompatible_with=["call_transfer", "simple_dialout", "voicemail_detection"],
auto_add_settings={"dialin_settings": {}},
)
)
bot_registry.register(
BotType(
name="simple_dialout",
settings_creator=create_simple_dialout_settings,
required_settings=["dialout_settings"],
incompatible_with=["call_transfer", "simple_dialin", "voicemail_detection"],
auto_add_settings={"dialout_settings": [{}]},
)
)
bot_registry.register(
BotType(
name="voicemail_detection",
settings_creator=lambda body: body.get(
"voicemail_detection", {}
), # No creator function in original code
required_settings=["dialout_settings"],
incompatible_with=["call_transfer", "simple_dialin", "simple_dialout"],
auto_add_settings={"dialout_settings": [{}]},
)
)

View File

@@ -1,137 +0,0 @@
# bot_registry.py
"""Bot registry pattern for managing different bot types."""
from typing import Any, Callable, Dict, List, Optional
from bot_constants import DEFAULT_DIALIN_EXAMPLE
from bot_runner_helpers import ensure_dialout_settings_array
from fastapi import HTTPException
class BotType:
"""Bot type configuration and handling."""
def __init__(
self,
name: str,
settings_creator: Callable[[Dict[str, Any]], Dict[str, Any]],
required_settings: list = None,
incompatible_with: list = None,
auto_add_settings: dict = None,
):
"""Initialize a bot type.
Args:
name: Name of the bot type
settings_creator: Function to create/update settings for this bot type
required_settings: List of settings this bot type requires
incompatible_with: List of bot types this one cannot be used with
auto_add_settings: Settings to add if this bot is being run in test mode
"""
self.name = name
self.settings_creator = settings_creator
self.required_settings = required_settings or []
self.incompatible_with = incompatible_with or []
self.auto_add_settings = auto_add_settings or {}
def has_test_mode(self, body: Dict[str, Any]) -> bool:
"""Check if this bot type is configured for test mode."""
return self.name in body and body[self.name].get("testInPrebuilt", False)
def create_settings(self, body: Dict[str, Any]) -> Dict[str, Any]:
"""Create or update settings for this bot type."""
body[self.name] = self.settings_creator(body)
return body
def prepare_for_test(self, body: Dict[str, Any]) -> Dict[str, Any]:
"""Add required settings for test mode if they don't exist."""
for setting, default_value in self.auto_add_settings.items():
if setting not in body:
body[setting] = default_value
return body
class BotRegistry:
"""Registry for managing different bot types."""
def __init__(self):
self.bots = {}
self.bot_validation_rules = []
def register(self, bot_type: BotType):
"""Register a bot type."""
self.bots[bot_type.name] = bot_type
return self
def get_bot(self, name: str) -> BotType:
"""Get a bot type by name."""
return self.bots.get(name)
def detect_bot_type(self, body: Dict[str, Any]) -> Optional[str]:
"""Detect which bot type to use based on configuration."""
# First check for test mode bots
for name, bot in self.bots.items():
if bot.has_test_mode(body):
return name
# Then check for specific combinations of settings
for name, bot in self.bots.items():
if name in body and all(req in body for req in bot.required_settings):
return name
# Default for dialin settings
if "dialin_settings" in body:
return DEFAULT_DIALIN_EXAMPLE
return None
def validate_bot_combination(self, body: Dict[str, Any]) -> List[str]:
"""Validate that bot types in the configuration are compatible."""
errors = []
bot_types_in_config = [name for name in self.bots.keys() if name in body]
# Check each bot type against its incompatible list
for bot_name in bot_types_in_config:
bot = self.bots[bot_name]
for incompatible in bot.incompatible_with:
if incompatible in body:
errors.append(
f"Cannot have both '{bot_name}' and '{incompatible}' in the same configuration"
)
return errors
def setup_configuration(self, body: Dict[str, Any]) -> Dict[str, Any]:
"""Set up bot configuration based on detected bot type."""
# Ensure dialout_settings is an array if present
body = ensure_dialout_settings_array(body)
# Detect which bot type to use
bot_type_name = self.detect_bot_type(body)
if not bot_type_name:
raise HTTPException(
status_code=400, detail="Configuration doesn't match any supported scenario"
)
# If we have a dialin scenario but no explicit bot type, add the default
if "dialin_settings" in body and bot_type_name == DEFAULT_DIALIN_EXAMPLE:
if bot_type_name not in body:
body[bot_type_name] = {}
# Get the bot type object
bot_type = self.get_bot(bot_type_name)
# Create/update settings for the bot type
body = bot_type.create_settings(body)
# If in test mode, add any required settings
if bot_type.has_test_mode(body):
body = bot_type.prepare_for_test(body)
# Validate bot combinations
errors = self.validate_bot_combination(body)
if errors:
error_message = "Invalid configuration: " + "; ".join(errors)
raise HTTPException(status_code=400, detail=error_message)
return body

View File

@@ -1,247 +0,0 @@
import argparse
import json
import os
import shlex
import subprocess
from contextlib import asynccontextmanager
from typing import Any, Dict
import aiohttp
from bot_constants import (
MAX_SESSION_TIME,
REQUIRED_ENV_VARS,
)
from bot_definitions import bot_registry
from bot_runner_helpers import (
determine_room_capabilities,
ensure_prompt_config,
process_dialin_request,
)
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pipecat.transports.services.helpers.daily_rest import (
DailyRESTHelper,
DailyRoomParams,
DailyRoomProperties,
DailyRoomSipParams,
)
load_dotenv(override=True)
daily_helpers = {}
# ----------------- Daily Room Management ----------------- #
async def create_daily_room(room_url: str = None, config_body: Dict[str, Any] = None):
"""Create or retrieve a Daily room with appropriate properties based on the configuration.
Args:
room_url: Optional existing room URL
config_body: Optional configuration that determines room capabilities
Returns:
Dict containing room URL, token, and SIP endpoint
"""
if not room_url:
# Get room capabilities based on the configuration
capabilities = determine_room_capabilities(config_body)
# Configure SIP parameters if dialin is needed
sip_params = None
if capabilities["enable_dialin"]:
sip_params = DailyRoomSipParams(
display_name="dialin-user", video=False, sip_mode="dial-in", num_endpoints=2
)
# Create the properties object with the appropriate settings
properties = DailyRoomProperties(sip=sip_params)
# Set dialout capability if needed
if capabilities["enable_dialout"]:
properties.enable_dialout = True
# Log the capabilities being used
capability_str = ", ".join([f"{k}={v}" for k, v in capabilities.items()])
print(f"Creating room with capabilities: {capability_str}")
params = DailyRoomParams(properties=properties)
print("Creating new room...")
room = await daily_helpers["rest"].create_room(params=params)
else:
# Check if passed room URL exists
try:
room = await daily_helpers["rest"].get_room_from_url(room_url)
except Exception:
raise HTTPException(status_code=500, detail=f"Room not found: {room_url}")
print(f"Daily room: {room.url} {room.config.sip_endpoint}")
# Get token for the agent
token = await daily_helpers["rest"].get_token(room.url, MAX_SESSION_TIME)
if not room or not token:
raise HTTPException(status_code=500, detail="Failed to get room or token")
return {"room": room.url, "token": token, "sip_endpoint": room.config.sip_endpoint}
# ----------------- Bot Process Management ----------------- #
async def start_bot(room_details: Dict[str, str], body: Dict[str, Any], example: str) -> bool:
"""Start a bot process with the given configuration.
Args:
room_details: Room URL and token
body: Bot configuration
example: Example script to run
Returns:
Boolean indicating success
"""
room_url = room_details["room"]
token = room_details["token"]
# Properly format body as JSON string for command line
body_json = json.dumps(body).replace('"', '\\"')
print(f"++++ Body JSON: {body_json}")
# Modified to use non-LLM-specific bot module names
bot_proc = f'python3 -m {example} -u {room_url} -t {token} -b "{body_json}"'
print(f"Starting bot. Example: {example}, Room: {room_url}")
try:
command_parts = shlex.split(bot_proc)
subprocess.Popen(command_parts, bufsize=1, cwd=os.path.dirname(os.path.abspath(__file__)))
return True
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to start subprocess: {e}")
# ----------------- API Setup ----------------- #
@asynccontextmanager
async def lifespan(app: FastAPI):
aiohttp_session = aiohttp.ClientSession()
daily_helpers["rest"] = DailyRESTHelper(
daily_api_key=os.getenv("DAILY_API_KEY", ""),
daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"),
aiohttp_session=aiohttp_session,
)
yield
await aiohttp_session.close()
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ----------------- API Endpoints ----------------- #
@app.post("/start")
async def handle_start_request(request: Request) -> JSONResponse:
"""Unified endpoint to handle bot configuration for different scenarios."""
# Get default room URL from environment
room_url = os.getenv("DAILY_SAMPLE_ROOM_URL", None)
try:
data = await request.json()
# Handle webhook test
if "test" in data:
return JSONResponse({"test": True})
# Handle direct dialin webhook from Daily
if all(key in data for key in ["From", "To", "callId", "callDomain"]):
body = await process_dialin_request(data)
# Handle body-based request
elif "config" in data:
# Use the registry to set up the bot configuration
body = bot_registry.setup_configuration(data["config"])
else:
raise HTTPException(status_code=400, detail="Invalid request format")
# Ensure prompt configuration
body = ensure_prompt_config(body)
# Detect which bot type to use
bot_type_name = bot_registry.detect_bot_type(body)
if not bot_type_name:
raise HTTPException(
status_code=400, detail="Configuration doesn't match any supported scenario"
)
# Create the Daily room
room_details = await create_daily_room(room_url, body)
# Start the bot
await start_bot(room_details, body, bot_type_name)
# Get the bot type
bot_type = bot_registry.get_bot(bot_type_name)
# Build the response
response = {"status": "Bot started", "bot_type": bot_type_name}
# Add room URL for test mode
if bot_type.has_test_mode(body):
response["room_url"] = room_details["room"]
# Remove llm_model from response as it's no longer relevant
if "llm" in body:
response["llm_provider"] = body["llm"] # Optionally keep track of provider
# Add dialout info for dialout scenarios
if "dialout_settings" in body and len(body["dialout_settings"]) > 0:
first_setting = body["dialout_settings"][0]
if "phoneNumber" in first_setting:
response["dialing_to"] = f"phone:{first_setting['phoneNumber']}"
elif "sipUri" in first_setting:
response["dialing_to"] = f"sip:{first_setting['sipUri']}"
return JSONResponse(response)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON in request body")
except Exception as e:
raise HTTPException(status_code=400, detail=f"Request processing error: {str(e)}")
# ----------------- Main ----------------- #
if __name__ == "__main__":
# Check environment variables
for env_var in REQUIRED_ENV_VARS:
if env_var not in os.environ:
raise Exception(f"Missing environment variable: {env_var}.")
parser = argparse.ArgumentParser(description="Pipecat Bot Runner")
parser.add_argument(
"--host", type=str, default=os.getenv("HOST", "0.0.0.0"), help="Host address"
)
parser.add_argument("--port", type=int, default=os.getenv("PORT", 7860), help="Port number")
parser.add_argument("--reload", action="store_true", default=True, help="Reload code on change")
config = parser.parse_args()
try:
import uvicorn
uvicorn.run("bot_runner:app", host=config.host, port=config.port, reload=config.reload)
except KeyboardInterrupt:
print("Pipecat runner shutting down...")

View File

@@ -1,211 +0,0 @@
# bot_runner_helpers.py
from typing import Any, Dict, Optional
from bot_constants import (
DEFAULT_CALLTRANSFER_MODE,
DEFAULT_DIALIN_EXAMPLE,
DEFAULT_SPEAK_SUMMARY,
DEFAULT_STORE_SUMMARY,
DEFAULT_TEST_IN_PREBUILT,
)
from call_connection_manager import CallConfigManager
# ----------------- Configuration Helpers ----------------- #
def determine_room_capabilities(config_body: Optional[Dict[str, Any]] = None) -> Dict[str, bool]:
"""Determine room capabilities based on the configuration.
This function examines the configuration to determine which capabilities
the Daily room should have enabled.
Args:
config_body: Configuration dictionary that determines room capabilities
Returns:
Dictionary of capability flags
"""
capabilities = {
"enable_dialin": False,
"enable_dialout": False,
# Add more capabilities here in the future as needed
}
if not config_body:
return capabilities
# Check for dialin capability
capabilities["enable_dialin"] = "dialin_settings" in config_body
# Check for dialout capability - needed for outbound calls or transfers
has_dialout_settings = "dialout_settings" in config_body
# Check if there's a transfer to an operator configured
has_call_transfer = "call_transfer" in config_body
# Enable dialout if any condition requires it
capabilities["enable_dialout"] = has_dialout_settings or has_call_transfer
return capabilities
def ensure_dialout_settings_array(body: Dict[str, Any]) -> Dict[str, Any]:
"""Ensures dialout_settings is an array of objects.
Args:
body: The configuration dictionary
Returns:
Updated configuration with dialout_settings as an array
"""
if "dialout_settings" in body:
# Convert to array if it's not already one
if not isinstance(body["dialout_settings"], list):
body["dialout_settings"] = [body["dialout_settings"]]
return body
def ensure_prompt_config(body: Dict[str, Any]) -> Dict[str, Any]:
"""Ensures the body has appropriate prompts settings, but doesn't add defaults.
Only makes sure the prompt section exists, allowing the bot script to handle defaults.
Args:
body: The configuration dictionary
Returns:
Updated configuration with prompt settings section
"""
if "prompts" not in body:
body["prompts"] = []
return body
def create_call_transfer_settings(body: Dict[str, Any]) -> Dict[str, Any]:
"""Create call transfer settings based on configuration and customer mapping.
Args:
body: The configuration dictionary
Returns:
Call transfer settings dictionary
"""
# Default transfer settings
transfer_settings = {
"mode": DEFAULT_CALLTRANSFER_MODE,
"speakSummary": DEFAULT_SPEAK_SUMMARY,
"storeSummary": DEFAULT_STORE_SUMMARY,
"testInPrebuilt": DEFAULT_TEST_IN_PREBUILT,
}
# If call_transfer already exists, merge the defaults with the existing settings
# This ensures all required fields exist while preserving user-specified values
if "call_transfer" in body:
existing_settings = body["call_transfer"]
# Update defaults with existing settings (existing values will override defaults)
for key, value in existing_settings.items():
transfer_settings[key] = value
else:
# No existing call_transfer - check if we have dialin settings for customer lookup
if "dialin_settings" in body:
# Create a temporary routing manager just for customer lookup
call_config_manager = CallConfigManager(body)
# Get caller info
caller_info = call_config_manager.get_caller_info()
from_number = caller_info.get("caller_number")
if from_number:
# Get customer name from phone number
customer_name = call_config_manager.get_customer_name(from_number)
# If we know the customer name, add it to the config for the bot to use
if customer_name:
transfer_settings["customerName"] = customer_name
return transfer_settings
def create_simple_dialin_settings(body: Dict[str, Any]) -> Dict[str, Any]:
"""Create simple dialin settings based on configuration.
Args:
body: The configuration dictionary
Returns:
Simple dialin settings dictionary
"""
# Default simple dialin settings
simple_dialin_settings = {
"testInPrebuilt": DEFAULT_TEST_IN_PREBUILT,
}
# If simple_dialin already exists, merge the defaults with the existing settings
if "simple_dialin" in body:
existing_settings = body["simple_dialin"]
# Update defaults with existing settings (existing values will override defaults)
for key, value in existing_settings.items():
simple_dialin_settings[key] = value
return simple_dialin_settings
def create_simple_dialout_settings(body: Dict[str, Any]) -> Dict[str, Any]:
"""Create simple dialout settings based on configuration.
Args:
body: The configuration dictionary
Returns:
Simple dialout settings dictionary
"""
# Default simple dialout settings
simple_dialout_settings = {
"testInPrebuilt": DEFAULT_TEST_IN_PREBUILT,
}
# If simple_dialout already exists, merge the defaults with the existing settings
if "simple_dialout" in body:
existing_settings = body["simple_dialout"]
# Update defaults with existing settings (existing values will override defaults)
for key, value in existing_settings.items():
simple_dialout_settings[key] = value
return simple_dialout_settings
async def process_dialin_request(data: Dict[str, Any]) -> Dict[str, Any]:
"""Process incoming dial-in request data to create a properly formatted body.
Converts camelCase fields received from webhook to snake_case format
for internal consistency across the codebase.
Args:
data: Raw dialin data from webhook
Returns:
Properly formatted configuration with snake_case keys
"""
# Create base body with dialin settings
body = {
"dialin_settings": {
"to": data.get("To", ""),
"from": data.get("From", ""),
"call_id": data.get("callId", data.get("CallSid", "")), # Convert to snake_case
"call_domain": data.get("callDomain", ""), # Convert to snake_case
}
}
# Use the global default to determine which example to run for dialin webhooks
example = DEFAULT_DIALIN_EXAMPLE
# Configure the bot based on the example
if example == "call_transfer":
# Create call transfer settings
body["call_transfer"] = create_call_transfer_settings(body)
elif example == "simple_dialin":
# Create simple dialin settings
body["simple_dialin"] = create_simple_dialin_settings(body)
return body

View File

@@ -1,608 +0,0 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""call_connection_manager.py.
Manages customer/operator relationships and call routing for voice bots.
Provides mapping between customers and operators, and functions for retrieving
contact information. Also includes call state management.
"""
import json
import os
from typing import Any, Dict, List, Optional
from loguru import logger
class CallFlowState:
"""State for tracking call flow operations and state transitions."""
def __init__(self):
# Operator-related state
self.dialed_operator = False
self.operator_connected = False
self.current_operator_index = 0
self.operator_dialout_settings = []
self.summary_finished = False
# Voicemail detection state
self.voicemail_detected = False
self.human_detected = False
self.voicemail_message_left = False
# Call termination state
self.call_terminated = False
self.participant_left_early = False
# Operator-related methods
def set_operator_dialed(self):
"""Mark that an operator has been dialed."""
self.dialed_operator = True
def set_operator_connected(self):
"""Mark that an operator has connected to the call."""
self.operator_connected = True
# Summary is not finished when operator first connects
self.summary_finished = False
def set_operator_disconnected(self):
"""Handle operator disconnection."""
self.operator_connected = False
self.summary_finished = False
def set_summary_finished(self):
"""Mark the summary as finished."""
self.summary_finished = True
def set_operator_dialout_settings(self, settings):
"""Set the list of operator dialout settings to try."""
self.operator_dialout_settings = settings
self.current_operator_index = 0
def get_current_dialout_setting(self):
"""Get the current operator dialout setting to try."""
if not self.operator_dialout_settings or self.current_operator_index >= len(
self.operator_dialout_settings
):
return None
return self.operator_dialout_settings[self.current_operator_index]
def move_to_next_operator(self):
"""Move to the next operator in the list."""
self.current_operator_index += 1
return self.get_current_dialout_setting()
# Voicemail detection methods
def set_voicemail_detected(self):
"""Mark that a voicemail system has been detected."""
self.voicemail_detected = True
self.human_detected = False
def set_human_detected(self):
"""Mark that a human has been detected (not voicemail)."""
self.human_detected = True
self.voicemail_detected = False
def set_voicemail_message_left(self):
"""Mark that a voicemail message has been left."""
self.voicemail_message_left = True
# Call termination methods
def set_call_terminated(self):
"""Mark that the call has been terminated by the bot."""
self.call_terminated = True
def set_participant_left_early(self):
"""Mark that a participant left the call early."""
self.participant_left_early = True
class SessionManager:
"""Centralized management of session IDs and state for all call participants."""
def __init__(self):
# Track session IDs of different participant types
self.session_ids = {
"operator": None,
"customer": None,
"bot": None,
# Add other participant types as needed
}
# References for easy access in processors that need mutable containers
self.session_id_refs = {
"operator": [None],
"customer": [None],
"bot": [None],
# Add other participant types as needed
}
# State object for call flow
self.call_flow_state = CallFlowState()
def set_session_id(self, participant_type, session_id):
"""Set the session ID for a specific participant type.
Args:
participant_type: Type of participant (e.g., "operator", "customer", "bot")
session_id: The session ID to set
"""
if participant_type in self.session_ids:
self.session_ids[participant_type] = session_id
# Also update the corresponding reference if it exists
if participant_type in self.session_id_refs:
self.session_id_refs[participant_type][0] = session_id
def get_session_id(self, participant_type):
"""Get the session ID for a specific participant type.
Args:
participant_type: Type of participant (e.g., "operator", "customer", "bot")
Returns:
The session ID or None if not set
"""
return self.session_ids.get(participant_type)
def get_session_id_ref(self, participant_type):
"""Get the mutable reference for a specific participant type.
Args:
participant_type: Type of participant (e.g., "operator", "customer", "bot")
Returns:
A mutable list container holding the session ID or None if not available
"""
return self.session_id_refs.get(participant_type)
def is_participant_type(self, session_id, participant_type):
"""Check if a session ID belongs to a specific participant type.
Args:
session_id: The session ID to check
participant_type: Type of participant (e.g., "operator", "customer", "bot")
Returns:
True if the session ID matches the participant type, False otherwise
"""
return self.session_ids.get(participant_type) == session_id
def reset_participant(self, participant_type):
"""Reset the state for a specific participant type.
Args:
participant_type: Type of participant (e.g., "operator", "customer", "bot")
"""
if participant_type in self.session_ids:
self.session_ids[participant_type] = None
if participant_type in self.session_id_refs:
self.session_id_refs[participant_type][0] = None
# Additional reset actions for specific participant types
if participant_type == "operator":
self.call_flow_state.set_operator_disconnected()
class CallConfigManager:
"""Manages customer/operator relationships and call routing."""
def __init__(self, body_data: Dict[str, Any] = None):
"""Initialize with optional body data.
Args:
body_data: Optional dictionary containing request body data
"""
self.body = body_data or {}
# Get environment variables with fallbacks
self.dial_in_from_number = os.getenv("DIAL_IN_FROM_NUMBER", "+10000000001")
self.dial_out_to_number = os.getenv("DIAL_OUT_TO_NUMBER", "+10000000002")
self.operator_number = os.getenv("OPERATOR_NUMBER", "+10000000003")
# Initialize maps with dynamic values
self._initialize_maps()
self._build_reverse_lookup_maps()
def _initialize_maps(self):
"""Initialize the customer and operator maps with environment variables."""
# Maps customer names to their contact information
self.CUSTOMER_MAP = {
"Dominic": {
"phoneNumber": self.dial_in_from_number, # I have two phone numbers, one for dialing in and one for dialing out. I give myself a separate name for each.
},
"Stewart": {
"phoneNumber": self.dial_out_to_number,
},
"James": {
"phoneNumber": "+10000000000",
"callerId": "james-caller-id-uuid",
"sipUri": "sip:james@example.com",
},
"Sarah": {
"sipUri": "sip:sarah@example.com",
},
"Michael": {
"phoneNumber": "+16505557890",
"callerId": "michael-caller-id-uuid",
},
}
# Maps customer names to their assigned operator names
self.CUSTOMER_TO_OPERATOR_MAP = {
"Dominic": ["Yunyoung", "Maria"], # Try Yunyoung first, then Maria
"Stewart": "Yunyoung",
"James": "Yunyoung",
"Sarah": "Jennifer",
"Michael": "Paul",
# Default mapping to ensure all customers have an operator
"Default": "Yunyoung",
}
# Maps operator names to their contact details
self.OPERATOR_CONTACT_MAP = {
"Paul": {
"phoneNumber": "+12345678904",
"callerId": "paul-caller-id-uuid",
},
"Yunyoung": {
"phoneNumber": self.operator_number, # Dials out to my other phone number.
},
"Maria": {
"sipUri": "sip:maria@example.com",
},
"Jennifer": {"phoneNumber": "+14155559876", "callerId": "jennifer-caller-id-uuid"},
"Default": {
"phoneNumber": self.operator_number, # Use the operator number as default
},
}
def _build_reverse_lookup_maps(self):
"""Build reverse lookup maps for phone numbers and SIP URIs to customer names."""
self._PHONE_TO_CUSTOMER_MAP = {}
self._SIP_TO_CUSTOMER_MAP = {}
for customer_name, contact_info in self.CUSTOMER_MAP.items():
if "phoneNumber" in contact_info:
self._PHONE_TO_CUSTOMER_MAP[contact_info["phoneNumber"]] = customer_name
if "sipUri" in contact_info:
self._SIP_TO_CUSTOMER_MAP[contact_info["sipUri"]] = customer_name
@classmethod
def from_json_string(cls, json_string: str):
"""Create a CallRoutingManager from a JSON string.
Args:
json_string: JSON string containing body data
Returns:
CallRoutingManager instance with parsed data
Raises:
json.JSONDecodeError: If JSON string is invalid
"""
body_data = json.loads(json_string)
return cls(body_data)
def find_customer_by_contact(self, contact_info: str) -> Optional[str]:
"""Find customer name from a contact identifier (phone number or SIP URI).
Args:
contact_info: The contact identifier (phone number or SIP URI)
Returns:
The customer name or None if not found
"""
# Check if it's a phone number
if contact_info in self._PHONE_TO_CUSTOMER_MAP:
return self._PHONE_TO_CUSTOMER_MAP[contact_info]
# Check if it's a SIP URI
if contact_info in self._SIP_TO_CUSTOMER_MAP:
return self._SIP_TO_CUSTOMER_MAP[contact_info]
return None
def get_customer_name(self, phone_number: str) -> Optional[str]:
"""Get customer name from their phone number.
Args:
phone_number: The customer's phone number
Returns:
The customer name or None if not found
"""
# Note: In production, this would likely query a database
return self.find_customer_by_contact(phone_number)
def get_operators_for_customer(self, customer_name: Optional[str]) -> List[str]:
"""Get the operator name(s) assigned to a customer.
Args:
customer_name: The customer's name
Returns:
List of operator names (single item or multiple)
"""
# Note: In production, this would likely query a database
if not customer_name or customer_name not in self.CUSTOMER_TO_OPERATOR_MAP:
return ["Default"]
operators = self.CUSTOMER_TO_OPERATOR_MAP[customer_name]
# Convert single string to list for consistency
if isinstance(operators, str):
return [operators]
return operators
def get_operator_dialout_settings(self, operator_name: str) -> Dict[str, str]:
"""Get an operator's dialout settings from their name.
Args:
operator_name: The operator's name
Returns:
Dictionary with dialout settings for the operator
"""
# Note: In production, this would likely query a database
return self.OPERATOR_CONTACT_MAP.get(operator_name, self.OPERATOR_CONTACT_MAP["Default"])
def get_dialout_settings_for_caller(
self, from_number: Optional[str] = None
) -> List[Dict[str, str]]:
"""Determine the appropriate operator dialout settings based on caller's number.
This method uses the caller's number to look up the customer name,
then finds the assigned operators for that customer, and returns
an array of operator dialout settings to try in sequence.
Args:
from_number: The caller's phone number (from dialin_settings)
Returns:
List of operator dialout settings to try
"""
if not from_number:
# If we don't have dialin settings, use the Default operator
return [self.get_operator_dialout_settings("Default")]
# Get customer name from phone number
customer_name = self.get_customer_name(from_number)
# Get operator names assigned to this customer
operator_names = self.get_operators_for_customer(customer_name)
# Get dialout settings for each operator
return [self.get_operator_dialout_settings(name) for name in operator_names]
def get_caller_info(self) -> Dict[str, Optional[str]]:
"""Get caller and dialed numbers from dialin settings in the body.
Returns:
Dictionary containing caller_number and dialed_number
"""
raw_dialin_settings = self.body.get("dialin_settings")
if not raw_dialin_settings:
return {"caller_number": None, "dialed_number": None}
# Handle different case variations
dialed_number = raw_dialin_settings.get("To") or raw_dialin_settings.get("to")
caller_number = raw_dialin_settings.get("From") or raw_dialin_settings.get("from")
return {"caller_number": caller_number, "dialed_number": dialed_number}
def get_caller_number(self) -> Optional[str]:
"""Get the caller's phone number from dialin settings in the body.
Returns:
The caller's phone number or None if not available
"""
return self.get_caller_info()["caller_number"]
async def start_dialout(self, transport, dialout_settings=None):
"""Helper function to start dialout using the provided settings or from body.
Args:
transport: The transport instance to use for dialout
dialout_settings: Optional override for dialout settings
Returns:
None
"""
# Use provided settings or get from body
settings = dialout_settings or self.get_dialout_settings()
if not settings:
logger.warning("No dialout settings available")
return
for setting in settings:
if "phoneNumber" in setting:
logger.info(f"Dialing number: {setting['phoneNumber']}")
if "callerId" in setting:
logger.info(f"with callerId: {setting['callerId']}")
await transport.start_dialout(
{"phoneNumber": setting["phoneNumber"], "callerId": setting["callerId"]}
)
else:
logger.info("with no callerId")
await transport.start_dialout({"phoneNumber": setting["phoneNumber"]})
elif "sipUri" in setting:
logger.info(f"Dialing sipUri: {setting['sipUri']}")
await transport.start_dialout({"sipUri": setting["sipUri"]})
else:
logger.warning(f"Unknown dialout setting format: {setting}")
def get_dialout_settings(self) -> Optional[List[Dict[str, Any]]]:
"""Extract dialout settings from the body.
Returns:
List of dialout setting objects or None if not present
"""
# Check if we have dialout settings
if "dialout_settings" in self.body:
dialout_settings = self.body["dialout_settings"]
# Convert to list if it's an object (for backward compatibility)
if isinstance(dialout_settings, dict):
return [dialout_settings]
elif isinstance(dialout_settings, list):
return dialout_settings
return None
def get_dialin_settings(self) -> Optional[Dict[str, Any]]:
"""Extract dialin settings from the body.
Handles both camelCase and snake_case variations of fields for backward compatibility,
but normalizes to snake_case for internal usage.
Returns:
Dictionary containing dialin settings or None if not present
"""
raw_dialin_settings = self.body.get("dialin_settings")
if not raw_dialin_settings:
return None
# Normalize dialin settings to handle different case variations
# Prioritize snake_case (call_id, call_domain) but fall back to camelCase (callId, callDomain)
dialin_settings = {
"call_id": raw_dialin_settings.get("call_id") or raw_dialin_settings.get("callId"),
"call_domain": raw_dialin_settings.get("call_domain")
or raw_dialin_settings.get("callDomain"),
"to": raw_dialin_settings.get("to") or raw_dialin_settings.get("To"),
"from": raw_dialin_settings.get("from") or raw_dialin_settings.get("From"),
}
return dialin_settings
# Bot prompt helper functions - no defaults provided, just return what's in the body
def get_prompt(self, prompt_name: str) -> Optional[str]:
"""Retrieve the prompt text for a given prompt name.
Args:
prompt_name: The name of the prompt to retrieve.
Returns:
The prompt string corresponding to the provided name, or None if not configured.
"""
prompts = self.body.get("prompts", [])
for prompt in prompts:
if prompt.get("name") == prompt_name:
return prompt.get("text")
return None
def get_transfer_mode(self) -> Optional[str]:
"""Get transfer mode from the body.
Returns:
Transfer mode string or None if not configured
"""
if "call_transfer" in self.body:
return self.body["call_transfer"].get("mode")
return None
def get_speak_summary(self) -> Optional[bool]:
"""Get speak summary from the body.
Returns:
Boolean indicating if summary should be spoken or None if not configured
"""
if "call_transfer" in self.body:
return self.body["call_transfer"].get("speakSummary")
return None
def get_store_summary(self) -> Optional[bool]:
"""Get store summary from the body.
Returns:
Boolean indicating if summary should be stored or None if not configured
"""
if "call_transfer" in self.body:
return self.body["call_transfer"].get("storeSummary")
return None
def is_test_mode(self) -> bool:
"""Check if running in test mode.
Returns:
Boolean indicating if test mode is enabled
"""
if "voicemail_detection" in self.body:
return bool(self.body["voicemail_detection"].get("testInPrebuilt"))
if "call_transfer" in self.body:
return bool(self.body["call_transfer"].get("testInPrebuilt"))
if "simple_dialin" in self.body:
return bool(self.body["simple_dialin"].get("testInPrebuilt"))
if "simple_dialout" in self.body:
return bool(self.body["simple_dialout"].get("testInPrebuilt"))
return False
def is_voicemail_detection_enabled(self) -> bool:
"""Check if voicemail detection is enabled in the body.
Returns:
Boolean indicating if voicemail detection is enabled
"""
return bool(self.body.get("voicemail_detection"))
def customize_prompt(self, prompt: str, customer_name: Optional[str] = None) -> str:
"""Insert customer name into prompt template if available.
Args:
prompt: The prompt template containing optional {customer_name} placeholders
customer_name: Optional customer name to insert
Returns:
Customized prompt with customer name inserted
"""
if customer_name and prompt:
return prompt.replace("{customer_name}", customer_name)
return prompt
def create_system_message(self, content: str) -> Dict[str, str]:
"""Create a properly formatted system message.
Args:
content: The message content
Returns:
Dictionary with role and content for the system message
"""
return {"role": "system", "content": content}
def create_user_message(self, content: str) -> Dict[str, str]:
"""Create a properly formatted user message.
Args:
content: The message content
Returns:
Dictionary with role and content for the user message
"""
return {"role": "user", "content": content}
def get_customer_info_suffix(
self, customer_name: Optional[str] = None, preposition: str = "for"
) -> str:
"""Create a consistent customer info suffix.
Args:
customer_name: Optional customer name
preposition: Preposition to use before the name (e.g., "for", "to", "")
Returns:
String with formatted customer info suffix
"""
if not customer_name:
return ""
# Add a space before the preposition if it's not empty
space_prefix = " " if preposition else ""
# For non-empty prepositions, add a space after it
space_suffix = " " if preposition else ""
return f"{space_prefix}{preposition}{space_suffix}{customer_name}"

View File

@@ -0,0 +1,122 @@
# Daily PSTN Advanced Voicemail Detection Bot
This project demonstrates how to create a voice bot that uses Dailys PSTN capabilities to make calls to phone numbers, and if the bot hits a voicemail system, to have the bot also leave a message. In this example, we have two pipelines. The voicemail detection pipeline uses Gemini Flash Lite, a fast and cheap LLM that works well for voicemail detection. The second pipeline uses Gemini Flash, a more advanced LLM model ideal for conversations.
## How it works
1. The server file receives a curl request with the phone number 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 rings the number provided in the curl request
5. When the phone is answered, the bot detects for certain key phrases
6. Gemini Flash Lite works best when given small, concise prompts. When a voicemail machine is detected, we switch to a new prompt focused on the message that must be left
7. Once the bot has left the message, it then ends the call
8. If the bot detects there's a human on the phone, the bot runs a function call and switches to the human conversation pipeline. We give the new LLM a prompt and tell the LLM to speak.
## Prerequisites
- A Daily account with an API key, and a phone number purchased through Daily
- A US phone number to ring
- dial-out must be enabled on your domain. Find out more by reading this [document and filling in the form](https://docs.daily.co/guides/products/dial-in-dial-out#main)
- Google 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. 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. Request dial-out enablement
For compliance reasons, to enable dial-out for your Daily account, you must request enablement via the form. You can find out more about dial-out, and the form at the [link here:](https://docs.daily.co/guides/products/dial-in-dial-out#main)
## 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": {
"phone_number": "+12345678910"
}
}'
```
The server should make a room. The bot will join the room and then ring the number provided. Answer the call to speak with the bot.
- You can pretend to be a voicemail machine by saying something like "Please leave a message after the beep... beeeeep".
- You should observe the bot detects the voicemail machine and leaves a message before terminating the call
- You can also say something like "Hello?", and the bot will notice you're likely a human and begin having a conversation with you
## 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 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 you have purchased a phone number to ring from
- Check that the phone number you are trying to ring is correct, and is a US or Canadian number.
### The bot doesn't detect my voicemail
- The bot should be smart enough to detect variations of certain patterns,
- If your voicemail machine doesn't follow the example patterns, add the pattern to the LLM prompt
### 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

@@ -6,11 +6,11 @@
import argparse
import asyncio
import functools
import json
import os
import sys
from typing import Any
from call_connection_manager import CallConfigManager, SessionManager
from dotenv import load_dotenv
from loguru import logger
@@ -50,6 +50,39 @@ daily_api_url = os.getenv("DAILY_API_URL", "https://api.daily.co/v1")
# ------------ HELPER CLASSES ------------
class CallFlowState:
"""State for tracking call flow operations and state transitions."""
def __init__(self):
# Voicemail detection state
self.voicemail_detected = False
self.human_detected = False
# Call termination state
self.call_terminated = False
self.participant_left_early = False
# Voicemail detection methods
def set_voicemail_detected(self):
"""Mark that a voicemail system has been detected."""
self.voicemail_detected = True
self.human_detected = False
def set_human_detected(self):
"""Mark that a human has been detected (not voicemail)."""
self.human_detected = True
self.voicemail_detected = False
# Call termination methods
def set_call_terminated(self):
"""Mark that the call has been terminated by the bot."""
self.call_terminated = True
def set_participant_left_early(self):
"""Mark that a participant left the call early."""
self.participant_left_early = True
class UserAudioCollector(FrameProcessor):
"""Collects audio frames in a buffer, then adds them to the LLM context when the user stops speaking."""
@@ -94,9 +127,8 @@ class UserAudioCollector(FrameProcessor):
class FunctionHandlers:
"""Handlers for the voicemail detection bot functions."""
def __init__(self, session_manager):
self.session_manager = session_manager
self.prompt = None # Can be set externally
def __init__(self, call_flow_state: CallFlowState):
self.call_flow_state = call_flow_state
async def voicemail_response(self, params: FunctionCallParams):
"""Function the bot can call to leave a voicemail message."""
@@ -109,49 +141,73 @@ class FunctionHandlers:
async def human_conversation(self, params: FunctionCallParams):
"""Function called when bot detects it's talking to a human."""
# Update state to indicate human was detected
self.session_manager.call_flow_state.set_human_detected()
self.call_flow_state.set_human_detected()
await params.llm.push_frame(StopTaskFrame(), FrameDirection.UPSTREAM)
# ------------ MAIN FUNCTION ------------
async def main(
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)
# Create a configuration manager from the provided body
call_config_manager = CallConfigManager.from_json_string(body) if body else CallConfigManager()
# Check if the body contains dial-in settings
logger.debug(f"Body data: {body_data}")
# Get important configuration values
dialout_settings = call_config_manager.get_dialout_settings()
test_mode = call_config_manager.is_test_mode()
if not body_data.get("dialout_settings"):
logger.error("Dial-out settings not found in the body data")
return
# Get caller info (might be None for dialout scenarios)
caller_info = call_config_manager.get_caller_info()
logger.info(f"Caller info: {caller_info}")
dialout_settings = body_data["dialout_settings"]
# Initialize the session manager
session_manager = SessionManager()
if not dialout_settings.get("phone_number"):
logger.error("Dial-out phone number not found in the dial-out settings")
return
# ------------ TRANSPORT AND SERVICES SETUP ------------
# Extract dial-out phone number
phone_number = dialout_settings["phone_number"]
caller_id = dialout_settings.get("caller_id") # Use .get() to handle optional field
# Initialize transport
if caller_id:
logger.info(f"Dial-out caller ID specified: {caller_id}")
else:
logger.info("Dial-out caller ID not specified; proceeding without it")
# ------------ 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,
"Voicemail Detection Bot",
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(),
),
transport_params,
)
# Initialize TTS
@@ -167,12 +223,12 @@ async def main(
async def terminate_call(
params: FunctionCallParams,
session_manager=None,
call_flow_state: CallFlowState = None,
):
"""Function the bot can call to terminate the call."""
if session_manager:
if call_flow_state:
# Set call terminated flag in the session manager
session_manager.call_flow_state.set_call_terminated()
call_flow_state.set_call_terminated()
await params.llm.queue_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
@@ -198,12 +254,7 @@ async def main(
}
]
# Get voicemail detection prompt
voicemail_detection_prompt = call_config_manager.get_prompt("voicemail_detection_prompt")
if voicemail_detection_prompt:
system_instruction = voicemail_detection_prompt
else:
system_instruction = """You are Chatbot trying to determine if this is a voicemail system or a human.
system_instruction = """You are Chatbot trying to determine if this is a voicemail system or a human.
If you hear any of these phrases (or very similar ones):
- "Please leave a message after the beep"
@@ -238,12 +289,9 @@ async def main(
voicemail_detection_context
)
# Get custom voicemail prompt if available
voicemail_prompt = call_config_manager.get_prompt("voicemail_prompt")
# Set up function handlers
handlers = FunctionHandlers(session_manager)
handlers.prompt = voicemail_prompt # Set custom prompt if available
call_flow_state = CallFlowState()
handlers = FunctionHandlers(call_flow_state)
# Register functions with the voicemail detection LLM
voicemail_detection_llm.register_function(
@@ -254,7 +302,7 @@ async def main(
"switch_to_human_conversation", handlers.human_conversation
)
voicemail_detection_llm.register_function(
"terminate_call", lambda params: terminate_call(params, session_manager)
"terminate_call", lambda params: terminate_call(params, call_flow_state)
)
# Set up audio collector for handling audio input
@@ -279,17 +327,41 @@ async def main(
voicemail_detection_pipeline_task = PipelineTask(
voicemail_detection_pipeline,
params=PipelineParams(allow_interruptions=True),
check_dangling_tasks=False,
)
# ------------ RETRY LOGIC VARIABLES ------------
max_retries = 5
retry_count = 0
dialout_successful = False
# Build dialout parameters conditionally
dialout_params = {"phoneNumber": phone_number}
if caller_id:
dialout_params["callerId"] = caller_id
logger.debug(f"Including caller ID in dialout: {caller_id}")
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: {phone_number}"
)
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 dialout if needed
if not test_mode and dialout_settings:
logger.debug("Dialout settings detected; starting dialout")
await call_config_manager.start_dialout(transport, dialout_settings)
# Start initial dialout attempt
logger.debug(f"Dialout settings detected; starting dialout to number: {phone_number}")
await attempt_dialout()
@transport.event_handler("on_dialout_connected")
async def on_dialout_connected(transport, data):
@@ -297,27 +369,36 @@ async def main(
@transport.event_handler("on_dialout_answered")
async def on_dialout_answered(transport, data):
nonlocal dialout_successful
logger.debug(f"Dial-out answered: {data}")
# Start capturing transcription
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 voicemail_detection_pipeline_task.queue_frame(EndFrame())
@transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(transport, participant):
logger.debug(f"First participant joined: {participant['id']}")
if test_mode:
await transport.capture_participant_transcription(participant["id"])
@transport.event_handler("on_participant_left")
async def on_participant_left(transport, participant, reason):
# Mark that a participant left early
session_manager.call_flow_state.set_participant_left_early()
call_flow_state.set_participant_left_early()
await voicemail_detection_pipeline_task.queue_frame(EndFrame())
# ------------ RUN VOICEMAIL DETECTION PIPELINE ------------
if test_mode:
logger.debug("Detect voicemail example. You can test this in Daily Prebuilt")
runner = PipelineRunner()
print("!!! starting voicemail detection pipeline")
@@ -331,24 +412,17 @@ async def main(
print("!!! Done with voicemail detection pipeline")
# Check if we should exit early
if (
session_manager.call_flow_state.participant_left_early
or session_manager.call_flow_state.call_terminated
):
if session_manager.call_flow_state.participant_left_early:
if call_flow_state.participant_left_early or call_flow_state.call_terminated:
if call_flow_state.participant_left_early:
print("!!! Participant left early; terminating call")
elif session_manager.call_flow_state.call_terminated:
elif call_flow_state.call_terminated:
print("!!! Bot terminated call; not proceeding to human conversation")
return
# ------------ HUMAN CONVERSATION PHASE SETUP ------------
# Get human conversation prompt
human_conversation_prompt = call_config_manager.get_prompt("human_conversation_prompt")
if human_conversation_prompt:
human_conversation_system_instruction = human_conversation_prompt
else:
human_conversation_system_instruction = """You are Chatbot talking to a human. Be friendly and helpful.
human_conversation_system_instruction = """You are Chatbot talking to a human. Be friendly and helpful.
Start with: "Hello! I'm a friendly chatbot. How can I help you today?"
@@ -378,7 +452,7 @@ async def main(
# Register terminate function with the human conversation LLM
human_conversation_llm.register_function(
"terminate_call", functools.partial(terminate_call, session_manager=session_manager)
"terminate_call", lambda params: terminate_call(params, call_flow_state)
)
# Build human conversation pipeline
@@ -412,7 +486,12 @@ async def main(
# Initialize the context with system message
human_conversation_context_aggregator.user().set_messages(
[call_config_manager.create_system_message(human_conversation_system_instruction)]
[
{
"role": "system",
"content": human_conversation_system_instruction,
}
]
)
# Queue the context frame to start the conversation
@@ -434,17 +513,26 @@ async def main(
# ------------ SCRIPT ENTRY POINT ------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Pipecat Voicemail Detection Bot")
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()
# Log the arguments for debugging
logger.info(f"Room URL: {args.url}")
logger.info(f"Token: {args.token}")
logger.info(f"Body provided: {bool(args.body)}")
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)
asyncio.run(main(args.url, args.token, args.body))
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
GOOGLE_API_KEY=your_google_api_key
CARTESIA_API_KEY=your_cartesia_api_key

View File

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

View File

@@ -0,0 +1,121 @@
#
# 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 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("phone_number"):
raise HTTPException(
status_code=400, detail="Missing 'phone_number' in dialout_settings"
)
# Extract the phone number we want to dial out to
caller_phone = str(data["dialout_settings"]["phone_number"])
print(f"Processing 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

View File

@@ -0,0 +1,124 @@
# Daily PSTN call transfer
A basic example of how to create a bot that handles the initial customer interaction and then transfers to a human operator when needed
## Architecture Overview
These examples use the following components:
- 🔁 **Transport**: Daily WebRTC
- 💬 **Speech-to-Text**: Deepgram via Daily transport
- 🤖 **LLMs**: Each example uses a specific LLM (OpenAI GPT-4o or Google Gemini)
- 🔉 **Text-to-Speech**: Cartesia
## 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
- One phone to dial-in from and another phone to receive calls when escalating to a manager
## 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
```
- Note, please specify an OPERATOR_NUMBER so that the bot can ring a number when escalating to a manager
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.
Have a short conversation with the bot, and then request to speak with a manager. The bot should then ring the manager. On your second phone, answer the call.
The bot will then summarise the conversation so far, and then silently listen to the conversation. You can now speak with the manager on the other phone.
When the manager hangs up the call, the bot will start speaking again. You can then ask the bot about the conversation with the manager, and it will have the context of the conversation.
## 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
)
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
### 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
### The bot does not escalate to the manager
- 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 phone number you are trying to ring is correct, and is a US or Canadian number.
### 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

@@ -5,10 +5,10 @@
#
import argparse
import asyncio
import json
import os
import sys
from call_connection_manager import CallConfigManager, SessionManager
from dotenv import load_dotenv
from loguru import logger
@@ -29,7 +29,7 @@ from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
from pipecat.processors.filters.function_filter import FunctionFilter
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.llm_service import FunctionCallParams, LLMService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.services.daily import DailyDialinSettings, DailyParams, DailyTransport
@@ -42,6 +42,124 @@ daily_api_key = os.getenv("DAILY_API_KEY", "")
daily_api_url = os.getenv("DAILY_API_URL", "https://api.daily.co/v1")
class SessionManager:
"""Centralized management of session IDs and state for all call participants."""
def __init__(self, call_flow_state=None):
# Track session IDs of different participant types
self.session_ids = {
"operator": None,
"customer": None,
"bot": None,
# Add other participant types as needed
}
# References for easy access in processors that need mutable containers
self.session_id_refs = {
"operator": [None],
"customer": [None],
"bot": [None],
# Add other participant types as needed
}
# Use the provided call_flow_state or create a new one
self.call_flow_state = call_flow_state if call_flow_state is not None else CallFlowState()
def set_session_id(self, participant_type, session_id):
"""Set the session ID for a specific participant type.
Args:
participant_type: Type of participant (e.g., "operator", "customer", "bot")
session_id: The session ID to set
"""
if participant_type in self.session_ids:
self.session_ids[participant_type] = session_id
# Also update the corresponding reference if it exists
if participant_type in self.session_id_refs:
self.session_id_refs[participant_type][0] = session_id
def get_session_id(self, participant_type):
"""Get the session ID for a specific participant type.
Args:
participant_type: Type of participant (e.g., "operator", "customer", "bot")
Returns:
The session ID or None if not set
"""
return self.session_ids.get(participant_type)
def get_session_id_ref(self, participant_type):
"""Get the mutable reference for a specific participant type.
Args:
participant_type: Type of participant (e.g., "operator", "customer", "bot")
Returns:
A mutable list container holding the session ID or None if not available
"""
return self.session_id_refs.get(participant_type)
def is_participant_type(self, session_id, participant_type):
"""Check if a session ID belongs to a specific participant type.
Args:
session_id: The session ID to check
participant_type: Type of participant (e.g., "operator", "customer", "bot")
Returns:
True if the session ID matches the participant type, False otherwise
"""
return self.session_ids.get(participant_type) == session_id
def reset_participant(self, participant_type):
"""Reset the state for a specific participant type.
Args:
participant_type: Type of participant (e.g., "operator", "customer", "bot")
"""
if participant_type in self.session_ids:
self.session_ids[participant_type] = None
if participant_type in self.session_id_refs:
self.session_id_refs[participant_type][0] = None
# Additional reset actions for specific participant types
if participant_type == "operator":
self.call_flow_state.set_operator_disconnected()
class CallFlowState:
"""State for tracking call flow operations and state transitions."""
def __init__(self):
# Operator-related state
self.dialed_operator = False
self.operator_connected = False
self.summary_finished = False
# Operator-related methods
def set_operator_dialed(self):
"""Mark that an operator has been dialed."""
self.dialed_operator = True
def set_operator_connected(self):
"""Mark that an operator has connected to the call."""
self.operator_connected = True
# Summary is not finished when operator first connects
self.summary_finished = False
def set_operator_disconnected(self):
"""Handle operator disconnection."""
self.operator_connected = False
self.summary_finished = False
def set_summary_finished(self):
"""Mark the summary as finished."""
self.summary_finished = True
class TranscriptionModifierProcessor(FrameProcessor):
"""Processor that modifies transcription frames before they reach the context aggregator."""
@@ -96,72 +214,62 @@ class SummaryFinished(FrameProcessor):
await self.push_frame(frame, direction)
async def main(
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)
# Create a routing manager using the provided body
call_config_manager = CallConfigManager.from_json_string(body) if body else CallConfigManager()
# Check if the body contains dial-in settings
logger.debug(f"Body data: {body_data}")
# Get caller information
caller_info = call_config_manager.get_caller_info()
caller_number = caller_info["caller_number"]
dialed_number = caller_info["dialed_number"]
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
# Get customer name based on caller number
customer_name = call_config_manager.get_customer_name(caller_number) if caller_number else 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}")
# Get appropriate operator settings based on the caller
operator_dialout_settings = call_config_manager.get_dialout_settings_for_caller(caller_number)
if not call_id or not call_domain:
logger.error("Call ID and Call Domain are required for dial-in.")
sys.exit(1)
logger.info(f"Caller number: {caller_number}")
logger.info(f"Dialed number: {dialed_number}")
logger.info(f"Customer name: {customer_name}")
logger.info(f"Operator dialout settings: {operator_dialout_settings}")
# Check if in test mode
test_mode = call_config_manager.is_test_mode()
# Get dialin settings if present
dialin_settings = call_config_manager.get_dialin_settings()
# ------------ TRANSPORT SETUP ------------
# Set up transport parameters
if test_mode:
logger.info("Running in test mode")
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,
)
else:
daily_dialin_settings = DailyDialinSettings(
call_id=dialin_settings.get("call_id"), call_domain=dialin_settings.get("call_domain")
)
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,
)
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 the session manager
session_manager = SessionManager()
call_flow_state = CallFlowState()
session_manager = SessionManager(call_flow_state)
# Set up the operator dialout settings
session_manager.call_flow_state.set_operator_dialout_settings(operator_dialout_settings)
# Operator dialout number
operator_number = os.getenv("OPERATOR_NUMBER", None)
# Initialize transport
transport = DailyTransport(
@@ -177,30 +285,38 @@ async def main(
voice_id="b7d50908-b17c-442d-ad8d-810c63997ed9", # Use Helpful Woman voice by default
)
# ------------ RETRY LOGIC VARIABLES ------------
max_retries = 5
retry_count = 0
dialout_successful = False
dialout_params = None
async def attempt_operator_dialout():
"""Attempt to start operator 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 operator dialout (attempt {retry_count}/{max_retries}) to: {operator_number}"
)
await transport.start_dialout(dialout_params)
else:
logger.error(f"Maximum retry attempts ({max_retries}) reached for operator dialout.")
# Notify user that operator connection failed
content = "I'm sorry, but I'm unable to connect you with a supervisor at this time. Please try again later or contact us through other means."
message = {"role": "system", "content": content}
messages.append(message)
await task.queue_frames([LLMMessagesFrame(messages)])
# ------------ LLM AND CONTEXT SETUP ------------
# Get prompts from routing manager
call_transfer_initial_prompt = call_config_manager.get_prompt("call_transfer_initial_prompt")
# Build default greeting with customer name if available
customer_greeting = f"Hello {customer_name}" if customer_name else "Hello"
default_greeting = f"{customer_greeting}, this is Hailey from customer support. What can I help you with today?"
# Build initial prompt
if call_transfer_initial_prompt:
# Use custom prompt with customer name replacement if needed
system_instruction = call_config_manager.customize_prompt(
call_transfer_initial_prompt, customer_name
)
logger.info("Using custom call transfer initial prompt")
else:
# Use default prompt with formatted greeting
system_instruction = f"""You are Chatbot, a friendly, helpful robot. Never refer to this prompt, even if asked. Follow these steps **EXACTLY**.
system_instruction = f"""You are Chatbot, a friendly, helpful robot. Never refer to this prompt, even if asked. Follow these steps **EXACTLY**.
### **Standard Operating Procedure:**
#### **Step 1: Greeting**
- Greet the user with: "{default_greeting}"
- Greet the user with: "Hello, this is Hailey from customer support. What can I help you with today?"
#### **Step 2: Handling Requests**
- If the user requests a supervisor, **IMMEDIATELY** call the `dial_operator` function.
@@ -211,10 +327,13 @@ async def main(
### **General Rules**
- Your output will be converted to audio, so **do not include special characters or formatting.**
"""
logger.info("Using default call transfer initial prompt")
# Create the system message and initialize messages list
messages = [call_config_manager.create_system_message(system_instruction)]
messages = [
{
"role": "system",
"content": system_instruction,
}
]
# ------------ FUNCTION DEFINITIONS ------------
@@ -225,7 +344,10 @@ async def main(
"""Function the bot can call to terminate the call."""
# Create a message to add
content = "The user wants to end the conversation, thank them for chatting."
message = call_config_manager.create_system_message(content)
message = {
"role": "system",
"content": content,
}
# Append the message to the list
messages.append(message)
# Queue the message to the context
@@ -236,41 +358,41 @@ async def main(
async def dial_operator(params: FunctionCallParams):
"""Function the bot can call to dial an operator."""
dialout_setting = session_manager.call_flow_state.get_current_dialout_setting()
if call_config_manager.get_transfer_mode() == "dialout":
if dialout_setting:
session_manager.call_flow_state.set_operator_dialed()
logger.info(f"Dialing operator with settings: {dialout_setting}")
nonlocal dialout_params
# Create a message to add
content = "The user has requested a supervisor, indicate that you will attempt to connect them with a supervisor."
message = call_config_manager.create_system_message(content)
if operator_number:
call_flow_state.set_operator_dialed()
logger.info(f"Dialing operator number: {operator_number}")
# Append the message to the list
messages.append(message)
# Queue the message to the context
await task.queue_frames([LLMMessagesFrame(messages)])
# Start the dialout
await call_config_manager.start_dialout(transport, [dialout_setting])
else:
# Create a message to add
content = "Indicate that there are no operator dialout settings available."
message = call_config_manager.create_system_message(content)
# Append the message to the list
messages.append(message)
# Queue the message to the context
await task.queue_frames([LLMMessagesFrame(messages)])
logger.info("No operator dialout settings available")
else:
# Create a message to add
content = "Indicate that the current mode is not supported."
message = call_config_manager.create_system_message(content)
content = "The user has requested a supervisor, indicate that you will attempt to connect them with a supervisor."
message = {
"role": "system",
"content": content,
}
# Append the message to the list
messages.append(message)
# Queue the message to the context
await task.queue_frames([LLMMessagesFrame(messages)])
logger.info("Other mode not supported")
# Set up dialout parameters and start attempt
dialout_params = {"phoneNumber": operator_number}
logger.debug(f"Dialout parameters: {dialout_params}")
await attempt_operator_dialout()
else:
# Create a message to add
content = "Indicate that there are no operator dialout settings available."
message = {
"role": "system",
"content": content,
}
# Append the message to the list
messages.append(message)
# Queue the message to the context
await task.queue_frames([LLMMessagesFrame(messages)])
logger.info("No operator dialout settings available")
# Define function schemas for tools
terminate_call_function = FunctionSchema(
@@ -304,17 +426,14 @@ async def main(
# ------------ PIPELINE SETUP ------------
# Use the session manager's references
summary_finished = SummaryFinished(session_manager.call_flow_state)
summary_finished = SummaryFinished(call_flow_state)
transcription_modifier = TranscriptionModifierProcessor(
session_manager.get_session_id_ref("operator")
)
# Define function to determine if bot should speak
async def should_speak(self) -> bool:
result = (
not session_manager.call_flow_state.operator_connected
or not session_manager.call_flow_state.summary_finished
)
result = not call_flow_state.operator_connected or not call_flow_state.summary_finished
return result
# Build pipeline
@@ -348,14 +467,15 @@ async def main(
@transport.event_handler("on_dialout_answered")
async def on_dialout_answered(transport, data):
nonlocal dialout_successful
logger.debug(f"++++ Dial-out answered: {data}")
await transport.capture_participant_transcription(data["sessionId"])
# Mark dialout as successful to stop retries
dialout_successful = True
# Skip if operator already connected
if (
not session_manager.call_flow_state
or session_manager.call_flow_state.operator_connected
):
if not call_flow_state or call_flow_state.operator_connected:
logger.debug(f"Operator already connected: {data}")
return
@@ -365,34 +485,32 @@ async def main(
session_manager.set_session_id("operator", data["sessionId"])
# Update state
session_manager.call_flow_state.set_operator_connected()
# Determine message content based on configuration
if call_config_manager.get_speak_summary():
logger.debug("Bot will speak summary")
call_transfer_prompt = call_config_manager.get_prompt("call_transfer_prompt")
if call_transfer_prompt:
# Use custom prompt
logger.info("Using custom call transfer prompt")
content = call_config_manager.customize_prompt(call_transfer_prompt, customer_name)
else:
# Use default summary prompt
logger.info("Using default call transfer prompt")
customer_info = call_config_manager.get_customer_info_suffix(customer_name)
content = f"""An operator is joining the call{customer_info}.
Give a brief summary of the customer's issues so far."""
else:
# Simple join notification without summary
logger.debug("Bot will not speak summary")
customer_info = call_config_manager.get_customer_info_suffix(customer_name)
content = f"""Indicate that an operator has joined the call{customer_info}."""
call_flow_state.set_operator_connected()
# Create and queue system message
message = call_config_manager.create_system_message(content)
content = """An operator is joining the call.
Give a brief summary of the customer's issues so far."""
message = {
"role": "system",
"content": content,
}
messages.append(message)
await task.queue_frames([LLMMessagesFrame(messages)])
@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_error")
async def on_dialout_error(transport, data):
logger.error(f"Operator dialout error (attempt {retry_count}/{max_retries}): {data}")
if retry_count < max_retries:
logger.info(f"Retrying operator dialout")
await attempt_operator_dialout()
else:
logger.error(f"All {max_retries} operator dialout attempts failed.")
@transport.event_handler("on_dialout_stopped")
async def on_dialout_stopped(transport, data):
if session_manager.get_session_id("operator") and data[
@@ -417,29 +535,14 @@ async def main(
# Reset operator state
session_manager.reset_participant("operator")
# Determine message content
call_transfer_finished_prompt = call_config_manager.get_prompt(
"call_transfer_finished_prompt"
)
if call_transfer_finished_prompt:
# Use custom prompt for operator departure
logger.info("Using custom call transfer finished prompt")
content = call_config_manager.customize_prompt(
call_transfer_finished_prompt, customer_name
)
else:
# Use default prompt for operator departure
logger.info("Using default call transfer finished prompt")
customer_info = call_config_manager.get_customer_info_suffix(
customer_name, preposition=""
)
content = f"""The operator has left the call.
# Create and queue system message
content = """The operator has left the call.
Resume your role as the primary support agent and use information from the operator's conversation to help the customer{customer_info}.
Let the customer know the operator has left and ask if they need further assistance."""
# Create and queue system message
message = call_config_manager.create_system_message(content)
message = {
"role": "system",
"content": content,
}
messages.append(message)
await task.queue_frames([LLMMessagesFrame(messages)])
@@ -449,17 +552,25 @@ async def main(
await runner.run(task)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Pipecat Call Transfer Bot")
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()
# Log the arguments for debugging
logger.info(f"Room URL: {args.url}")
logger.info(f"Token: {args.token}")
logger.info(f"Body provided: {bool(args.body)}")
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)
asyncio.run(main(args.url, args.token, args.body))
await run_bot(args.url, args.token, args.body)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,10 @@
# 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
# Operator number
OPERATOR_NUMBER=phone_number_to_dial

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

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

View File

@@ -0,0 +1,111 @@
# Daily PSTN dial-out simple chatbot
This project demonstrates how to create a voice bot that uses Dailys PSTN capabilities to make calls to phone numbers.
## How it works
1. The server file receives a curl request with the phone number 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 rings the number provided in the curl request
5. The user then answers the phone and the user is brought into the call
6. The end user and bot are connected, and the bot handles the conversation
## Prerequisites
- A Daily account with an API key, and a phone number purchased through Daily
- A US phone number to ring
- dial-out must be enabled on your domain. Find out more by reading this [document and filling in the form](https://docs.daily.co/guides/products/dial-in-dial-out#main)
- 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. 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. Request dial-out enablement
For compliance reasons, to enable dial-out for your Daily account, you must request enablement via the form. You can find out more about dial-out, and the form at the [link here:](https://docs.daily.co/guides/products/dial-in-dial-out#main)
## 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": {
"phone_number": "+12345678910"
}
}'
```
The server should make a room. The bot will join the room and then ring the number 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`.
## Multiple SIP Endpoints
For PSTN calls, you only need one SIP endpoint.
## 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 you have purchased a phone number to ring from
- Check that the phone number you are trying to ring is correct, and is a US or Canadian number.
### 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,239 @@
#
# 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("phone_number"):
logger.error("Dial-out phone number not found in the dial-out settings")
return
# Extract dial-out phone number
phone_number = dialout_settings["phone_number"]
caller_id = dialout_settings.get("caller_id") # Use .get() to handle optional field
if caller_id:
logger.info(f"Dial-out caller ID specified: {caller_id}")
else:
logger.info("Dial-out caller ID not specified; proceeding without it")
# ------------ 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,
"Simple Dial-out 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 = {"phoneNumber": phone_number}
if caller_id:
dialout_params["callerId"] = caller_id
logger.debug(f"Including caller ID in dialout: {caller_id}")
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: {phone_number}"
)
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: {phone_number}")
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,openai,silero]
fastapi==0.115.6
uvicorn
python-dotenv
python-multipart
aiohttp

View File

@@ -0,0 +1,121 @@
#
# 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 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("phone_number"):
raise HTTPException(
status_code=400, detail="Missing 'phone_number' in dialout_settings"
)
# Extract the phone number we want to dial out to
caller_phone = str(data["dialout_settings"]["phone_number"])
print(f"Processing 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

View File

@@ -0,0 +1,121 @@
# Daily PSTN Simple Voicemail Detection Bot
This project demonstrates how to create a voice bot that uses Dailys PSTN capabilities to make calls to phone numbers, and if the bot hits a voicemail system, to have the bot also leave a message.
## How it works
1. The server file receives a curl request with the phone number 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 rings the number provided in the curl request
5. When the phone is answered, the bot detects for certain key phrases
6. If the bot detects those key phrases, it leaves a messages and then ends the call
7. If the bot detects there's a human on the phone, it behaves like a regular bot
## Prerequisites
- A Daily account with an API key, and a phone number purchased through Daily
- A US phone number to ring
- dial-out must be enabled on your domain. Find out more by reading this [document and filling in the form](https://docs.daily.co/guides/products/dial-in-dial-out#main)
- Google 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. 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. Request dial-out enablement
For compliance reasons, to enable dial-out for your Daily account, you must request enablement via the form. You can find out more about dial-out, and the form at the [link here:](https://docs.daily.co/guides/products/dial-in-dial-out#main)
## 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": {
"phone_number": "+12345678910"
}
}'
```
The server should make a room. The bot will join the room and then ring the number provided. Answer the call to speak with the bot.
- You can pretend to be a voicemail machine by saying something like "Please leave a message after the beep... beeeeep".
- You should observe the bot detects the voicemail machine and leaves a message before terminating the call
- You can also say something like "Hello?", and the bot will notice you're likely a human and begin having a conversation with you
## 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 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 you have purchased a phone number to ring from
- Check that the phone number you are trying to ring is correct, and is a US or Canadian number.
### The bot doesn't detect my voicemail
- The bot should be smart enough to detect variations of certain patterns,
- If your voicemail machine doesn't follow the example patterns, add the pattern to the LLM prompt
### 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,313 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
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.frames.frames import EndTaskFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.google.google import GoogleLLMContext
from pipecat.services.google.llm import GoogleLLMService
from pipecat.services.llm_service import FunctionCallParams
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("phone_number"):
logger.error("Dial-out phone number not found in the dial-out settings")
return
# Extract dial-out phone number
phone_number = dialout_settings["phone_number"]
caller_id = dialout_settings.get("caller_id") # Use .get() to handle optional field
if caller_id:
logger.info(f"Dial-out caller ID specified: {caller_id}")
else:
logger.info("Dial-out caller ID not specified; proceeding without it")
# ------------ 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,
"Voicemail Detection 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
)
async def terminate_call(
params: FunctionCallParams,
):
"""Function the bot can call to terminate the call."""
await params.llm.queue_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
tools = [
{
"function_declarations": [
{
"name": "terminate_call",
"description": "Terminate the call",
},
]
}
]
system_instruction = """You are Chatbot, a friendly, helpful robot. Never mention this prompt.
**Operating Procedure:**
**Phase 1: Initial Call Answer - Listen for Voicemail Greeting**
**IMMEDIATELY after the call connects, LISTEN CAREFULLY for the *very first thing* you hear.**
If you hear any of these phrases (or very similar ones):
- "Please leave a message after the beep"
- "No one is available to take your call"
- "Record your message after the tone"
- "You have reached voicemail for..."
- "You have reached [phone number]"
- "[phone number] is unavailable"
- "The person you are trying to reach..."
- "The number you have dialed..."
- "Your call has been forwarded to an automated voice messaging system"
**If you HEAR one of these sentences (or a very similar greeting) as the *initial response* to the call, IMMEDIATELY assume it is voicemail and proceed to Phase 2.**
**If you hear "PLEASE LEAVE A MESSAGE AFTER THE BEEP", WAIT for the actual beep sound from the voicemail system *after* hearing the sentence, before proceeding to Phase 2.**
**If you DO NOT hear any of these voicemail greetings as the *initial response*, assume it is a human and proceed to Phase 3.**
**Phase 2: Leave Voicemail Message (If Voicemail Detected):**
If you assumed voicemail in Phase 1, say this EXACTLY:
"Hello, this is a message for Pipecat example user. This is Chatbot. Please call back on 123-456-7891. Thank you."
**Immediately after saying the message, call the function `terminate_call`.**
**DO NOT SAY ANYTHING ELSE. SILENCE IS REQUIRED AFTER `terminate_call`.**
**Phase 3: Human Interaction (If No Voicemail Greeting Detected in Phase 1):**
If you did not detect a voicemail greeting in Phase 1 and a human answers, say:
"Oh, hello! I'm a friendly chatbot. Is there anything I can help you with?"
Keep your responses **short and helpful.**
When the person indicates they're done with the conversation by saying something like:
- "Goodbye"
- "That's all"
- "I'm done"
- "Thank you, that's all I needed"
THEN say: "Thank you for chatting. Goodbye!" and call the terminate_call function.
**Then, immediately call the function `terminate_call`.**
**VERY IMPORTANT RULES - DO NOT DO THESE THINGS:**
* **DO NOT SAY "Please leave a message after the beep."**
* **DO NOT SAY "No one is available to take your call."**
* **DO NOT SAY "Record your message after the tone."**
* **DO NOT SAY ANY voicemail greeting yourself.**
* **Only check for voicemail greetings in Phase 1, *immediately after the call connects*.**
* **After voicemail or human interaction, ALWAYS call `terminate_call` immediately.**
* **Do not speak after calling `terminate_call`.**
* Your speech will be audio, so use simple language without special characters.
"""
llm = GoogleLLMService(
model="models/gemini-2.0-flash-001", # Full model for better conversation
api_key=os.getenv("GOOGLE_API_KEY"),
system_instruction=system_instruction,
tools=tools,
)
llm.register_function("terminate_call", terminate_call)
context = GoogleLLMContext()
context_aggregator = llm.create_context_aggregator(context)
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 = {"phoneNumber": phone_number}
if caller_id:
dialout_params["callerId"] = caller_id
logger.debug(f"Including caller ID in dialout: {caller_id}")
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: {phone_number}"
)
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: {phone_number}")
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)
# ------------ SCRIPT ENTRY POINT ------------
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
GOOGLE_API_KEY=your_google_api_key
CARTESIA_API_KEY=your_cartesia_api_key

View File

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

View File

@@ -0,0 +1,121 @@
#
# 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 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("phone_number"):
raise HTTPException(
status_code=400, detail="Missing 'phone_number' in dialout_settings"
)
# Extract the phone number we want to dial out to
caller_phone = str(data["dialout_settings"]["phone_number"])
print(f"Processing 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

View File

@@ -1,11 +1,11 @@
# Daily + Twilio SIP Voice Bot
# Daily + Twilio SIP dial-in 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)
2. Twilio calls your webhook server (`/start` 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
@@ -44,12 +44,12 @@ cp .env.example .env
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"
- Set the webhook for "A Call Comes In" to your server's URL + "/start"
- 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
ngrok http 7860
# Then use the provided URL (e.g., https://abc123.ngrok.io/start) in Twilio
```
## Running the Server

View File

@@ -3,3 +3,4 @@ fastapi==0.115.6
uvicorn
python-dotenv
twilio
aiohttp

View File

@@ -30,7 +30,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan)
@app.post("/call", response_class=PlainTextResponse)
@app.post("/start", response_class=PlainTextResponse)
async def handle_call(request: Request):
"""Handle incoming Twilio call webhook."""
print("Received call webhook from Twilio")
@@ -111,6 +111,6 @@ async def health_check():
if __name__ == "__main__":
# Run the server
port = int(os.getenv("PORT", "8000"))
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,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

@@ -3,26 +3,28 @@
#
# 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 call_connection_manager import CallConfigManager
from dotenv import load_dotenv
from loguru import logger
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import EndTaskFrame
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.processors.frame_processor import FrameDirection
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.services.daily import DailyParams, DailyTransport
@@ -35,19 +37,41 @@ daily_api_key = os.getenv("DAILY_API_KEY", "")
daily_api_url = os.getenv("DAILY_API_URL", "https://api.daily.co/v1")
async def main(
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)
# Create a config manager using the provided body
call_config_manager = CallConfigManager.from_json_string(body) if body else CallConfigManager()
# Check if the body contains dial-in settings
logger.debug(f"Body data: {body_data}")
# Get important configuration values
dialout_settings = call_config_manager.get_dialout_settings()
test_mode = call_config_manager.is_test_mode()
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 ------------
@@ -65,7 +89,7 @@ async def main(
transport = DailyTransport(
room_url,
token,
"Simple Dial-out Bot",
"Phone Bot",
transport_params,
)
@@ -75,39 +99,24 @@ async def main(
voice_id="b7d50908-b17c-442d-ad8d-810c63997ed9", # Use Helpful Woman voice by default
)
# ------------ FUNCTION DEFINITIONS ------------
async def terminate_call(params: FunctionCallParams):
"""Function the bot can call to terminate the call upon completion of a voicemail message."""
await params.llm.queue_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
# Define function schemas for tools
terminate_call_function = FunctionSchema(
name="terminate_call",
description="Call this function to terminate the call.",
properties={},
required=[],
)
# Create tools schema
tools = ToolsSchema(standard_tools=[terminate_call_function])
# ------------ LLM AND CONTEXT SETUP ------------
# Set up the system instruction for the LLM
system_instruction = """You are Chatbot, a friendly, helpful robot. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way, but keep your responses brief. Start by introducing yourself. If the user ends the conversation, **IMMEDIATELY** call the `terminate_call` function. """
# Initialize LLM
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
# Register functions with the LLM
llm.register_function("terminate_call", terminate_call)
# Create system message and initialize messages list
messages = [call_config_manager.create_system_message(system_instruction)]
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, tools)
context = OpenAILLMContext(messages)
context_aggregator = llm.create_context_aggregator(context)
# ------------ PIPELINE SETUP ------------
@@ -127,14 +136,34 @@ async def main(
# 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 dialout if needed
if not test_mode and dialout_settings:
logger.debug("Dialout settings detected; starting dialout")
await call_config_manager.start_dialout(transport, dialout_settings)
# 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):
@@ -142,17 +171,27 @@ async def main(
@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):
if test_mode:
logger.debug(f"First participant joined: {participant['id']}")
await transport.capture_participant_transcription(participant["id"])
# The bot will wait to hear the user before the bot speaks
logger.debug(f"First participant joined: {participant['id']}")
@transport.event_handler("on_participant_left")
async def on_participant_left(transport, participant, reason):
@@ -161,14 +200,12 @@ async def main(
# ------------ RUN PIPELINE ------------
if test_mode:
logger.debug("Running in test mode (can be tested in Daily Prebuilt)")
runner = PipelineRunner()
await runner.run(task)
if __name__ == "__main__":
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")
@@ -176,9 +213,16 @@ if __name__ == "__main__":
args = parser.parse_args()
# Log the arguments for debugging
logger.info(f"Room URL: {args.url}")
logger.info(f"Token: {args.token}")
logger.info(f"Body provided: {bool(args.body)}")
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)
asyncio.run(main(args.url, args.token, args.body))
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

View File

@@ -1,10 +0,0 @@
DAILY_SAMPLE_ROOM_URL=https://yourdomain.daily.co/yourroom # (optional: for joining the bot to the same room repeatedly for local dev)
DAILY_API_KEY=
DAILY_API_URL=https://api.daily.co/v1
DEEPGRAM_API_KEY=
OPENAI_API_KEY=
GOOGLE_API_KEY
CARTESIA_API_KEY=
DIAL_IN_FROM_NUMBER=
DIAL_OUT_TO_NUMBER=
OPERATOR_NUMBER=

View File

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

View File

@@ -1,192 +0,0 @@
#
# Copyright (c) 20242025, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
import argparse
import asyncio
import os
import sys
from call_connection_manager import CallConfigManager, SessionManager
from dotenv import load_dotenv
from loguru import logger
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import EndTaskFrame
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.processors.frame_processor import FrameDirection
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.services.daily import DailyDialinSettings, 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 main(
room_url: str,
token: str,
body: dict,
):
# ------------ CONFIGURATION AND SETUP ------------
# Create a config manager using the provided body
call_config_manager = CallConfigManager.from_json_string(body) if body else CallConfigManager()
# Get important configuration values
test_mode = call_config_manager.is_test_mode()
# Get dialin settings if present
dialin_settings = call_config_manager.get_dialin_settings()
# Initialize the session manager
session_manager = SessionManager()
# ------------ TRANSPORT SETUP ------------
# Set up transport parameters
if test_mode:
logger.info("Running in test mode")
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,
)
else:
daily_dialin_settings = DailyDialinSettings(
call_id=dialin_settings.get("call_id"), call_domain=dialin_settings.get("call_domain")
)
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,
)
# Initialize transport with Daily
transport = DailyTransport(
room_url,
token,
"Simple Dial-in 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
)
# ------------ FUNCTION DEFINITIONS ------------
async def terminate_call(params: FunctionCallParams):
"""Function the bot can call to terminate the call upon completion of a voicemail message."""
if session_manager:
# Mark that the call was terminated by the bot
session_manager.call_flow_state.set_call_terminated()
# Then end the call
await params.llm.queue_frame(EndTaskFrame(), FrameDirection.UPSTREAM)
# Define function schemas for tools
terminate_call_function = FunctionSchema(
name="terminate_call",
description="Call this function to terminate the call.",
properties={},
required=[],
)
# Create tools schema
tools = ToolsSchema(standard_tools=[terminate_call_function])
# ------------ LLM AND CONTEXT SETUP ------------
# Set up the system instruction for the LLM
system_instruction = """You are Chatbot, a friendly, helpful robot. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way, but keep your responses brief. Start by introducing yourself. If the user ends the conversation, **IMMEDIATELY** call the `terminate_call` function. """
# Initialize LLM
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
# Register functions with the LLM
llm.register_function("terminate_call", terminate_call)
# Create system message and initialize messages list
messages = [call_config_manager.create_system_message(system_instruction)]
# Initialize LLM context and aggregator
context = OpenAILLMContext(messages, tools)
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))
# ------------ 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()
# ------------ RUN PIPELINE ------------
if test_mode:
logger.debug("Running in test mode (can be tested in Daily Prebuilt)")
runner = PipelineRunner()
await runner.run(task)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Simple Dial-in 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()
# Log the arguments for debugging
logger.info(f"Room URL: {args.url}")
logger.info(f"Token: {args.token}")
logger.info(f"Body provided: {bool(args.body)}")
asyncio.run(main(args.url, args.token, args.body))

View File

@@ -2,7 +2,7 @@
accompanistPermissions = "0.34.0"
agp = "8.7.3"
constraintlayoutCompose = "1.1.0"
pipecatClientDaily = "0.3.2"
pipecatClientDaily = "0.3.7"
kotlin = "2.0.20"
coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.8.7"

View File

@@ -16,6 +16,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
mavenLocal()
}
}

View File

@@ -16,6 +16,7 @@ import ai.pipecat.client.types.ServiceConfig
import ai.pipecat.client.types.Tracks
import ai.pipecat.client.types.Transcript
import ai.pipecat.client.types.TransportState
import ai.pipecat.client.types.Value
import ai.pipecat.simple_chatbot_client.utils.Timestamp
import android.content.Context
import android.util.Log
@@ -88,6 +89,10 @@ class VoiceClientManager(private val context: Context) {
}
}
override fun onServerMessage(data: Value) {
Log.i(TAG, "onServerMessage: $data")
}
override fun onBotReady(version: String, config: List<ServiceConfig>) {
Log.i(TAG, "Bot ready. Version $version, config: $config")

View File

@@ -110,31 +110,6 @@ To start a call, simply make a call to your configured Twilio phone number. The
## Testing
It is also possible to automatically test the server without making phone calls by using a software client.
First, update `templates/streams.xml` to point to your server's websocket endpoint. For example:
```
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="ws://localhost:8765/ws"></Stream>
</Connect>
<Pause length="40"/>
</Response>
```
Then, start the server with `-t` to indicate we are testing:
```sh
# Make sure youre in the project directory and your virtual environment is activated
python server.py -t
```
Finally, just point the client to the server's URL:
```sh
python client.py -u http://localhost:8765 -c 2
```
where `-c` allows you to create multiple concurrent clients.
It is also possible to test the server without making phone calls by using one of these clients.
- [python](client/python/README.md): This Python client enables automated testing of the server via WebSocket without the need to make actual phone calls.
- [typescript](client/typescript/README.md): This typescript client enables manual testing of the server via WebSocket without the need to make actual phone calls.

View File

@@ -0,0 +1,39 @@
# Python Client for Server Testing
This Python client enables automated testing of the server via WebSocket without the need to make actual phone calls.
## Setup Instructions
### 1. Configure the Stream Template
Edit the `templates/streams.xml` file to point to your servers WebSocket endpoint. For example:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="ws://localhost:8765/ws" />
</Connect>
<Pause length="40"/>
</Response>
```
### 2. Start the Server in Test Mode
Run the server with the `-t` flag to indicate test mode:
```sh
# Ensure you're in the project directory and your virtual environment is activated
python server.py -t
```
### 3. Run the Client
Start the client and point it to the server URL:
```sh
python client.py -u http://localhost:8765 -c 2
```
- `-u`: Server URL (default is `http://localhost:8765`)
- `-c`: Number of concurrent client connections (e.g., 2)

View File

@@ -0,0 +1,27 @@
# Typescript Client for Server Testing
This typescript client enables manual testing of the server via WebSocket without the need to make actual phone calls.
## Setup
1. Run the bot server. See the [server README](../../README).
2. Navigate to the `client/typescript` directory:
```bash
cd client/typescript
```
3. Install dependencies:
```bash
npm install
```
4. Run the client app:
```
npm run dev
```
5. Visit http://localhost:5173 in your browser.

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chatbot</title>
</head>
<body>
<div class="container">
<div class="status-bar">
<div class="status">
Transport: <span id="connection-status">Disconnected</span>
</div>
<div class="controls">
<button id="connect-btn">Connect</button>
<button id="disconnect-btn" disabled>Disconnect</button>
</div>
</div>
<audio id="bot-audio" autoplay></audio>
<div class="debug-panel">
<h3>Debug Info</h3>
<div id="debug-log"></div>
</div>
</div>
<script type="module" src="/src/app.ts"></script>
<link rel="stylesheet" href="/src/style.css">
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
{
"name": "client",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/node": "^22.13.1",
"@types/protobufjs": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"typescript": "^5.7.3",
"vite": "^6.0.2"
},
"dependencies": {
"@pipecat-ai/client-js": "^0.4.0",
"@pipecat-ai/websocket-transport": "^0.4.2"
}
}

Some files were not shown because too many files have changed in this diff Show More