From de9f3e55f12e9452b9ca40841b00e1886cdec046 Mon Sep 17 00:00:00 2001 From: Jon Taylor Date: Mon, 3 Jun 2024 18:19:22 +0200 Subject: [PATCH] new example: dialin --- CHANGELOG.md | 5 + examples/README.md | 3 +- examples/dialin-chatbot/.dockerignore | 7 + examples/dialin-chatbot/.gitignore | 165 +++++++++++++++++ examples/dialin-chatbot/Dockerfile | 40 +++++ examples/dialin-chatbot/README.md | 83 +++++++++ examples/dialin-chatbot/bot_daily.py | 113 ++++++++++++ examples/dialin-chatbot/bot_runner.py | 218 +++++++++++++++++++++++ examples/dialin-chatbot/bot_twilio.py | 125 +++++++++++++ examples/dialin-chatbot/daily_helpers.py | 130 ++++++++++++++ examples/dialin-chatbot/env.example | 8 + examples/dialin-chatbot/image.png | Bin 0 -> 19403 bytes examples/dialin-chatbot/requirements.txt | 7 + 13 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 examples/dialin-chatbot/.dockerignore create mode 100644 examples/dialin-chatbot/.gitignore create mode 100644 examples/dialin-chatbot/Dockerfile create mode 100644 examples/dialin-chatbot/README.md create mode 100644 examples/dialin-chatbot/bot_daily.py create mode 100644 examples/dialin-chatbot/bot_runner.py create mode 100644 examples/dialin-chatbot/bot_twilio.py create mode 100644 examples/dialin-chatbot/daily_helpers.py create mode 100644 examples/dialin-chatbot/env.example create mode 100644 examples/dialin-chatbot/image.png create mode 100644 examples/dialin-chatbot/requirements.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index c499dcc1b..ca4c9885b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed an issue where `BaseOutputTransport` would send longer audio frames preventing interruptions. +### Other + +- Added new `dialin-chatbot` example. This examples shows how to call the bot + using a phone number. + ## [0.0.29] - 2024-06-07 ### Added diff --git a/examples/README.md b/examples/README.md index 250638a30..dfda87856 100644 --- a/examples/README.md +++ b/examples/README.md @@ -38,7 +38,8 @@ Next, follow the steps in the README for each demo. | [Storytelling Chatbot](storytelling-chatbot) | Stitches together multiple third-party services to create a collaborative storytime experience. | Deepgram, ElevenLabs, Open AI, Fal, Daily, Custom UI | | [Translation Chatbot](translation-chatbot) | Listens for user speech, then translates that speech to Spanish and speaks the translation back. Demonstrates multi-participant use-cases. | Deepgram, Azure, OpenAI, Daily, Daily Prebuilt UI | | [Moondream Chatbot](moondream-chatbot) | Demonstrates how to add vision capabilities to GPT4. **Note: works best with a GPU** | Deepgram, OpenAI, Moondream, Daily, Daily Prebuilt UI | -| Function-calling Chatbot (TBC) | A chatbot that can call functions in response to user input | Deepgram, OpenAI, Fireworks, Daily, Daily Prebuilt UI | +| Function-calling Chatbot (TBC) | A chatbot that can call functions in response to user input. | Deepgram, OpenAI, Fireworks, Daily, Daily Prebuilt UI | +| [Dialin Chatbot](dialin-chatbot) | A chatbot that connects to an incoming phone call from Daily or Twilio. | Deepgram, OpenAI, ElevenLabs, Daily, Twilio | > [!IMPORTANT] > These example projects use Daily as a WebRTC transport and can be joined using their hosted Prebuilt UI. diff --git a/examples/dialin-chatbot/.dockerignore b/examples/dialin-chatbot/.dockerignore new file mode 100644 index 000000000..a832d431c --- /dev/null +++ b/examples/dialin-chatbot/.dockerignore @@ -0,0 +1,7 @@ +**/.DS_Store +web-ui/node_modules +web-ui/dist +web-ui/dist-ssr +web-ui/*.lock +web-ui/*.local +web-ui/*.*.local \ No newline at end of file diff --git a/examples/dialin-chatbot/.gitignore b/examples/dialin-chatbot/.gitignore new file mode 100644 index 000000000..c8b0ff9d3 --- /dev/null +++ b/examples/dialin-chatbot/.gitignore @@ -0,0 +1,165 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +runpod.toml + +# custom script to recursively upgrade items in requirements.py +upgrade_requirements.py +.DS_Store diff --git a/examples/dialin-chatbot/Dockerfile b/examples/dialin-chatbot/Dockerfile new file mode 100644 index 000000000..e0838f0d7 --- /dev/null +++ b/examples/dialin-chatbot/Dockerfile @@ -0,0 +1,40 @@ +FROM python:3.11-bullseye + +ARG DEBIAN_FRONTEND=noninteractive +ARG USE_PERSISTENT_DATA +ENV PYTHONUNBUFFERED=1 +# Expose FastAPI port +ENV FAST_API_PORT=7860 +EXPOSE 7860 + +# Install system dependencies +RUN apt-get update && apt-get install --no-install-recommends -y \ + build-essential \ + git \ + ffmpeg \ + google-perftools \ + ca-certificates curl gnupg \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Set up a new user named "user" with user ID 1000 +RUN useradd -m -u 1000 user + +# Set home to the user's home directory +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH \ + PYTHONPATH=$HOME/app \ + PYTHONUNBUFFERED=1 + +# Switch to the "user" user +USER user + +# Set the working directory to the user's home directory +WORKDIR $HOME/app + +# Install Python dependencies +COPY *.py . +COPY ./requirements.txt requirements.txt +RUN pip3 install --no-cache-dir --upgrade -r requirements.txt + +# Start the FastAPI server +CMD python3 bot_runner.py --host "0.0.0.0" --port ${FAST_API_PORT} \ No newline at end of file diff --git a/examples/dialin-chatbot/README.md b/examples/dialin-chatbot/README.md new file mode 100644 index 000000000..5bdfcc481 --- /dev/null +++ b/examples/dialin-chatbot/README.md @@ -0,0 +1,83 @@ +
pipecat +
+ +# Dialin example + +Example project that demonstrates how to add phone number dialin to your Pipecat bots. We include examples for both Daily (`bot_daily.py`) and Twilio (`bot_twilio.py`), depending on who you want to use as a phone vendor. + +- 🔁 Transport: Daily WebRTC +- 💬 Speech-to-Text (transcription): Daily +- 🤖 LLM: GPT4-o / OpenAI +- 🔉 Text-to-Speech: ElevenLabs + +#### Should I use Daily or Twilio as a vendor? + +If you're starting from scratch, using Daily to provision phone numbers alongside Daily as a transport offers some convenience (such as automatic call forwarding.) + +If you already have Twilio numbers and workflows that you want to connect to your Pipecat bots, there is some additional configuration required (you'll need to create a `on_dialin_ready` and use the Twilio client to trigger the forward.) + +You can read more about this, as well as see respective walkthroughs in our docs. + +## Setup + +```shell +# Install the requirements +pip install -r requirements.txt + +# Setup your env +mv env.example .env +``` + +## Using Daily numbers + +Run `bot_runner.py` to handle incoming HTTP requests: + +`python bot_runner.py --host localhost` + +Then target the following URL: + +`POST /daily_start_bot` + +For more configuration options, please consult Daily's API documentation. + + +## Using Twilio numbers + +As above, but target the following URL: + +`POST /twilio_start_bot` + +For more configuration options, please consult Twilio's API documentation. + +## Deployment example + +A Dockerfile is included in this demo for convenience. Here is an example of how to build and deploy your bot to [fly.io](https://fly.io). + +*Please note: This demo spawns agents as subprocesses for convenience / demonstration purposes. You would likely not want to do this in production as it would limit concurrency to available system resources. For more information on how to deploy your bots using VMs, refer to the Pipecat documentation.* + +### Build the docker image + +`docker build -t tag:project .` + +### Launch the fly project + +`fly launch` (using the included fly.toml) + +### Setup your secrets on Fly + +Set the necessary secrets (found in `env.example`) + +`fly secrets set DAILY_API_KEY=... OPENAI_API_KEY=... ELEVENLABS_API_KEY=... ELEVENLABS_VOICE_ID=...` + +If you're using Twilio as a number vendor: + +`fly secrets set TWILIO_ACCOUNT_SID=... TWILIO_AUTH_TOKEN=...` + +### Deploy! + +`fly deploy` + +## Need to do something more advanced? + +This demo covers the basics of bot telephony. If you want to know more about working with PSTN / SIP, please ping us on [Discord](https://discord.gg/pipecat). \ No newline at end of file diff --git a/examples/dialin-chatbot/bot_daily.py b/examples/dialin-chatbot/bot_daily.py new file mode 100644 index 000000000..18bdf6e46 --- /dev/null +++ b/examples/dialin-chatbot/bot_daily.py @@ -0,0 +1,113 @@ +import asyncio +import aiohttp +import os +import sys +import argparse + +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_response import LLMAssistantResponseAggregator, LLMUserResponseAggregator +from pipecat.frames.frames import ( + LLMMessagesFrame, + EndFrame +) +from pipecat.services.elevenlabs import ElevenLabsTTSService +from pipecat.services.openai import OpenAILLMService +from pipecat.transports.services.daily import DailyParams, DailyTransport, DailyDialinSettings +from pipecat.vad.silero import SileroVADAnalyzer +from loguru import logger + +from dotenv import load_dotenv +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + +daily_api_url = os.getenv("DAILY_API_URL", "api.daily.co/v1") +daily_api_key = os.getenv("DAILY_API_KEY") + + +async def main(room_url: str, token: str, callId: str, callDomain: str, sipUri: str): + async with aiohttp.ClientSession() as session: + # diallin_settings are only needed if Daily's SIP URI is used + # If you are handling this via Twilio, Telnyx, set this to None + # and handle call-forwarding when on_dialin_ready fires. + diallin_settings = DailyDialinSettings( + call_id=callId, + call_domain=callDomain, + sip_uri=sipUri + ) + + transport = DailyTransport( + room_url, + token, + "Chatbot", + DailyParams( + api_url=f"https://{daily_api_url}", + api_key=daily_api_key, + dialin_settings=diallin_settings, + audio_in_enabled=True, + audio_out_enabled=True, + camera_out_enabled=False, + vad_enabled=True, + vad_analyzer=SileroVADAnalyzer(), + transcription_enabled=True, + ) + ) + + tts = ElevenLabsTTSService( + aiohttp_session=session, + api_key=os.getenv("ELEVENLABS_API_KEY"), + voice_id=os.getenv("ELEVENLABS_VOICE_ID"), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + model="gpt-4o") + + messages = [ + { + "role": "system", + "content": "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 saying 'Oh, hello! Who dares dial me at this hour?!'.", + }, + ] + + tma_in = LLMUserResponseAggregator(messages) + tma_out = LLMAssistantResponseAggregator(messages) + + pipeline = Pipeline([ + transport.input(), + tma_in, + llm, + tts, + transport.output(), + tma_out, + ]) + + task = PipelineTask(pipeline, PipelineParams(allow_interruptions=True)) + + @transport.event_handler("on_first_participant_joined") + async def on_first_participant_joined(transport, participant): + transport.capture_participant_transcription(participant["id"]) + await task.queue_frames([LLMMessagesFrame(messages)]) + + @transport.event_handler("on_participant_left") + async def on_participant_left(transport, participant, reason): + await task.queue_frame(EndFrame()) + + runner = PipelineRunner() + + await runner.run(task) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Pipecat Simple ChatBot") + parser.add_argument("-u", type=str, help="Room URL") + parser.add_argument("-t", type=str, help="Token") + parser.add_argument("-i", type=str, help="Call ID") + parser.add_argument("-d", type=str, help="Call Domain") + parser.add_argument("-s", type=str, help="SIP URI") + config = parser.parse_args() + + asyncio.run(main(config.u, config.t, config.i, config.d, config.s)) diff --git a/examples/dialin-chatbot/bot_runner.py b/examples/dialin-chatbot/bot_runner.py new file mode 100644 index 000000000..8428eabfd --- /dev/null +++ b/examples/dialin-chatbot/bot_runner.py @@ -0,0 +1,218 @@ +""" +bot_runner.py + +HTTP service that listens for incoming calls from either Daily or Twilio, +provisioning a room and starting a Pipecat bot in response. + +Refer to README for more information. +""" +import os +import argparse +import subprocess +from daily_helpers import DailyRESTHelper, DailyRoomObject, DailyRoomProperties, DailyRoomSipParams, DailyRoomParams +from fastapi import FastAPI, Request, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse, PlainTextResponse +from twilio.twiml.voice_response import VoiceResponse + +from dotenv import load_dotenv +load_dotenv(override=True) + + +# ------------ Configuration ------------ # + +MAX_SESSION_TIME = 5 * 60 # 5 minutes +REQUIRED_ENV_VARS = ['OPENAI_API_KEY', 'DAILY_API_KEY', + 'ELEVENLABS_API_KEY', 'ELEVENLABS_VOICE_ID'] + +daily_rest_helper = DailyRESTHelper( + os.getenv("DAILY_API_KEY", None), + os.getenv("DAILY_API_URL", 'api.daily.co/v1')) + + +# ----------------- API ----------------- # + +app = FastAPI() + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"] +) + +""" +Create Daily room, tell the bot if the room is created for Twilio's SIP or Daily's SIP (vendor). +When the vendor is Daily, the bot handles the call forwarding automatically, +i.e, forwards the call from the "hold music state" to the Daily Room's SIP URI. + +Alternatively, when the vendor is Twilio (not Daily), the bot is responsible for +updating the state on Twilio. So when `dialin-ready` fires, it takes appropriate +action using the Twilio Client library. +""" + + +def _create_daily_room(room_url, callId, callDomain=None, vendor="daily"): + if not room_url: + params = DailyRoomParams( + properties=DailyRoomProperties( + # Note: these are the default values, except for the display name + sip=DailyRoomSipParams( + display_name="dialin-user", + video=False, + sip_mode="dial-in", + num_endpoints=1 + ) + ) + ) + + print(f"Creating new room...") + room: DailyRoomObject = daily_rest_helper.create_room(params=params) + + else: + # Check passed room URL exist (we assume that it already has a sip set up!) + try: + print(f"Joining existing room: {room_url}") + room: DailyRoomObject = daily_rest_helper.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}") + + # Give the agent a token to join the session + token = daily_rest_helper.get_token(room.url, MAX_SESSION_TIME) + + if not room or not token: + raise HTTPException( + status_code=500, detail=f"Failed to get room or token token") + + # Spawn a new agent, and join the user session + # Note: this is mostly for demonstration purposes (refer to 'deployment' in docs) + if vendor == "daily": + bot_proc = f"python3 -m bot_daily -u {room.url} -t {token} -i {callId} -d {callDomain} -s {room.config.sip_endpoint}" + else: + bot_proc = f"python3 -m bot_twilio -u {room.url} -t {token} -i {callId} -s {room.config.sip_endpoint}" + + try: + subprocess.Popen( + [bot_proc], + shell=True, + bufsize=1, + cwd=os.path.dirname(os.path.abspath(__file__)) + ) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to start subprocess: {e}") + + return room + + +@app.post("/twilio_start_bot", response_class=PlainTextResponse) +async def twilio_start_bot(request: Request): + print(f"POST /twilio_voice_bot") + + # twilio_start_bot is invoked directly by Twilio (as a web hook). + # On Twilio, under Active Numbers, pick the phone number + # Click Configure and under Voice Configuration, + # "a call comes in" choose webhook and point the URL to + # where this code is hosted. + data = None + try: + # shouldnt have received json, twilio sends form data + form_data = await request.form() + data = dict(form_data) + except Exception: + pass + + room_url = os.getenv("DAILY_SAMPLE_ROOM_URL", None) + callId = data.get('CallSid') + + if not callId: + raise HTTPException( + status_code=500, detail="Missing 'CallSid' in request") + + print("CallId: %s" % callId) + + # create room and tell the bot to join the created room + # note: Twilio does not require a callDomain + room: DailyRoomObject = _create_daily_room( + room_url, callId, None, "twilio") + + print(f"Put Twilio on hold...") + # We have the room and the SIP URI, + # but we do not know if the Daily SIP Worker and the Bot have joined the call + # put the call on hold until the 'on_dialin_ready' fires. + # Then, the bot will update the called sid with the sip uri. + # http://com.twilio.music.classical.s3.amazonaws.com/BusyStrings.mp3 + resp = VoiceResponse() + resp.play( + url="http://com.twilio.sounds.music.s3.amazonaws.com/MARKOVICHAMP-Borghestral.mp3", loop=10) + return str(resp) + + +@app.post("/daily_start_bot") +async def daily_start_bot(request: Request) -> JSONResponse: + # The /daily_start_bot is invoked when a call is received on Daily's SIP URI + # daily_start_bot will create the room, put the call on hold until + # the bot and sip worker are ready. Daily will automatically + # forward the call to the SIP URi when dialin_ready fires. + + # Use specified room URL, or create a new one if not specified + room_url = os.getenv("DAILY_SAMPLE_ROOM_URL", None) + # 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}) + callId = data.get("callId", None) + callDomain = data.get("callDomain", None) + except Exception: + raise HTTPException( + status_code=500, + detail="Missing properties 'callId' or 'callDomain'") + + print(f"CallId: {callId}, CallDomain: {callDomain}") + room: DailyRoomObject = _create_daily_room( + room_url, callId, callDomain, "daily") + + # Grab a token for the user to join with + return JSONResponse({ + "room_url": room.url, + "sipUri": room.config.sip_endpoint + }) + +# ----------------- 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...") diff --git a/examples/dialin-chatbot/bot_twilio.py b/examples/dialin-chatbot/bot_twilio.py new file mode 100644 index 000000000..6f9556b0b --- /dev/null +++ b/examples/dialin-chatbot/bot_twilio.py @@ -0,0 +1,125 @@ +import asyncio +import aiohttp +import os +import sys +import argparse + +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.pipeline.task import PipelineParams, PipelineTask +from pipecat.processors.aggregators.llm_response import LLMAssistantResponseAggregator, LLMUserResponseAggregator +from pipecat.frames.frames import ( + LLMMessagesFrame, + EndFrame +) +from pipecat.services.elevenlabs import ElevenLabsTTSService +from pipecat.services.openai import OpenAILLMService +from pipecat.transports.services.daily import DailyParams, DailyTransport +from pipecat.vad.silero import SileroVADAnalyzer + +from twilio.rest import Client + +from loguru import logger + +from dotenv import load_dotenv +load_dotenv(override=True) + +logger.remove(0) +logger.add(sys.stderr, level="DEBUG") + + +twilio_account_sid = os.getenv('TWILIO_ACCOUNT_SID') +twilio_auth_token = os.getenv('TWILIO_AUTH_TOKEN') +twilioclient = Client(twilio_account_sid, twilio_auth_token) + +daily_api_key = os.getenv("DAILY_API_KEY") + + +async def main(room_url: str, token: str, callId: str, sipUri: str): + async with aiohttp.ClientSession() as session: + # diallin_settings are only needed if Daily's SIP URI is used + # If you are handling this via Twilio, Telnyx, set this to None + # and handle call-forwarding when on_dialin_ready fires. + transport = DailyTransport( + room_url, + token, + "Chatbot", + DailyParams( + api_key=daily_api_key, + dialin_settings=None, # Not required for Twilio + audio_in_enabled=True, + audio_out_enabled=True, + camera_out_enabled=False, + vad_enabled=True, + vad_analyzer=SileroVADAnalyzer(), + transcription_enabled=True, + ) + ) + + tts = ElevenLabsTTSService( + aiohttp_session=session, + api_key=os.getenv("ELEVENLABS_API_KEY"), + voice_id=os.getenv("ELEVENLABS_VOICE_ID"), + ) + + llm = OpenAILLMService( + api_key=os.getenv("OPENAI_API_KEY"), + model="gpt-4o") + + messages = [ + { + "role": "system", + "content": "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 saying 'Hello! Who dares dial me at this hour?!'.", + }, + ] + + tma_in = LLMUserResponseAggregator(messages) + tma_out = LLMAssistantResponseAggregator(messages) + + pipeline = Pipeline([ + transport.input(), + tma_in, + llm, + tts, + transport.output(), + tma_out, + ]) + + task = PipelineTask(pipeline, PipelineParams(allow_interruptions=True)) + + @transport.event_handler("on_first_participant_joined") + async def on_first_participant_joined(transport, participant): + transport.capture_participant_transcription(participant["id"]) + await task.queue_frames([LLMMessagesFrame(messages)]) + + @transport.event_handler("on_participant_left") + async def on_participant_left(transport, participant, reason): + await task.queue_frame(EndFrame()) + + @transport.event_handler("on_dialin_ready") + async def on_dialin_ready(transport, cdata): + # For Twilio, Telnyx, etc. You need to update the state of the call + # and forward it to the sip_uri.. + print(f"Forwarding call: {callId} {sipUri}") + + try: + # The TwiML is updated using Twilio's client library + call = twilioclient.calls(callId).update( + twiml=f'{sipUri}' + ) + except Exception as e: + raise Exception(detail=f"Failed to forward call: {str(e)}") + + runner = PipelineRunner() + await runner.run(task) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Pipecat Simple ChatBot") + parser.add_argument("-u", type=str, help="Room URL") + parser.add_argument("-t", type=str, help="Token") + parser.add_argument("-i", type=str, help="Call ID") + parser.add_argument("-s", type=str, help="SIP URI") + config = parser.parse_args() + + asyncio.run(main(config.u, config.t, config.i, config.s)) diff --git a/examples/dialin-chatbot/daily_helpers.py b/examples/dialin-chatbot/daily_helpers.py new file mode 100644 index 000000000..f508f428c --- /dev/null +++ b/examples/dialin-chatbot/daily_helpers.py @@ -0,0 +1,130 @@ + +""" +Daily Helpers + +Methods that wrap the Daily API to create rooms, check room URLs, and get meeting tokens. + +""" +import urllib.parse +import urllib +import requests +from typing import Literal, Optional +from time import time + +from pydantic import BaseModel, ValidationError + + +class DailyRoomSipParams(BaseModel): + display_name: str = "sw-sip-dialin" + video: bool = False + sip_mode: str = "dial-in" + num_endpoints: int = 1 + + +class DailyRoomProperties(BaseModel): + exp: float = time() + 5 * 60 + enable_chat: bool = False + enable_emoji_reactions: bool = False + eject_at_room_exp: bool = True + enable_dialout: Optional[bool] = None + sip: DailyRoomSipParams = None + sip_uri: dict = None + + @property + def sip_endpoint(self) -> str: + return "sip:%s" % self.sip_uri['endpoint'] + + +class DailyRoomParams(BaseModel): + name: Optional[str] = None + privacy: Literal['private', 'public'] = "public" + properties: DailyRoomProperties = DailyRoomProperties() + + +class DailyRoomObject(BaseModel): + id: str + name: str + api_created: bool + privacy: str + url: str + created_at: str + config: DailyRoomProperties + + +class DailyRESTHelper: + def __init__(self, daily_api_key: str, daily_api_url: str = "api.daily.co/v1"): + self.daily_api_key = daily_api_key + self.daily_api_url = daily_api_url + + def _get_name_from_url(self, room_url: str) -> str: + return urllib.parse.urlparse(room_url).path[1:] + + def create_room(self, params: DailyRoomParams) -> DailyRoomObject: + res = requests.post( + f"https://{self.daily_api_url}/rooms", + headers={"Authorization": f"Bearer {self.daily_api_key}"}, + json={**params.model_dump(exclude_none=True)} + ) + + if res.status_code != 200: + raise Exception(f"Unable to create room: {res.text}") + + data = res.json() + + try: + room = DailyRoomObject(**data) + except ValidationError as e: + raise Exception(f"Invalid response: {e}") + + return room + + def _get_room_from_name(self, room_name: str) -> DailyRoomObject: + res: requests.Response = requests.get( + f"https://{self.daily_api_url}/rooms/{room_name}", + headers={"Authorization": f"Bearer {self.daily_api_key}"} + ) + + if res.status_code != 200: + raise Exception(f"Room not found: {room_name}") + + data = res.json() + + try: + room = DailyRoomObject(**data) + except ValidationError as e: + raise Exception(f"Invalid response: {e}") + + return room + + def get_room_from_url(self, room_url: str,) -> DailyRoomObject: + room_name = self._get_name_from_url(room_url) + return self._get_room_from_name(room_name) + + def get_token(self, room_url: str, expiry_time: float = 60 * 60, owner: bool = True) -> str: + if not room_url: + raise Exception( + "No Daily room specified. You must specify a Daily room in order a token to be generated.") + + expiration: float = time() + expiry_time + + room_name = self._get_name_from_url(room_url) + + res: requests.Response = requests.post( + f"https://{self.daily_api_url}/meeting-tokens", + headers={ + "Authorization": f"Bearer {self.daily_api_key}"}, + json={ + "properties": { + "room_name": room_name, + "is_owner": owner, + "exp": expiration + }}, + ) + + if res.status_code != 200: + raise Exception( + f"Failed to create meeting token: {res.status_code} {res.text}") + + token: str = res.json()["token"] + + return token diff --git a/examples/dialin-chatbot/env.example b/examples/dialin-chatbot/env.example new file mode 100644 index 000000000..112b418c7 --- /dev/null +++ b/examples/dialin-chatbot/env.example @@ -0,0 +1,8 @@ +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=api.daily.co/v1 +OPENAI_API_KEY= +ELEVENLABS_API_KEY= +ELEVENLABS_VOICE_ID= +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= \ No newline at end of file diff --git a/examples/dialin-chatbot/image.png b/examples/dialin-chatbot/image.png new file mode 100644 index 0000000000000000000000000000000000000000..6c53d3743b0809f7d33ac3b03ebad8bca025fe45 GIT binary patch literal 19403 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