diff --git a/examples/storytelling-chatbot/Dockerfile b/examples/storytelling-chatbot/Dockerfile
index b84ba5431..5d762cd14 100644
--- a/examples/storytelling-chatbot/Dockerfile
+++ b/examples/storytelling-chatbot/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.11-bullseye
+FROM python:3.11-slim-bookworm
ARG DEBIAN_FRONTEND=noninteractive
ARG USE_PERSISTENT_DATA
@@ -51,4 +51,4 @@ COPY --chown=user ./frontend/ frontend/
RUN cd frontend && npm install && npm run build
# Start the FastAPI server
-CMD python3 src/server.py --port ${FAST_API_PORT}
\ No newline at end of file
+CMD python3 src/bot_runner.py --port ${FAST_API_PORT}
\ No newline at end of file
diff --git a/examples/storytelling-chatbot/README.md b/examples/storytelling-chatbot/README.md
index f3a23d305..b2c1e3d8a 100644
--- a/examples/storytelling-chatbot/README.md
+++ b/examples/storytelling-chatbot/README.md
@@ -48,6 +48,8 @@ pip install -r requirements.txt
mv env.example .env
```
+When deploying to production, to ensure only this app can spawn a new bot, set your `ENV` to `production`
+
**Build the frontend:**
This project uses a custom frontend, which needs to built. Note: this is done automatically as part of the Docker deployment.
@@ -64,11 +66,11 @@ The build UI files can be found in `frontend/out`
Start the API / bot manager:
-`python src/server.py`
+`python src/bot_runner.py`
If you'd like to run a custom domain or port:
-`python src/server.py --host somehost --p 7777`
+`python src/bot_runner.py --host somehost --p someport`
➡️ Open the host URL in your browser `http://localhost:7860`
diff --git a/examples/storytelling-chatbot/env.example b/examples/storytelling-chatbot/env.example
index 56b121a4d..7060aef52 100644
--- a/examples/storytelling-chatbot/env.example
+++ b/examples/storytelling-chatbot/env.example
@@ -1,5 +1,9 @@
-DAILY_API_KEY=7df...
-ELEVENLABS_API_KEY=aeb...
-ELEVENLABS_VOICE_ID=7S...
-FAL_KEY=8c...
-OPENAI_API_KEY=sk-PL...
+DAILY_API_KEY=
+DAILY_SAMPLE_ROOM_URL=
+ELEVENLABS_API_KEY=
+ELEVENLABS_VOICE_ID=
+FAL_KEY=
+OPENAI_API_KEY=
+
+ENV= # dev | production
+RUN_AS_VM= # Set this if you want to run bots on process (not launch a new VM)
\ No newline at end of file
diff --git a/examples/storytelling-chatbot/frontend/components/App.tsx b/examples/storytelling-chatbot/frontend/components/App.tsx
index 761787c88..73ad50a97 100644
--- a/examples/storytelling-chatbot/frontend/components/App.tsx
+++ b/examples/storytelling-chatbot/frontend/components/App.tsx
@@ -27,14 +27,11 @@ export default function Call() {
// Create a new room for the story session
try {
- const response = await fetch("/create", {
+ const response = await fetch("/start_bot", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
- body: JSON.stringify({
- room_url: process.env.NEXT_PUBLIC_ROOM_URL || null,
- }),
});
const { room_url, token } = await response.json();
@@ -55,21 +52,9 @@ export default function Call() {
// Disable local audio, the bot will say hello first
daily.setLocalAudio(false);
- // Start the bot
- const resp = await fetch("/start", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- room_url,
- }),
- });
-
setState("started");
} catch (error) {
setState("error");
- leave();
}
}
@@ -79,7 +64,13 @@ export default function Call() {
}
if (state === "error") {
- return
An Error occured
;
+ return (
+
+
+ This demo is currently at capacity. Please try again later.
+
+
+ );
}
if (state === "started") {
diff --git a/examples/storytelling-chatbot/frontend/components/DevicePicker/index.tsx b/examples/storytelling-chatbot/frontend/components/DevicePicker/index.tsx
index 6cc69796d..cdbc21c5b 100644
--- a/examples/storytelling-chatbot/frontend/components/DevicePicker/index.tsx
+++ b/examples/storytelling-chatbot/frontend/components/DevicePicker/index.tsx
@@ -108,26 +108,26 @@ export default function DevicePicker({}: Props) {
{hasMicError && (
{micState === "blocked" ? (
-
+
Please check your browser and system permissions. Make sure that
this app is allowed to access your microphone.
) : micState === "in-use" ? (
-
+
Your microphone is being used by another app. Please close any
other apps using your microphone and restart this app.
) : micState === "not-found" ? (
-
+
No microphone seems to be connected. Please connect a microphone.
) : micState === "not-supported" ? (
-
+
This app is not supported on your device. Please update your
software or use a different device.
) : (
-
+
There seems to be an issue accessing your microphone. Try
restarting the app or consult a system administrator.
diff --git a/examples/storytelling-chatbot/frontend/components/Setup.tsx b/examples/storytelling-chatbot/frontend/components/Setup.tsx
index 42781e87e..4b6c35ea6 100644
--- a/examples/storytelling-chatbot/frontend/components/Setup.tsx
+++ b/examples/storytelling-chatbot/frontend/components/Setup.tsx
@@ -1,7 +1,7 @@
import React from "react";
import { Button } from "@/components/ui/button";
import DevicePicker from "@/components/DevicePicker";
-import { IconEar, IconLoader2 } from "@tabler/icons-react";
+import { IconAlertCircle, IconEar, IconLoader2 } from "@tabler/icons-react";
type SetupProps = {
handleStart: () => void;
@@ -24,7 +24,6 @@ export const Setup: React.FC
= ({ handleStart }) => {
Welcome to Storytime
-
{state === "intro" ? (
<>
@@ -38,6 +37,9 @@ export const Setup: React.FC = ({ handleStart }) => {
For best results, try in a quiet
environment!
+
+ This demo expires after 5 minutes.
+
>
) : (
<>
@@ -49,7 +51,6 @@ export const Setup: React.FC = ({ handleStart }) => {
>
)}
-
JSONResponse:
+ if os.getenv("ENV", "dev") == "production":
+ # Only allow requests from the specified domain
+ host_header = request.headers.get("host")
+ allowed_domains = ["storytelling-chatbot.fly.dev", "www.storytelling-chatbot.fly.dev"]
+ # Check if the Host header matches the allowed domain
+ if host_header not in allowed_domains:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ try:
+ data = await request.json()
+ # Is this a webhook creation request?
+ if "test" in data:
+ return JSONResponse({"test": True})
+ except Exception as e:
+ pass
+
+ # Use specified room URL, or create a new one if not specified
+ room_url = os.getenv("DAILY_SAMPLE_ROOM_URL", "")
+
+ if not room_url:
+ params = DailyRoomParams(
+ properties=DailyRoomProperties()
+ )
+ try:
+ room: DailyRoomObject = daily_rest_helper.create_room(params=params)
+ except Exception as e:
+ raise HTTPException(
+ status_code=500,
+ detail=f"Unable to provision room {e}")
+ else:
+ # Check passed room URL exists, we should assume that it already has a sip set up
+ try:
+ 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}")
+
+ # 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 token for room: {room_url}")
+
+ # Launch a new VM, or run as a shell process (not recommended)
+ if os.getenv("RUN_AS_VM", False):
+ try:
+ virtualize_bot(room.url, token)
+ except Exception as e:
+ raise HTTPException(
+ status_code=500, detail=f"Failed to spawn VM: {e}")
+ else:
+ try:
+ subprocess.Popen(
+ [f"python3 -m bot -u {room.url} -t {token}"],
+ 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}")
+
+ # Grab a token for the user to join with
+ user_token = daily_rest_helper.get_token(room.url, MAX_SESSION_TIME)
+
+ return JSONResponse({
+ "room_url": room.url,
+ "token": user_token,
+ })
+
+
+@app.get("/{path_name:path}", response_class=FileResponse)
+async def catch_all(path_name: Optional[str] = ""):
+ if path_name == "":
+ return FileResponse(f"{STATIC_DIR}/index.html")
+
+ file_path = Path(STATIC_DIR) / (path_name or "")
+
+ if file_path.is_file():
+ return file_path
+
+ html_file_path = file_path.with_suffix(".html")
+ if html_file_path.is_file():
+ return FileResponse(html_file_path)
+
+ raise HTTPException(status_code=450, detail="Incorrect API call")
+
+
+# ------------ Virtualization ------------ #
+
+def virtualize_bot(room_url: str, token: str):
+ """
+ This is an example of how to virtualize the bot using Fly.io
+ You can adapt this method to use whichever cloud provider you prefer.
+ """
+ FLY_API_HOST = os.getenv("FLY_API_HOST", "https://api.machines.dev/v1")
+ FLY_APP_NAME = os.getenv("FLY_APP_NAME", "storytelling-chatbot")
+ FLY_API_KEY = os.getenv("FLY_API_KEY", "")
+ FLY_HEADERS = {
+ 'Authorization': f"Bearer {FLY_API_KEY}",
+ 'Content-Type': 'application/json'
+ }
+
+ # Use the same image as the bot runner
+ res = requests.get(f"{FLY_API_HOST}/apps/{FLY_APP_NAME}/machines", headers=FLY_HEADERS)
+ if res.status_code != 200:
+ raise Exception(f"Unable to get machine info from Fly: {res.text}")
+ image = res.json()[0]['config']['image']
+
+ # Machine configuration
+ cmd = f"python3 src/bot.py -u {room_url} -t {token}"
+ cmd = cmd.split()
+ worker_props = {
+ "config": {
+ "image": image,
+ "auto_destroy": True,
+ "init": {
+ "cmd": cmd
+ },
+ "restart": {
+ "policy": "no"
+ },
+ "guest": {
+ "cpu_kind": "shared",
+ "cpus": 1,
+ "memory_mb": 512
+ }
+ },
+
+ }
+
+ # Spawn a new machine instance
+ res = requests.post(
+ f"{FLY_API_HOST}/apps/{FLY_APP_NAME}/machines",
+ headers=FLY_HEADERS,
+ json=worker_props)
+
+ if res.status_code != 200:
+ raise Exception(f"Problem starting a bot worker: {res.text}")
+
+ # Wait for the machine to enter the started state
+ vm_id = res.json()['id']
+
+ res = requests.get(
+ f"{FLY_API_HOST}/apps/{FLY_APP_NAME}/machines/{vm_id}/wait?state=started",
+ headers=FLY_HEADERS)
+
+ if res.status_code != 200:
+ raise Exception(f"Bot was unable to enter started state: {res.text}")
+
+ print(f"Machine joined room: {room_url}")
+
+
+# ------------ Main ------------ #
+
+if __name__ == "__main__":
+ # Check environment variables
+ required_env_vars = ['OPENAI_API_KEY', 'DAILY_API_KEY',
+ 'FAL_KEY', 'ELEVENLABS_VOICE_ID', 'ELEVENLABS_API_KEY']
+ for env_var in required_env_vars:
+ if env_var not in os.environ:
+ raise Exception(f"Missing environment variable: {env_var}.")
+
+ import uvicorn
+
+ default_host = os.getenv("HOST", "0.0.0.0")
+ default_port = int(os.getenv("FAST_API_PORT", "7860"))
+
+ parser = argparse.ArgumentParser(
+ description="Daily Storyteller FastAPI server")
+ parser.add_argument("--host", type=str,
+ default=default_host, help="Host address")
+ parser.add_argument("--port", type=int,
+ default=default_port, help="Port number")
+ parser.add_argument("--reload", action="store_true",
+ help="Reload code on change")
+
+ config = parser.parse_args()
+
+ uvicorn.run(
+ "bot_runner:app",
+ host=config.host,
+ port=config.port,
+ reload=config.reload
+ )
diff --git a/examples/storytelling-chatbot/src/server.py b/examples/storytelling-chatbot/src/server.py
deleted file mode 100644
index 89f67a15b..000000000
--- a/examples/storytelling-chatbot/src/server.py
+++ /dev/null
@@ -1,175 +0,0 @@
-import os
-import argparse
-import subprocess
-import atexit
-from pathlib import Path
-from typing import Optional
-
-from fastapi import FastAPI, Request, HTTPException
-from fastapi.middleware.cors import CORSMiddleware
-from fastapi.staticfiles import StaticFiles
-from fastapi.responses import FileResponse, JSONResponse
-
-from utils.daily_helpers import create_room as _create_room, get_token, get_name_from_url
-
-MAX_BOTS_PER_ROOM = 1
-
-# Bot sub-process dict for status reporting and concurrency control
-bot_procs = {}
-
-
-def cleanup():
- # Clean up function, just to be extra safe
- for proc in bot_procs.values():
- proc.terminate()
- proc.wait()
-
-
-atexit.register(cleanup)
-
-
-app = FastAPI()
-
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-# Mount the static directory
-STATIC_DIR = "frontend/out"
-
-app.mount("/static", StaticFiles(directory=STATIC_DIR, html=True), name="static")
-
-
-@app.post("/create")
-async def create_room(request: Request) -> JSONResponse:
- data = await request.json()
-
- if data.get('room_url') is not None:
- room_url = data.get('room_url')
- room_name = get_name_from_url(room_url)
- else:
- room_url, room_name = _create_room()
-
- token = get_token(room_url)
-
- return JSONResponse({"room_url": room_url, "room_name": room_name, "token": token})
-
-
-@app.post("/start")
-async def start_agent(request: Request) -> JSONResponse:
- data = await request.json()
-
- # Is this a webhook creation request?
- if "test" in data:
- return JSONResponse({"test": True})
-
- # Ensure the room property is present
- room_url = data.get('room_url')
- if not room_url:
- raise HTTPException(
- status_code=500,
- detail="Missing 'room' property in request data. Cannot start agent without a target room!")
-
- # Check if there is already an existing process running in this room
- num_bots_in_room = sum(
- 1 for proc in bot_procs.values() if proc[1] == room_url and proc[0].poll() is None)
- if num_bots_in_room >= MAX_BOTS_PER_ROOM:
- raise HTTPException(
- status_code=500, detail=f"Max bot limited reach for room: {room_url}")
-
- # Get the token for the room
- token = get_token(room_url)
-
- if not token:
- raise HTTPException(
- status_code=500, detail=f"Failed to get token for room: {room_url}")
-
- # Spawn a new agent, and join the user session
- # Note: this is mostly for demonstration purposes (refer to 'deployment' in README)
- try:
- proc = subprocess.Popen(
- [
- f"python3 -m bot -u {room_url} -t {token}"
- ],
- shell=True,
- bufsize=1,
- cwd=os.path.dirname(os.path.abspath(__file__))
- )
- bot_procs[proc.pid] = (proc, room_url)
- except Exception as e:
- raise HTTPException(
- status_code=500, detail=f"Failed to start subprocess: {e}")
-
- return JSONResponse({"bot_id": proc.pid, "room_url": room_url})
-
-
-@app.get("/status/{pid}")
-def get_status(pid: int):
- # Look up the subprocess
- proc = bot_procs.get(pid)
-
- # If the subprocess doesn't exist, return an error
- if not proc:
- raise HTTPException(
- status_code=404, detail=f"Bot with process id: {pid} not found")
-
- # Check the status of the subprocess
- if proc[0].poll() is None:
- status = "running"
- else:
- status = "finished"
-
- return JSONResponse({"bot_id": pid, "status": status})
-
-
-@app.get("/{path_name:path}", response_class=FileResponse)
-async def catch_all(path_name: Optional[str] = ""):
- if path_name == "":
- return FileResponse(f"{STATIC_DIR}/index.html")
-
- file_path = Path(STATIC_DIR) / (path_name or "")
-
- if file_path.is_file():
- return file_path
-
- html_file_path = file_path.with_suffix(".html")
- if html_file_path.is_file():
- return FileResponse(html_file_path)
-
- raise HTTPException(status_code=450, detail="Incorrect API call")
-
-
-if __name__ == "__main__":
- # Check environment variables
- required_env_vars = ['OPENAI_API_KEY', 'DAILY_API_KEY',
- 'FAL_KEY', 'ELEVENLABS_VOICE_ID', 'ELEVENLABS_API_KEY']
- for env_var in required_env_vars:
- if env_var not in os.environ:
- raise Exception(f"Missing environment variable: {env_var}.")
-
- import uvicorn
-
- default_host = os.getenv("HOST", "0.0.0.0")
- default_port = int(os.getenv("FAST_API_PORT", "7860"))
-
- parser = argparse.ArgumentParser(
- description="Daily Storyteller FastAPI server")
- parser.add_argument("--host", type=str,
- default=default_host, help="Host address")
- parser.add_argument("--port", type=int,
- default=default_port, help="Port number")
- parser.add_argument("--reload", action="store_true",
- help="Reload code on change")
-
- config = parser.parse_args()
-
- uvicorn.run(
- "server:app",
- host=config.host,
- port=config.port,
- reload=config.reload
- )