├── LICENSE
├── README.md
├── webapp
├── .env.example
├── .gitignore
├── app
│ ├── api
│ │ └── twilio
│ │ │ ├── numbers
│ │ │ └── route.ts
│ │ │ ├── route.ts
│ │ │ └── webhook-local
│ │ │ └── route.ts
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── components.json
├── components
│ ├── backend-tag.tsx
│ ├── call-interface.tsx
│ ├── checklist-and-config.tsx
│ ├── function-calls-panel.tsx
│ ├── phone-number-checklist.tsx
│ ├── session-configuration-panel.tsx
│ ├── tool-configuration-dialog.tsx
│ ├── top-bar.tsx
│ ├── transcript.tsx
│ ├── types.ts
│ └── ui
│ │ ├── alert.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── checkbox.tsx
│ │ ├── dialog.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ └── textarea.tsx
├── lib
│ ├── handle-realtime-event.ts
│ ├── tool-templates.ts
│ ├── twilio.ts
│ ├── use-backend-tools.ts
│ └── utils.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│ ├── next.svg
│ └── vercel.svg
├── tailwind.config.ts
└── tsconfig.json
└── websocket-server
├── .env.example
├── .gitignore
├── package-lock.json
├── package.json
├── src
├── functionHandlers.ts
├── server.ts
├── sessionManager.ts
├── twiml.xml
└── types.ts
└── tsconfig.json
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 OpenAI
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OpenAI Realtime API with Twilio Quickstart
2 |
3 | Combine OpenAI's Realtime API and Twilio's phone calling capability to build an AI calling assistant.
4 |
5 |
6 |
7 | ## Quick Setup
8 |
9 | Open three terminal windows:
10 |
11 | | Terminal | Purpose | Quick Reference (see below for more) |
12 | | -------- | ----------------------------- | ------------------------------------ |
13 | | 1 | To run the `webapp` | `npm run dev` |
14 | | 2 | To run the `websocket-server` | `npm run dev` |
15 | | 3 | To run `ngrok` | `ngrok http 8081` |
16 |
17 | Make sure all vars in `webapp/.env` and `websocket-server/.env` are set correctly. See [full setup](#full-setup) section for more.
18 |
19 | ## Overview
20 |
21 | This repo implements a phone calling assistant with the Realtime API and Twilio, and had two main parts: the `webapp`, and the `websocket-server`.
22 |
23 | 1. `webapp`: NextJS app to serve as a frontend for call configuration and transcripts
24 | 2. `websocket-server`: Express backend that handles connection from Twilio, connects it to the Realtime API, and forwards messages to the frontend
25 |
26 |
27 | Twilio uses TwiML (a form of XML) to specify how to handle a phone call. When a call comes in we tell Twilio to start a bi-directional stream to our backend, where we forward messages between the call and the Realtime API. (`{{WS_URL}}` is replaced with our websocket endpoint.)
28 |
29 | ```xml
30 |
31 |
32 |
33 |
34 | Connected
35 |
36 |
37 |
38 | Disconnected
39 |
40 | ```
41 |
42 | We use `ngrok` to make our server reachable by Twilio.
43 |
44 | ### Life of a phone call
45 |
46 | Setup
47 |
48 | 1. We run ngrok to make our server reachable by Twilio
49 | 1. We set the Twilio webhook to our ngrok address
50 | 1. Frontend connects to the backend (`wss://[your_backend]/logs`), ready for a call
51 |
52 | Call
53 |
54 | 1. Call is placed to Twilio-managed number
55 | 1. Twilio queries the webhook (`http://[your_backend]/twiml`) for TwiML instructions
56 | 1. Twilio opens a bi-directional stream to the backend (`wss://[your_backend]/call`)
57 | 1. The backend connects to the Realtime API, and starts forwarding messages:
58 | - between Twilio and the Realtime API
59 | - between the frontend and the Realtime API
60 |
61 | ### Function Calling
62 |
63 | This demo mocks out function calls so you can provide sample responses. In reality you could handle the function call, execute some code, and then supply the response back to the model.
64 |
65 | ## Full Setup
66 |
67 | 1. Make sure your [auth & env](#detailed-auth--env) is configured correctly.
68 |
69 | 2. Run webapp.
70 |
71 | ```shell
72 | cd webapp
73 | npm install
74 | npm run dev
75 | ```
76 |
77 | 3. Run websocket server.
78 |
79 | ```shell
80 | cd websocket-server
81 | npm install
82 | npm run dev
83 | ```
84 |
85 | ## Detailed Auth & Env
86 |
87 | ### OpenAI & Twilio
88 |
89 | Set your credentials in `webapp/.env` and `websocket-server` - see `webapp/.env.example` and `websocket-server.env.example` for reference.
90 |
91 | ### Ngrok
92 |
93 | Twilio needs to be able to reach your websocket server. If you're running it locally, your ports are inaccessible by default. [ngrok](https://ngrok.com/) can make them temporarily accessible.
94 |
95 | We have set the `websocket-server` to run on port `8081` by default, so that is the port we will be forwarding.
96 |
97 | ```shell
98 | ngrok http 8081
99 | ```
100 |
101 | Make note of the `Forwarding` URL. (e.g. `https://54c5-35-170-32-42.ngrok-free.app`)
102 |
103 | ### Websocket URL
104 |
105 | Your server should now be accessible at the `Forwarding` URL when run, so set the `PUBLIC_URL` in `websocket-server/.env`. See `websocket-server/.env.example` for reference.
106 |
107 | # Additional Notes
108 |
109 | This repo isn't polished, and the security practices leave some to be desired. Please only use this as reference, and make sure to audit your app with security and engineering before deploying!
110 |
--------------------------------------------------------------------------------
/webapp/.env.example:
--------------------------------------------------------------------------------
1 | # rename this to .env
2 |
3 | TWILIO_ACCOUNT_SID=""
4 | TWILIO_AUTH_TOKEN=""
--------------------------------------------------------------------------------
/webapp/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/webapp/app/api/twilio/numbers/route.ts:
--------------------------------------------------------------------------------
1 | import twilioClient from "@/lib/twilio";
2 |
3 | export async function GET() {
4 | if (!twilioClient) {
5 | return Response.json(
6 | { error: "Twilio client not initialized" },
7 | { status: 500 }
8 | );
9 | }
10 |
11 | const incomingPhoneNumbers = await twilioClient.incomingPhoneNumbers.list({
12 | limit: 20,
13 | });
14 | return Response.json(incomingPhoneNumbers);
15 | }
16 |
17 | export async function POST(req: Request) {
18 | if (!twilioClient) {
19 | return Response.json(
20 | { error: "Twilio client not initialized" },
21 | { status: 500 }
22 | );
23 | }
24 |
25 | const { phoneNumberSid, voiceUrl } = await req.json();
26 | const incomingPhoneNumber = await twilioClient
27 | .incomingPhoneNumbers(phoneNumberSid)
28 | .update({ voiceUrl });
29 |
30 | return Response.json(incomingPhoneNumber);
31 | }
32 |
--------------------------------------------------------------------------------
/webapp/app/api/twilio/route.ts:
--------------------------------------------------------------------------------
1 | export async function GET() {
2 | const credentialsSet = Boolean(
3 | process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN
4 | );
5 | return Response.json({ credentialsSet });
6 | }
7 |
--------------------------------------------------------------------------------
/webapp/app/api/twilio/webhook-local/route.ts:
--------------------------------------------------------------------------------
1 | export async function GET() {
2 | return Response.json({ webhookUrl: process.env.TWILIO_WEBHOOK_URL });
3 | }
4 |
--------------------------------------------------------------------------------
/webapp/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/openai/openai-realtime-twilio-demo/4e9f754f6349fd6ecb82c2f35e66691268cdf11d/webapp/app/favicon.ico
--------------------------------------------------------------------------------
/webapp/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 222.2 84% 4.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 222.2 84% 4.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 222.2 84% 4.9%;
13 | --primary: 222.2 47.4% 11.2%;
14 | --primary-foreground: 210 40% 98%;
15 | --secondary: 210 40% 96.1%;
16 | --secondary-foreground: 222.2 47.4% 11.2%;
17 | --muted: 210 40% 96.1%;
18 | --muted-foreground: 215.4 16.3% 46.9%;
19 | --accent: 210 40% 96.1%;
20 | --accent-foreground: 222.2 47.4% 11.2%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 210 40% 98%;
23 | --border: 214.3 31.8% 91.4%;
24 | --input: 214.3 31.8% 91.4%;
25 | --ring: 222.2 84% 4.9%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 222.2 84% 4.9%;
36 | --foreground: 210 40% 98%;
37 | --card: 222.2 84% 4.9%;
38 | --card-foreground: 210 40% 98%;
39 | --popover: 222.2 84% 4.9%;
40 | --popover-foreground: 210 40% 98%;
41 | --primary: 210 40% 98%;
42 | --primary-foreground: 222.2 47.4% 11.2%;
43 | --secondary: 217.2 32.6% 17.5%;
44 | --secondary-foreground: 210 40% 98%;
45 | --muted: 217.2 32.6% 17.5%;
46 | --muted-foreground: 215 20.2% 65.1%;
47 | --accent: 217.2 32.6% 17.5%;
48 | --accent-foreground: 210 40% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 210 40% 98%;
51 | --border: 217.2 32.6% 17.5%;
52 | --input: 217.2 32.6% 17.5%;
53 | --ring: 212.7 26.8% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
62 | @layer base {
63 | * {
64 | @apply border-border;
65 | }
66 | body {
67 | @apply bg-background text-foreground;
68 | }
69 | }
--------------------------------------------------------------------------------
/webapp/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export const metadata: Metadata = {
8 | title: "OpenAI Realtime + Twilio",
9 | description:
10 | "Sample phone call assistant app for OpenAI Realtime API and Twilio",
11 | };
12 |
13 | export default function RootLayout({
14 | children,
15 | }: Readonly<{
16 | children: React.ReactNode;
17 | }>) {
18 | return (
19 |
20 |
{children}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/webapp/app/page.tsx:
--------------------------------------------------------------------------------
1 | import CallInterface from "@/components/call-interface";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/webapp/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/webapp/components/backend-tag.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export const BackendTag = () => (
4 |
5 | backend
6 |
7 | );
8 |
--------------------------------------------------------------------------------
/webapp/components/call-interface.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useState, useEffect } from "react";
4 | import TopBar from "@/components/top-bar";
5 | import ChecklistAndConfig from "@/components/checklist-and-config";
6 | import SessionConfigurationPanel from "@/components/session-configuration-panel";
7 | import Transcript from "@/components/transcript";
8 | import FunctionCallsPanel from "@/components/function-calls-panel";
9 | import { Item } from "@/components/types";
10 | import handleRealtimeEvent from "@/lib/handle-realtime-event";
11 | import PhoneNumberChecklist from "@/components/phone-number-checklist";
12 |
13 | const CallInterface = () => {
14 | const [selectedPhoneNumber, setSelectedPhoneNumber] = useState("");
15 | const [allConfigsReady, setAllConfigsReady] = useState(false);
16 | const [items, setItems] = useState- ([]);
17 | const [callStatus, setCallStatus] = useState("disconnected");
18 | const [ws, setWs] = useState(null);
19 |
20 | useEffect(() => {
21 | if (allConfigsReady && !ws) {
22 | const newWs = new WebSocket("ws://localhost:8081/logs");
23 |
24 | newWs.onopen = () => {
25 | console.log("Connected to logs websocket");
26 | setCallStatus("connected");
27 | };
28 |
29 | newWs.onmessage = (event) => {
30 | const data = JSON.parse(event.data);
31 | console.log("Received logs event:", data);
32 | handleRealtimeEvent(data, setItems);
33 | };
34 |
35 | newWs.onclose = () => {
36 | console.log("Logs websocket disconnected");
37 | setWs(null);
38 | setCallStatus("disconnected");
39 | };
40 |
41 | setWs(newWs);
42 | }
43 | }, [allConfigsReady, ws]);
44 |
45 | return (
46 |
47 |
53 |
54 |
55 |
56 | {/* Left Column */}
57 |
58 | {
61 | if (ws && ws.readyState === WebSocket.OPEN) {
62 | const updateEvent = {
63 | type: "session.update",
64 | session: {
65 | ...config,
66 | },
67 | };
68 | console.log("Sending update event:", updateEvent);
69 | ws.send(JSON.stringify(updateEvent));
70 | }
71 | }}
72 | />
73 |
74 |
75 | {/* Middle Column: Transcript */}
76 |
84 |
85 | {/* Right Column: Function Calls */}
86 |
87 |
88 |
89 |
90 |
91 |
92 | );
93 | };
94 |
95 | export default CallInterface;
96 |
--------------------------------------------------------------------------------
/webapp/components/checklist-and-config.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useEffect, useState, useMemo } from "react";
4 | import {
5 | Dialog,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogDescription,
10 | } from "@/components/ui/dialog";
11 | import { Input } from "@/components/ui/input";
12 | import { Button } from "@/components/ui/button";
13 | import { Circle, CheckCircle, Loader2 } from "lucide-react";
14 | import { PhoneNumber } from "@/components/types";
15 | import {
16 | Select,
17 | SelectContent,
18 | SelectItem,
19 | SelectTrigger,
20 | SelectValue,
21 | } from "@/components/ui/select";
22 |
23 | export default function ChecklistAndConfig({
24 | ready,
25 | setReady,
26 | selectedPhoneNumber,
27 | setSelectedPhoneNumber,
28 | }: {
29 | ready: boolean;
30 | setReady: (val: boolean) => void;
31 | selectedPhoneNumber: string;
32 | setSelectedPhoneNumber: (val: string) => void;
33 | }) {
34 | const [hasCredentials, setHasCredentials] = useState(false);
35 | const [phoneNumbers, setPhoneNumbers] = useState([]);
36 | const [currentNumberSid, setCurrentNumberSid] = useState("");
37 | const [currentVoiceUrl, setCurrentVoiceUrl] = useState("");
38 |
39 | const [publicUrl, setPublicUrl] = useState("");
40 | const [localServerUp, setLocalServerUp] = useState(false);
41 | const [publicUrlAccessible, setPublicUrlAccessible] = useState(false);
42 |
43 | const [allChecksPassed, setAllChecksPassed] = useState(false);
44 | const [webhookLoading, setWebhookLoading] = useState(false);
45 | const [ngrokLoading, setNgrokLoading] = useState(false);
46 |
47 | const appendedTwimlUrl = publicUrl ? `${publicUrl}/twiml` : "";
48 | const isWebhookMismatch =
49 | appendedTwimlUrl && currentVoiceUrl && appendedTwimlUrl !== currentVoiceUrl;
50 |
51 | useEffect(() => {
52 | let polling = true;
53 |
54 | const pollChecks = async () => {
55 | try {
56 | // 1. Check credentials
57 | let res = await fetch("/api/twilio");
58 | if (!res.ok) throw new Error("Failed credentials check");
59 | const credData = await res.json();
60 | setHasCredentials(!!credData?.credentialsSet);
61 |
62 | // 2. Fetch numbers
63 | res = await fetch("/api/twilio/numbers");
64 | if (!res.ok) throw new Error("Failed to fetch phone numbers");
65 | const numbersData = await res.json();
66 | if (Array.isArray(numbersData) && numbersData.length > 0) {
67 | setPhoneNumbers(numbersData);
68 | // If currentNumberSid not set or not in the list, use first
69 | const selected =
70 | numbersData.find((p: PhoneNumber) => p.sid === currentNumberSid) ||
71 | numbersData[0];
72 | setCurrentNumberSid(selected.sid);
73 | setCurrentVoiceUrl(selected.voiceUrl || "");
74 | setSelectedPhoneNumber(selected.friendlyName || "");
75 | }
76 |
77 | // 3. Check local server & public URL
78 | let foundPublicUrl = "";
79 | try {
80 | const resLocal = await fetch("http://localhost:8081/public-url");
81 | if (resLocal.ok) {
82 | const pubData = await resLocal.json();
83 | foundPublicUrl = pubData?.publicUrl || "";
84 | setLocalServerUp(true);
85 | setPublicUrl(foundPublicUrl);
86 | } else {
87 | throw new Error("Local server not responding");
88 | }
89 | } catch {
90 | setLocalServerUp(false);
91 | setPublicUrl("");
92 | }
93 | } catch (err) {
94 | console.error(err);
95 | }
96 | };
97 |
98 | pollChecks();
99 | const intervalId = setInterval(() => polling && pollChecks(), 1000);
100 | return () => {
101 | polling = false;
102 | clearInterval(intervalId);
103 | };
104 | }, [currentNumberSid, setSelectedPhoneNumber]);
105 |
106 | const updateWebhook = async () => {
107 | if (!currentNumberSid || !appendedTwimlUrl) return;
108 | try {
109 | setWebhookLoading(true);
110 | const res = await fetch("/api/twilio/numbers", {
111 | method: "POST",
112 | headers: { "Content-Type": "application/json" },
113 | body: JSON.stringify({
114 | phoneNumberSid: currentNumberSid,
115 | voiceUrl: appendedTwimlUrl,
116 | }),
117 | });
118 | if (!res.ok) throw new Error("Failed to update webhook");
119 | setCurrentVoiceUrl(appendedTwimlUrl);
120 | } catch (err) {
121 | console.error(err);
122 | } finally {
123 | setWebhookLoading(false);
124 | }
125 | };
126 |
127 | const checkNgrok = async () => {
128 | if (!localServerUp || !publicUrl) return;
129 | setNgrokLoading(true);
130 | let success = false;
131 | for (let i = 0; i < 5; i++) {
132 | try {
133 | const resTest = await fetch(publicUrl + "/public-url");
134 | if (resTest.ok) {
135 | setPublicUrlAccessible(true);
136 | success = true;
137 | break;
138 | }
139 | } catch {
140 | // retry
141 | }
142 | if (i < 4) {
143 | await new Promise((r) => setTimeout(r, 3000));
144 | }
145 | }
146 | if (!success) {
147 | setPublicUrlAccessible(false);
148 | }
149 | setNgrokLoading(false);
150 | };
151 |
152 | const checklist = useMemo(() => {
153 | return [
154 | {
155 | label: "Set up Twilio account",
156 | done: hasCredentials,
157 | description: "Then update account details in webapp/.env",
158 | field: (
159 |
165 | ),
166 | },
167 | {
168 | label: "Set up Twilio phone number",
169 | done: phoneNumbers.length > 0,
170 | description: "Costs around $1.15/month",
171 | field:
172 | phoneNumbers.length > 0 ? (
173 | phoneNumbers.length === 1 ? (
174 |
175 | ) : (
176 |
198 | )
199 | ) : (
200 |
211 | ),
212 | },
213 | {
214 | label: "Start local WebSocket server",
215 | done: localServerUp,
216 | description: "cd websocket-server && npm run dev",
217 | field: null,
218 | },
219 | {
220 | label: "Start ngrok",
221 | done: publicUrlAccessible,
222 | description: "Then set ngrok URL in websocket-server/.env",
223 | field: (
224 |
225 |
226 |
227 |
228 |
229 |
241 |
242 |
243 | ),
244 | },
245 | {
246 | label: "Update Twilio webhook URL",
247 | done: !!publicUrl && !isWebhookMismatch,
248 | description: "Can also be done manually in Twilio console",
249 | field: (
250 |
251 |
252 |
253 |
254 |
255 |
266 |
267 |
268 | ),
269 | },
270 | ];
271 | }, [
272 | hasCredentials,
273 | phoneNumbers,
274 | currentNumberSid,
275 | localServerUp,
276 | publicUrl,
277 | publicUrlAccessible,
278 | currentVoiceUrl,
279 | isWebhookMismatch,
280 | appendedTwimlUrl,
281 | webhookLoading,
282 | ngrokLoading,
283 | setSelectedPhoneNumber,
284 | ]);
285 |
286 | useEffect(() => {
287 | setAllChecksPassed(checklist.every((item) => item.done));
288 | }, [checklist]);
289 |
290 | useEffect(() => {
291 | if (!ready) {
292 | checkNgrok();
293 | }
294 | }, [localServerUp, ready]);
295 |
296 | useEffect(() => {
297 | if (!allChecksPassed) {
298 | setReady(false);
299 | }
300 | }, [allChecksPassed, setReady]);
301 |
302 | const handleDone = () => setReady(true);
303 |
304 | return (
305 |
351 | );
352 | }
353 |
--------------------------------------------------------------------------------
/webapp/components/function-calls-panel.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3 | import { ScrollArea } from "@/components/ui/scroll-area";
4 | import { Input } from "@/components/ui/input";
5 | import { Badge } from "@/components/ui/badge";
6 | import { Button } from "@/components/ui/button";
7 | import { Item } from "@/components/types";
8 |
9 | type FunctionCallsPanelProps = {
10 | items: Item[];
11 | ws?: WebSocket | null; // pass down ws from parent
12 | };
13 |
14 | const FunctionCallsPanel: React.FC = ({
15 | items,
16 | ws,
17 | }) => {
18 | const [responses, setResponses] = useState>({});
19 |
20 | // Filter function_call items
21 | const functionCalls = items.filter((it) => it.type === "function_call");
22 |
23 | // For each function_call, check for a corresponding function_call_output
24 | const functionCallsWithStatus = functionCalls.map((call) => {
25 | const outputs = items.filter(
26 | (it) => it.type === "function_call_output" && it.call_id === call.call_id
27 | );
28 | const outputItem = outputs[0];
29 | const completed = call.status === "completed" || !!outputItem;
30 | const response = outputItem ? outputItem.output : undefined;
31 | return {
32 | ...call,
33 | completed,
34 | response,
35 | };
36 | });
37 |
38 | const handleChange = (call_id: string, value: string) => {
39 | setResponses((prev) => ({ ...prev, [call_id]: value }));
40 | };
41 |
42 | const handleSubmit = (call: Item) => {
43 | if (!ws || ws.readyState !== WebSocket.OPEN) return;
44 | const call_id = call.call_id || "";
45 | ws.send(
46 | JSON.stringify({
47 | type: "conversation.item.create",
48 | item: {
49 | type: "function_call_output",
50 | call_id: call_id,
51 | output: JSON.stringify(responses[call_id] || ""),
52 | },
53 | })
54 | );
55 | // Ask the model to continue after providing the tool response
56 | ws.send(JSON.stringify({ type: "response.create" }));
57 | };
58 |
59 | return (
60 |
61 |
62 |
63 | Function Calls
64 |
65 |
66 |
67 |
68 |
69 | {functionCallsWithStatus.map((call) => (
70 |
74 |
75 |
{call.name}
76 |
77 | {call.completed ? "Completed" : "Pending"}
78 |
79 |
80 |
81 |
82 | {JSON.stringify(call.params)}
83 |
84 |
85 | {!call.completed ? (
86 |
87 |
91 | handleChange(call.call_id || "", e.target.value)
92 | }
93 | />
94 |
103 |
104 | ) : (
105 |
106 | {JSON.stringify(JSON.parse(call.response || ""))}
107 |
108 | )}
109 |
110 | ))}
111 |
112 |
113 |
114 |
115 | );
116 | };
117 |
118 | export default FunctionCallsPanel;
119 |
--------------------------------------------------------------------------------
/webapp/components/phone-number-checklist.tsx:
--------------------------------------------------------------------------------
1 | // PhoneNumberChecklist.tsx
2 | "use client";
3 |
4 | import React, { useState } from "react";
5 | import { Card } from "@/components/ui/card";
6 | import { CheckCircle, Circle, Eye, EyeOff } from "lucide-react";
7 | import { Button } from "@/components/ui/button";
8 |
9 | type PhoneNumberChecklistProps = {
10 | selectedPhoneNumber: string;
11 | allConfigsReady: boolean;
12 | setAllConfigsReady: (ready: boolean) => void;
13 | };
14 |
15 | const PhoneNumberChecklist: React.FC = ({
16 | selectedPhoneNumber,
17 | allConfigsReady,
18 | setAllConfigsReady,
19 | }) => {
20 | const [isVisible, setIsVisible] = useState(true);
21 |
22 | return (
23 |
24 |
25 |
Number
26 |
27 |
28 | {isVisible ? selectedPhoneNumber || "None" : "••••••••••"}
29 |
30 |
42 |
43 |
44 |
45 |
46 | {allConfigsReady ? (
47 |
48 | ) : (
49 |
50 | )}
51 |
52 | {allConfigsReady ? "Setup Ready" : "Setup Not Ready"}
53 |
54 |
55 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default PhoneNumberChecklist;
68 |
--------------------------------------------------------------------------------
/webapp/components/session-configuration-panel.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3 | import { ScrollArea } from "@/components/ui/scroll-area";
4 | import {
5 | Select,
6 | SelectContent,
7 | SelectItem,
8 | SelectTrigger,
9 | SelectValue,
10 | } from "@/components/ui/select";
11 | import { Textarea } from "@/components/ui/textarea";
12 | import { Button } from "@/components/ui/button";
13 | import { Plus, Edit, Trash, Check, AlertCircle } from "lucide-react";
14 | import { toolTemplates } from "@/lib/tool-templates";
15 | import { ToolConfigurationDialog } from "./tool-configuration-dialog";
16 | import { BackendTag } from "./backend-tag";
17 | import { useBackendTools } from "@/lib/use-backend-tools";
18 |
19 | interface SessionConfigurationPanelProps {
20 | callStatus: string;
21 | onSave: (config: any) => void;
22 | }
23 |
24 | const SessionConfigurationPanel: React.FC = ({
25 | callStatus,
26 | onSave,
27 | }) => {
28 | const [instructions, setInstructions] = useState(
29 | "You are a helpful assistant in a phone call."
30 | );
31 | const [voice, setVoice] = useState("ash");
32 | const [tools, setTools] = useState([]);
33 | const [editingIndex, setEditingIndex] = useState(null);
34 | const [editingSchemaStr, setEditingSchemaStr] = useState("");
35 | const [isJsonValid, setIsJsonValid] = useState(true);
36 | const [openDialog, setOpenDialog] = useState(false);
37 | const [selectedTemplate, setSelectedTemplate] = useState("");
38 | const [saveStatus, setSaveStatus] = useState<
39 | "idle" | "saving" | "saved" | "error"
40 | >("idle");
41 | const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
42 |
43 | // Custom hook to fetch backend tools every 3 seconds
44 | const backendTools = useBackendTools("http://localhost:8081/tools", 3000);
45 |
46 | // Track changes to determine if there are unsaved modifications
47 | useEffect(() => {
48 | setHasUnsavedChanges(true);
49 | }, [instructions, voice, tools]);
50 |
51 | // Reset save status after a delay when saved
52 | useEffect(() => {
53 | if (saveStatus === "saved") {
54 | const timer = setTimeout(() => {
55 | setSaveStatus("idle");
56 | }, 3000);
57 | return () => clearTimeout(timer);
58 | }
59 | }, [saveStatus]);
60 |
61 | const handleSave = async () => {
62 | setSaveStatus("saving");
63 | try {
64 | await onSave({
65 | instructions,
66 | voice,
67 | tools: tools.map((tool) => JSON.parse(tool)),
68 | });
69 | setSaveStatus("saved");
70 | setHasUnsavedChanges(false);
71 | } catch (error) {
72 | setSaveStatus("error");
73 | }
74 | };
75 |
76 | const handleAddTool = () => {
77 | setEditingIndex(null);
78 | setEditingSchemaStr("");
79 | setSelectedTemplate("");
80 | setIsJsonValid(true);
81 | setOpenDialog(true);
82 | };
83 |
84 | const handleEditTool = (index: number) => {
85 | setEditingIndex(index);
86 | setEditingSchemaStr(tools[index] || "");
87 | setSelectedTemplate("");
88 | setIsJsonValid(true);
89 | setOpenDialog(true);
90 | };
91 |
92 | const handleDeleteTool = (index: number) => {
93 | const newTools = [...tools];
94 | newTools.splice(index, 1);
95 | setTools(newTools);
96 | };
97 |
98 | const handleDialogSave = () => {
99 | try {
100 | JSON.parse(editingSchemaStr);
101 | } catch {
102 | return;
103 | }
104 | const newTools = [...tools];
105 | if (editingIndex === null) {
106 | newTools.push(editingSchemaStr);
107 | } else {
108 | newTools[editingIndex] = editingSchemaStr;
109 | }
110 | setTools(newTools);
111 | setOpenDialog(false);
112 | };
113 |
114 | const handleTemplateChange = (val: string) => {
115 | setSelectedTemplate(val);
116 |
117 | // Determine if the selected template is from local or backend
118 | let templateObj =
119 | toolTemplates.find((t) => t.name === val) ||
120 | backendTools.find((t: any) => t.name === val);
121 |
122 | if (templateObj) {
123 | setEditingSchemaStr(JSON.stringify(templateObj, null, 2));
124 | setIsJsonValid(true);
125 | }
126 | };
127 |
128 | const onSchemaChange = (value: string) => {
129 | setEditingSchemaStr(value);
130 | try {
131 | JSON.parse(value);
132 | setIsJsonValid(true);
133 | } catch {
134 | setIsJsonValid(false);
135 | }
136 | };
137 |
138 | const getToolNameFromSchema = (schema: string): string => {
139 | try {
140 | const parsed = JSON.parse(schema);
141 | return parsed?.name || "Untitled Tool";
142 | } catch {
143 | return "Invalid JSON";
144 | }
145 | };
146 |
147 | const isBackendTool = (name: string): boolean => {
148 | return backendTools.some((t: any) => t.name === name);
149 | };
150 |
151 | return (
152 |
153 |
154 |
155 |
156 | Session Configuration
157 |
158 |
159 | {saveStatus === "error" ? (
160 |
161 |
162 | Save failed
163 |
164 | ) : hasUnsavedChanges ? (
165 |
Not saved
166 | ) : (
167 |
168 |
169 | Saved
170 |
171 | )}
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
182 |
189 |
190 |
191 |
192 |
204 |
205 |
206 |
207 |
208 |
209 | {tools.map((tool, index) => {
210 | const name = getToolNameFromSchema(tool);
211 | const backend = isBackendTool(name);
212 | return (
213 |
217 |
218 | {name}
219 | {backend && }
220 |
221 |
222 |
230 |
238 |
239 |
240 | );
241 | })}
242 |
250 |
251 |
252 |
253 |
271 |
272 |
273 |
274 |
275 |
287 |
288 | );
289 | };
290 |
291 | export default SessionConfigurationPanel;
292 |
--------------------------------------------------------------------------------
/webapp/components/tool-configuration-dialog.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button } from "@/components/ui/button";
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | } from "@/components/ui/dialog";
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectItem,
14 | SelectTrigger,
15 | SelectValue,
16 | } from "@/components/ui/select";
17 | import { Textarea } from "@/components/ui/textarea";
18 | import { toolTemplates } from "@/lib/tool-templates";
19 | import { BackendTag } from "./backend-tag";
20 |
21 | interface ToolConfigurationDialogProps {
22 | open: boolean;
23 | onOpenChange: (open: boolean) => void;
24 | editingIndex: number | null;
25 | selectedTemplate: string;
26 | editingSchemaStr: string;
27 | isJsonValid: boolean;
28 | onTemplateChange: (val: string) => void;
29 | onSchemaChange: (val: string) => void;
30 | onSave: () => void;
31 | backendTools: any[]; // schemas returned from the server
32 | }
33 |
34 | export const ToolConfigurationDialog: React.FC<
35 | ToolConfigurationDialogProps
36 | > = ({
37 | open,
38 | onOpenChange,
39 | editingIndex,
40 | selectedTemplate,
41 | editingSchemaStr,
42 | isJsonValid,
43 | onTemplateChange,
44 | onSchemaChange,
45 | onSave,
46 | backendTools,
47 | }) => {
48 | // Combine local templates and backend templates
49 | const localTemplateOptions = toolTemplates.map((template) => ({
50 | ...template,
51 | source: "local",
52 | }));
53 |
54 | const backendTemplateOptions = backendTools.map((t: any) => ({
55 | ...t,
56 | source: "backend",
57 | }));
58 |
59 | const allTemplates = [...localTemplateOptions, ...backendTemplateOptions];
60 |
61 | return (
62 |
103 | );
104 | };
105 |
--------------------------------------------------------------------------------
/webapp/components/top-bar.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Button } from "@/components/ui/button";
3 | import { BookOpen, FileText } from "lucide-react";
4 | import Link from "next/link";
5 |
6 | const TopBar = () => {
7 | return (
8 |
9 |
10 |
18 |
OpenAI Call Assistant
19 |
20 |
21 |
32 |
33 |
34 | );
35 | };
36 |
37 | export default TopBar;
38 |
--------------------------------------------------------------------------------
/webapp/components/transcript.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import { Card, CardContent } from "@/components/ui/card";
3 | import { ScrollArea } from "@/components/ui/scroll-area";
4 | import { Bot, Phone, MessageSquare, Wrench } from "lucide-react";
5 | import { Item } from "@/components/types";
6 |
7 | type TranscriptProps = {
8 | items: Item[];
9 | };
10 |
11 | const Transcript: React.FC = ({ items }) => {
12 | const scrollRef = useRef(null);
13 |
14 | useEffect(() => {
15 | scrollRef.current?.scrollIntoView({ behavior: "smooth" });
16 | }, [items]);
17 |
18 | // Show messages, function calls, and function call outputs in the transcript
19 | const transcriptItems = items.filter(
20 | (it) =>
21 | it.type === "message" ||
22 | it.type === "function_call" ||
23 | it.type === "function_call_output"
24 | );
25 |
26 | return (
27 |
28 |
29 | {transcriptItems.length === 0 && (
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | No messages yet
38 |
39 |
40 | Start a call to see the transcript
41 |
42 |
43 |
44 |
45 | )}
46 |
47 |
48 | {transcriptItems.map((msg, i) => {
49 | const isUser = msg.role === "user";
50 | const isTool = msg.role === "tool";
51 | // Default to assistant if not user or tool
52 | const Icon = isUser ? Phone : isTool ? Wrench : Bot;
53 |
54 | // Combine all text parts into a single string for display
55 | const displayText = msg.content
56 | ? msg.content.map((c) => c.text).join("")
57 | : "";
58 |
59 | return (
60 |
61 |
70 |
71 |
72 |
73 |
74 |
79 | {isUser
80 | ? "Caller"
81 | : isTool
82 | ? "Tool Response"
83 | : "Assistant"}
84 |
85 |
86 | {msg.timestamp}
87 |
88 |
89 |
90 | {displayText}
91 |
92 |
93 |
94 | );
95 | })}
96 |
97 |
98 |
99 |
100 |
101 | );
102 | };
103 |
104 | export default Transcript;
105 |
--------------------------------------------------------------------------------
/webapp/components/types.ts:
--------------------------------------------------------------------------------
1 | export type Item = {
2 | id: string;
3 | object: string; // e.g. "realtime.item"
4 | type: "message" | "function_call" | "function_call_output";
5 | timestamp?: string;
6 | status?: "running" | "completed";
7 | // For "message" items
8 | role?: "system" | "user" | "assistant" | "tool";
9 | content?: { type: string; text: string }[];
10 | // For "function_call" items
11 | name?: string;
12 | call_id?: string;
13 | params?: Record;
14 | // For "function_call_output" items
15 | output?: string;
16 | };
17 |
18 | export interface PhoneNumber {
19 | sid: string;
20 | friendlyName: string;
21 | voiceUrl?: string;
22 | }
23 |
24 | export type FunctionCall = {
25 | name: string;
26 | params: Record;
27 | completed?: boolean;
28 | response?: string;
29 | status?: string;
30 | call_id?: string; // ensure each call has a call_id
31 | };
32 |
--------------------------------------------------------------------------------
/webapp/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/webapp/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/webapp/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/webapp/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/webapp/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { Check } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/webapp/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { X } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ));
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 | {/*
48 |
49 | Close
50 | */}
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName;
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | );
68 | DialogHeader.displayName = "DialogHeader";
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | );
82 | DialogFooter.displayName = "DialogFooter";
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ));
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | };
123 |
--------------------------------------------------------------------------------
/webapp/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/webapp/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/webapp/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/webapp/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/webapp/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/webapp/lib/handle-realtime-event.ts:
--------------------------------------------------------------------------------
1 | import { Item } from "@/components/types";
2 |
3 | export default function handleRealtimeEvent(
4 | ev: any,
5 | setItems: React.Dispatch>
6 | ) {
7 | // Helper function to create a new item with default fields
8 | function createNewItem(base: Partial- ): Item {
9 | return {
10 | object: "realtime.item",
11 | timestamp: new Date().toLocaleTimeString(),
12 | ...base,
13 | } as Item;
14 | }
15 |
16 | // Helper function to update an existing item if found by id, or add a new one if not.
17 | // We can also pass partial updates to reduce repetitive code.
18 | function updateOrAddItem(id: string, updates: Partial
- ): void {
19 | setItems((prev) => {
20 | const idx = prev.findIndex((m) => m.id === id);
21 | if (idx >= 0) {
22 | const updated = [...prev];
23 | updated[idx] = { ...updated[idx], ...updates };
24 | return updated;
25 | } else {
26 | return [...prev, createNewItem({ id, ...updates })];
27 | }
28 | });
29 | }
30 |
31 | const { type } = ev;
32 |
33 | switch (type) {
34 | case "session.created": {
35 | // Starting a new session, clear all items
36 | setItems([]);
37 | break;
38 | }
39 |
40 | case "input_audio_buffer.speech_started": {
41 | // Create a user message item with running status and placeholder content
42 | const { item_id } = ev;
43 | setItems((prev) => [
44 | ...prev,
45 | createNewItem({
46 | id: item_id,
47 | type: "message",
48 | role: "user",
49 | content: [{ type: "text", text: "..." }],
50 | status: "running",
51 | }),
52 | ]);
53 | break;
54 | }
55 |
56 | case "conversation.item.created": {
57 | const { item } = ev;
58 | if (item.type === "message") {
59 | // A completed message from user or assistant
60 | const updatedContent =
61 | item.content && item.content.length > 0 ? item.content : [];
62 | setItems((prev) => {
63 | const idx = prev.findIndex((m) => m.id === item.id);
64 | if (idx >= 0) {
65 | const updated = [...prev];
66 | updated[idx] = {
67 | ...updated[idx],
68 | ...item,
69 | content: updatedContent,
70 | status: "completed",
71 | timestamp:
72 | updated[idx].timestamp || new Date().toLocaleTimeString(),
73 | };
74 | return updated;
75 | } else {
76 | return [
77 | ...prev,
78 | createNewItem({
79 | ...item,
80 | content: updatedContent,
81 | status: "completed",
82 | }),
83 | ];
84 | }
85 | });
86 | }
87 | // NOTE: We no longer handle function_call items here.
88 | // The handling of function_call items has been moved to the "response.output_item.done" event.
89 | else if (item.type === "function_call_output") {
90 | // Function call output item created
91 | // Add the output item and mark the corresponding function_call as completed
92 | // Also display in transcript as tool message with the response
93 | setItems((prev) => {
94 | const newItems = [
95 | ...prev,
96 | createNewItem({
97 | ...item,
98 | role: "tool",
99 | content: [
100 | {
101 | type: "text",
102 | text: `Function call response: ${item.output}`,
103 | },
104 | ],
105 | status: "completed",
106 | }),
107 | ];
108 |
109 | return newItems.map((m) =>
110 | m.call_id === item.call_id && m.type === "function_call"
111 | ? { ...m, status: "completed" }
112 | : m
113 | );
114 | });
115 | }
116 | break;
117 | }
118 |
119 | case "conversation.item.input_audio_transcription.completed": {
120 | // Update the user message with the final transcript
121 | const { item_id, transcript } = ev;
122 | setItems((prev) =>
123 | prev.map((m) =>
124 | m.id === item_id && m.type === "message" && m.role === "user"
125 | ? {
126 | ...m,
127 | content: [{ type: "text", text: transcript }],
128 | status: "completed",
129 | }
130 | : m
131 | )
132 | );
133 | break;
134 | }
135 |
136 | case "response.content_part.added": {
137 | const { item_id, part, output_index } = ev;
138 | // Append new content to the assistant message if output_index == 0
139 | if (part.type === "text" && output_index === 0) {
140 | setItems((prev) => {
141 | const idx = prev.findIndex((m) => m.id === item_id);
142 | if (idx >= 0) {
143 | const updated = [...prev];
144 | const existingContent = updated[idx].content || [];
145 | updated[idx] = {
146 | ...updated[idx],
147 | content: [
148 | ...existingContent,
149 | { type: part.type, text: part.text },
150 | ],
151 | };
152 | return updated;
153 | } else {
154 | // If the item doesn't exist yet, create it as a running assistant message
155 | return [
156 | ...prev,
157 | createNewItem({
158 | id: item_id,
159 | type: "message",
160 | role: "assistant",
161 | content: [{ type: part.type, text: part.text }],
162 | status: "running",
163 | }),
164 | ];
165 | }
166 | });
167 | }
168 | break;
169 | }
170 |
171 | case "response.audio_transcript.delta": {
172 | // Streaming transcript text (assistant)
173 | const { item_id, delta, output_index } = ev;
174 | if (output_index === 0 && delta) {
175 | setItems((prev) => {
176 | const idx = prev.findIndex((m) => m.id === item_id);
177 | if (idx >= 0) {
178 | const updated = [...prev];
179 | const existingContent = updated[idx].content || [];
180 | updated[idx] = {
181 | ...updated[idx],
182 | content: [...existingContent, { type: "text", text: delta }],
183 | };
184 | return updated;
185 | } else {
186 | return [
187 | ...prev,
188 | createNewItem({
189 | id: item_id,
190 | type: "message",
191 | role: "assistant",
192 | content: [{ type: "text", text: delta }],
193 | status: "running",
194 | }),
195 | ];
196 | }
197 | });
198 | }
199 | break;
200 | }
201 |
202 | case "response.output_item.done": {
203 | const { item } = ev;
204 | if (item.type === "function_call") {
205 | // A new function call item
206 | // Display it in the transcript as an assistant message indicating a function is being requested
207 | console.log("function_call", item);
208 | setItems((prev) => [
209 | ...prev,
210 | createNewItem({
211 | ...item,
212 | role: "assistant",
213 | content: [
214 | {
215 | type: "text",
216 | text: `${item.name}(${JSON.stringify(
217 | JSON.parse(item.arguments)
218 | )})`,
219 | },
220 | ],
221 | status: "running",
222 | }),
223 | ]);
224 | }
225 | break;
226 | }
227 |
228 | default:
229 | break;
230 | }
231 | }
232 |
--------------------------------------------------------------------------------
/webapp/lib/tool-templates.ts:
--------------------------------------------------------------------------------
1 | export const toolTemplates = [
2 | {
3 | name: "get_weather",
4 | type: "function",
5 | description: "Get the current weather",
6 | parameters: {
7 | type: "object",
8 | properties: {
9 | location: { type: "string" },
10 | },
11 | },
12 | },
13 | {
14 | name: "ping_no_args",
15 | type: "function",
16 | description: "A simple ping tool with no arguments",
17 | parameters: {
18 | type: "object",
19 | properties: {},
20 | },
21 | },
22 | {
23 | name: "get_user_nested_args",
24 | type: "function",
25 | description: "Fetch user profile by nested identifier",
26 | parameters: {
27 | type: "object",
28 | properties: {
29 | user: {
30 | type: "object",
31 | properties: {
32 | id: { type: "string" },
33 | metadata: {
34 | type: "object",
35 | properties: {
36 | region: { type: "string" },
37 | role: { type: "string" },
38 | },
39 | },
40 | },
41 | },
42 | },
43 | },
44 | },
45 | {
46 | name: "calculate_route_more_properties",
47 | type: "function",
48 | description: "Calculate travel route with multiple parameters",
49 | parameters: {
50 | type: "object",
51 | properties: {
52 | start: { type: "string" },
53 | end: { type: "string" },
54 | mode: { type: "string", enum: ["car", "bike", "walk"] },
55 | options: {
56 | type: "object",
57 | properties: {
58 | avoid_highways: { type: "boolean" },
59 | scenic_route: { type: "boolean" },
60 | },
61 | },
62 | },
63 | },
64 | },
65 | ];
66 |
--------------------------------------------------------------------------------
/webapp/lib/twilio.ts:
--------------------------------------------------------------------------------
1 | import "server-only";
2 | import twilio from "twilio";
3 |
4 | const { TWILIO_ACCOUNT_SID: accountSid, TWILIO_AUTH_TOKEN: authToken } =
5 | process.env;
6 |
7 | if (!accountSid || !authToken) {
8 | console.warn("Twilio credentials not set. Twilio client will be disabled.");
9 | }
10 |
11 | export const twilioClient =
12 | accountSid && authToken ? twilio(accountSid, authToken) : null;
13 | export default twilioClient;
14 |
--------------------------------------------------------------------------------
/webapp/lib/use-backend-tools.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | // Custom hook to fetch backend tools repeatedly
4 | export function useBackendTools(url: string, intervalMs: number) {
5 | const [tools, setTools] = useState([]);
6 |
7 | useEffect(() => {
8 | let isMounted = true;
9 |
10 | const fetchTools = () => {
11 | fetch(url)
12 | .then((res) => res.json())
13 | .then((data) => {
14 | if (isMounted) setTools(data);
15 | })
16 | .catch((error) => {
17 | // On failure, we just let it retry after interval
18 | console.error("Error fetching backend tools:", error);
19 | });
20 | };
21 |
22 | fetchTools();
23 | const intervalId = setInterval(fetchTools, intervalMs);
24 |
25 | return () => {
26 | isMounted = false;
27 | clearInterval(intervalId);
28 | };
29 | }, [url, intervalMs]);
30 |
31 | return tools;
32 | }
33 |
--------------------------------------------------------------------------------
/webapp/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/webapp/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/webapp/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twilio-realtime-frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@radix-ui/react-checkbox": "^1.1.1",
13 | "@radix-ui/react-dialog": "^1.1.3",
14 | "@radix-ui/react-label": "^2.1.0",
15 | "@radix-ui/react-scroll-area": "^1.2.1",
16 | "@radix-ui/react-select": "^2.1.1",
17 | "@radix-ui/react-slot": "^1.1.0",
18 | "class-variance-authority": "^0.7.0",
19 | "clsx": "^2.1.1",
20 | "dotenv": "^16.4.5",
21 | "lucide-react": "^0.417.0",
22 | "next": "14.2.5",
23 | "react": "^18",
24 | "react-dom": "^18",
25 | "tailwind-merge": "^2.4.0",
26 | "tailwindcss-animate": "^1.0.7",
27 | "twilio": "^5.2.2"
28 | },
29 | "devDependencies": {
30 | "@types/node": "^20",
31 | "@types/react": "^18",
32 | "@types/react-dom": "^18",
33 | "postcss": "^8",
34 | "tailwindcss": "^3.4.1",
35 | "typescript": "^5"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/webapp/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/webapp/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/webapp/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/webapp/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/webapp/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
--------------------------------------------------------------------------------
/websocket-server/.env.example:
--------------------------------------------------------------------------------
1 | # rename this to .env
2 |
3 | OPENAI_API_KEY=""
4 | PUBLIC_URL=""
--------------------------------------------------------------------------------
/websocket-server/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 | .env
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/websocket-server/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twilio-realtime",
3 | "version": "1.0.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "twilio-realtime",
9 | "version": "1.0.0",
10 | "license": "ISC",
11 | "dependencies": {
12 | "@types/node": "^22.0.0",
13 | "@types/ws": "^8.5.12",
14 | "cors": "^2.8.5",
15 | "dotenv": "^16.4.5",
16 | "express": "^4.21.2",
17 | "httpdispatcher": "^2.2.0",
18 | "ts-node": "^10.9.2",
19 | "typescript": "^5.5.4",
20 | "ws": "^8.18.0"
21 | },
22 | "devDependencies": {
23 | "@types/cors": "^2.8.17",
24 | "@types/dotenv": "^8.2.0",
25 | "@types/express": "^5.0.0",
26 | "nodemon": "^2.0.22"
27 | }
28 | },
29 | "node_modules/@cspotcode/source-map-support": {
30 | "version": "0.8.1",
31 | "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
32 | "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
33 | "dependencies": {
34 | "@jridgewell/trace-mapping": "0.3.9"
35 | },
36 | "engines": {
37 | "node": ">=12"
38 | }
39 | },
40 | "node_modules/@jridgewell/resolve-uri": {
41 | "version": "3.1.2",
42 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
43 | "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
44 | "engines": {
45 | "node": ">=6.0.0"
46 | }
47 | },
48 | "node_modules/@jridgewell/sourcemap-codec": {
49 | "version": "1.5.0",
50 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
51 | "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
52 | },
53 | "node_modules/@jridgewell/trace-mapping": {
54 | "version": "0.3.9",
55 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
56 | "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
57 | "dependencies": {
58 | "@jridgewell/resolve-uri": "^3.0.3",
59 | "@jridgewell/sourcemap-codec": "^1.4.10"
60 | }
61 | },
62 | "node_modules/@tsconfig/node10": {
63 | "version": "1.0.11",
64 | "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
65 | "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw=="
66 | },
67 | "node_modules/@tsconfig/node12": {
68 | "version": "1.0.11",
69 | "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
70 | "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag=="
71 | },
72 | "node_modules/@tsconfig/node14": {
73 | "version": "1.0.3",
74 | "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
75 | "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow=="
76 | },
77 | "node_modules/@tsconfig/node16": {
78 | "version": "1.0.4",
79 | "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
80 | "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA=="
81 | },
82 | "node_modules/@types/body-parser": {
83 | "version": "1.19.5",
84 | "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
85 | "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==",
86 | "dev": true,
87 | "dependencies": {
88 | "@types/connect": "*",
89 | "@types/node": "*"
90 | }
91 | },
92 | "node_modules/@types/connect": {
93 | "version": "3.4.38",
94 | "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
95 | "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
96 | "dev": true,
97 | "dependencies": {
98 | "@types/node": "*"
99 | }
100 | },
101 | "node_modules/@types/cors": {
102 | "version": "2.8.17",
103 | "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz",
104 | "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==",
105 | "dev": true,
106 | "dependencies": {
107 | "@types/node": "*"
108 | }
109 | },
110 | "node_modules/@types/dotenv": {
111 | "version": "8.2.0",
112 | "resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.0.tgz",
113 | "integrity": "sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw==",
114 | "deprecated": "This is a stub types definition. dotenv provides its own type definitions, so you do not need this installed.",
115 | "dev": true,
116 | "dependencies": {
117 | "dotenv": "*"
118 | }
119 | },
120 | "node_modules/@types/express": {
121 | "version": "5.0.0",
122 | "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz",
123 | "integrity": "sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==",
124 | "dev": true,
125 | "dependencies": {
126 | "@types/body-parser": "*",
127 | "@types/express-serve-static-core": "^5.0.0",
128 | "@types/qs": "*",
129 | "@types/serve-static": "*"
130 | }
131 | },
132 | "node_modules/@types/express-serve-static-core": {
133 | "version": "5.0.2",
134 | "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.2.tgz",
135 | "integrity": "sha512-vluaspfvWEtE4vcSDlKRNer52DvOGrB2xv6diXy6UKyKW0lqZiWHGNApSyxOv+8DE5Z27IzVvE7hNkxg7EXIcg==",
136 | "dev": true,
137 | "dependencies": {
138 | "@types/node": "*",
139 | "@types/qs": "*",
140 | "@types/range-parser": "*",
141 | "@types/send": "*"
142 | }
143 | },
144 | "node_modules/@types/http-errors": {
145 | "version": "2.0.4",
146 | "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
147 | "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==",
148 | "dev": true
149 | },
150 | "node_modules/@types/mime": {
151 | "version": "1.3.5",
152 | "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
153 | "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
154 | "dev": true
155 | },
156 | "node_modules/@types/node": {
157 | "version": "22.0.0",
158 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.0.tgz",
159 | "integrity": "sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==",
160 | "dependencies": {
161 | "undici-types": "~6.11.1"
162 | }
163 | },
164 | "node_modules/@types/qs": {
165 | "version": "6.9.17",
166 | "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz",
167 | "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==",
168 | "dev": true
169 | },
170 | "node_modules/@types/range-parser": {
171 | "version": "1.2.7",
172 | "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
173 | "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
174 | "dev": true
175 | },
176 | "node_modules/@types/send": {
177 | "version": "0.17.4",
178 | "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz",
179 | "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==",
180 | "dev": true,
181 | "dependencies": {
182 | "@types/mime": "^1",
183 | "@types/node": "*"
184 | }
185 | },
186 | "node_modules/@types/serve-static": {
187 | "version": "1.15.7",
188 | "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
189 | "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
190 | "dev": true,
191 | "dependencies": {
192 | "@types/http-errors": "*",
193 | "@types/node": "*",
194 | "@types/send": "*"
195 | }
196 | },
197 | "node_modules/@types/ws": {
198 | "version": "8.5.12",
199 | "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz",
200 | "integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==",
201 | "dependencies": {
202 | "@types/node": "*"
203 | }
204 | },
205 | "node_modules/accepts": {
206 | "version": "1.3.8",
207 | "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
208 | "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
209 | "dependencies": {
210 | "mime-types": "~2.1.34",
211 | "negotiator": "0.6.3"
212 | },
213 | "engines": {
214 | "node": ">= 0.6"
215 | }
216 | },
217 | "node_modules/acorn": {
218 | "version": "8.12.1",
219 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
220 | "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
221 | "bin": {
222 | "acorn": "bin/acorn"
223 | },
224 | "engines": {
225 | "node": ">=0.4.0"
226 | }
227 | },
228 | "node_modules/acorn-walk": {
229 | "version": "8.3.3",
230 | "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz",
231 | "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==",
232 | "dependencies": {
233 | "acorn": "^8.11.0"
234 | },
235 | "engines": {
236 | "node": ">=0.4.0"
237 | }
238 | },
239 | "node_modules/anymatch": {
240 | "version": "3.1.3",
241 | "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
242 | "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
243 | "dev": true,
244 | "dependencies": {
245 | "normalize-path": "^3.0.0",
246 | "picomatch": "^2.0.4"
247 | },
248 | "engines": {
249 | "node": ">= 8"
250 | }
251 | },
252 | "node_modules/arg": {
253 | "version": "4.1.3",
254 | "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
255 | "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
256 | },
257 | "node_modules/array-flatten": {
258 | "version": "1.1.1",
259 | "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
260 | "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
261 | },
262 | "node_modules/balanced-match": {
263 | "version": "1.0.2",
264 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
265 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
266 | "dev": true
267 | },
268 | "node_modules/binary-extensions": {
269 | "version": "2.3.0",
270 | "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
271 | "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
272 | "dev": true,
273 | "engines": {
274 | "node": ">=8"
275 | },
276 | "funding": {
277 | "url": "https://github.com/sponsors/sindresorhus"
278 | }
279 | },
280 | "node_modules/body-parser": {
281 | "version": "1.20.3",
282 | "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
283 | "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
284 | "dependencies": {
285 | "bytes": "3.1.2",
286 | "content-type": "~1.0.5",
287 | "debug": "2.6.9",
288 | "depd": "2.0.0",
289 | "destroy": "1.2.0",
290 | "http-errors": "2.0.0",
291 | "iconv-lite": "0.4.24",
292 | "on-finished": "2.4.1",
293 | "qs": "6.13.0",
294 | "raw-body": "2.5.2",
295 | "type-is": "~1.6.18",
296 | "unpipe": "1.0.0"
297 | },
298 | "engines": {
299 | "node": ">= 0.8",
300 | "npm": "1.2.8000 || >= 1.4.16"
301 | }
302 | },
303 | "node_modules/brace-expansion": {
304 | "version": "1.1.11",
305 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
306 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
307 | "dev": true,
308 | "dependencies": {
309 | "balanced-match": "^1.0.0",
310 | "concat-map": "0.0.1"
311 | }
312 | },
313 | "node_modules/braces": {
314 | "version": "3.0.3",
315 | "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
316 | "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
317 | "dev": true,
318 | "dependencies": {
319 | "fill-range": "^7.1.1"
320 | },
321 | "engines": {
322 | "node": ">=8"
323 | }
324 | },
325 | "node_modules/bytes": {
326 | "version": "3.1.2",
327 | "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
328 | "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
329 | "engines": {
330 | "node": ">= 0.8"
331 | }
332 | },
333 | "node_modules/call-bind": {
334 | "version": "1.0.8",
335 | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
336 | "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
337 | "dependencies": {
338 | "call-bind-apply-helpers": "^1.0.0",
339 | "es-define-property": "^1.0.0",
340 | "get-intrinsic": "^1.2.4",
341 | "set-function-length": "^1.2.2"
342 | },
343 | "engines": {
344 | "node": ">= 0.4"
345 | },
346 | "funding": {
347 | "url": "https://github.com/sponsors/ljharb"
348 | }
349 | },
350 | "node_modules/call-bind-apply-helpers": {
351 | "version": "1.0.1",
352 | "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz",
353 | "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==",
354 | "dependencies": {
355 | "es-errors": "^1.3.0",
356 | "function-bind": "^1.1.2"
357 | },
358 | "engines": {
359 | "node": ">= 0.4"
360 | }
361 | },
362 | "node_modules/call-bound": {
363 | "version": "1.0.2",
364 | "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.2.tgz",
365 | "integrity": "sha512-0lk0PHFe/uz0vl527fG9CgdE9WdafjDbCXvBbs+LUv000TVt2Jjhqbs4Jwm8gz070w8xXyEAxrPOMullsxXeGg==",
366 | "dependencies": {
367 | "call-bind": "^1.0.8",
368 | "get-intrinsic": "^1.2.5"
369 | },
370 | "engines": {
371 | "node": ">= 0.4"
372 | },
373 | "funding": {
374 | "url": "https://github.com/sponsors/ljharb"
375 | }
376 | },
377 | "node_modules/chokidar": {
378 | "version": "3.6.0",
379 | "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
380 | "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
381 | "dev": true,
382 | "dependencies": {
383 | "anymatch": "~3.1.2",
384 | "braces": "~3.0.2",
385 | "glob-parent": "~5.1.2",
386 | "is-binary-path": "~2.1.0",
387 | "is-glob": "~4.0.1",
388 | "normalize-path": "~3.0.0",
389 | "readdirp": "~3.6.0"
390 | },
391 | "engines": {
392 | "node": ">= 8.10.0"
393 | },
394 | "funding": {
395 | "url": "https://paulmillr.com/funding/"
396 | },
397 | "optionalDependencies": {
398 | "fsevents": "~2.3.2"
399 | }
400 | },
401 | "node_modules/concat-map": {
402 | "version": "0.0.1",
403 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
404 | "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
405 | "dev": true
406 | },
407 | "node_modules/content-disposition": {
408 | "version": "0.5.4",
409 | "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
410 | "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
411 | "dependencies": {
412 | "safe-buffer": "5.2.1"
413 | },
414 | "engines": {
415 | "node": ">= 0.6"
416 | }
417 | },
418 | "node_modules/content-type": {
419 | "version": "1.0.5",
420 | "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
421 | "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
422 | "engines": {
423 | "node": ">= 0.6"
424 | }
425 | },
426 | "node_modules/cookie": {
427 | "version": "0.7.1",
428 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
429 | "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
430 | "engines": {
431 | "node": ">= 0.6"
432 | }
433 | },
434 | "node_modules/cookie-signature": {
435 | "version": "1.0.6",
436 | "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
437 | "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
438 | },
439 | "node_modules/cors": {
440 | "version": "2.8.5",
441 | "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
442 | "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
443 | "dependencies": {
444 | "object-assign": "^4",
445 | "vary": "^1"
446 | },
447 | "engines": {
448 | "node": ">= 0.10"
449 | }
450 | },
451 | "node_modules/create-require": {
452 | "version": "1.1.1",
453 | "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
454 | "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
455 | },
456 | "node_modules/debug": {
457 | "version": "2.6.9",
458 | "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
459 | "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
460 | "dependencies": {
461 | "ms": "2.0.0"
462 | }
463 | },
464 | "node_modules/define-data-property": {
465 | "version": "1.1.4",
466 | "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
467 | "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
468 | "dependencies": {
469 | "es-define-property": "^1.0.0",
470 | "es-errors": "^1.3.0",
471 | "gopd": "^1.0.1"
472 | },
473 | "engines": {
474 | "node": ">= 0.4"
475 | },
476 | "funding": {
477 | "url": "https://github.com/sponsors/ljharb"
478 | }
479 | },
480 | "node_modules/depd": {
481 | "version": "2.0.0",
482 | "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
483 | "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
484 | "engines": {
485 | "node": ">= 0.8"
486 | }
487 | },
488 | "node_modules/destroy": {
489 | "version": "1.2.0",
490 | "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
491 | "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
492 | "engines": {
493 | "node": ">= 0.8",
494 | "npm": "1.2.8000 || >= 1.4.16"
495 | }
496 | },
497 | "node_modules/diff": {
498 | "version": "4.0.2",
499 | "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
500 | "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
501 | "engines": {
502 | "node": ">=0.3.1"
503 | }
504 | },
505 | "node_modules/dotenv": {
506 | "version": "16.4.5",
507 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
508 | "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
509 | "engines": {
510 | "node": ">=12"
511 | },
512 | "funding": {
513 | "url": "https://dotenvx.com"
514 | }
515 | },
516 | "node_modules/dunder-proto": {
517 | "version": "1.0.0",
518 | "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz",
519 | "integrity": "sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==",
520 | "dependencies": {
521 | "call-bind-apply-helpers": "^1.0.0",
522 | "es-errors": "^1.3.0",
523 | "gopd": "^1.2.0"
524 | },
525 | "engines": {
526 | "node": ">= 0.4"
527 | }
528 | },
529 | "node_modules/ee-first": {
530 | "version": "1.1.1",
531 | "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
532 | "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
533 | },
534 | "node_modules/encodeurl": {
535 | "version": "2.0.0",
536 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
537 | "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
538 | "engines": {
539 | "node": ">= 0.8"
540 | }
541 | },
542 | "node_modules/es-define-property": {
543 | "version": "1.0.1",
544 | "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
545 | "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
546 | "engines": {
547 | "node": ">= 0.4"
548 | }
549 | },
550 | "node_modules/es-errors": {
551 | "version": "1.3.0",
552 | "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
553 | "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
554 | "engines": {
555 | "node": ">= 0.4"
556 | }
557 | },
558 | "node_modules/es-object-atoms": {
559 | "version": "1.0.0",
560 | "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz",
561 | "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==",
562 | "dependencies": {
563 | "es-errors": "^1.3.0"
564 | },
565 | "engines": {
566 | "node": ">= 0.4"
567 | }
568 | },
569 | "node_modules/escape-html": {
570 | "version": "1.0.3",
571 | "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
572 | "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
573 | },
574 | "node_modules/etag": {
575 | "version": "1.8.1",
576 | "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
577 | "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
578 | "engines": {
579 | "node": ">= 0.6"
580 | }
581 | },
582 | "node_modules/express": {
583 | "version": "4.21.2",
584 | "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
585 | "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
586 | "dependencies": {
587 | "accepts": "~1.3.8",
588 | "array-flatten": "1.1.1",
589 | "body-parser": "1.20.3",
590 | "content-disposition": "0.5.4",
591 | "content-type": "~1.0.4",
592 | "cookie": "0.7.1",
593 | "cookie-signature": "1.0.6",
594 | "debug": "2.6.9",
595 | "depd": "2.0.0",
596 | "encodeurl": "~2.0.0",
597 | "escape-html": "~1.0.3",
598 | "etag": "~1.8.1",
599 | "finalhandler": "1.3.1",
600 | "fresh": "0.5.2",
601 | "http-errors": "2.0.0",
602 | "merge-descriptors": "1.0.3",
603 | "methods": "~1.1.2",
604 | "on-finished": "2.4.1",
605 | "parseurl": "~1.3.3",
606 | "path-to-regexp": "0.1.12",
607 | "proxy-addr": "~2.0.7",
608 | "qs": "6.13.0",
609 | "range-parser": "~1.2.1",
610 | "safe-buffer": "5.2.1",
611 | "send": "0.19.0",
612 | "serve-static": "1.16.2",
613 | "setprototypeof": "1.2.0",
614 | "statuses": "2.0.1",
615 | "type-is": "~1.6.18",
616 | "utils-merge": "1.0.1",
617 | "vary": "~1.1.2"
618 | },
619 | "engines": {
620 | "node": ">= 0.10.0"
621 | },
622 | "funding": {
623 | "type": "opencollective",
624 | "url": "https://opencollective.com/express"
625 | }
626 | },
627 | "node_modules/fill-range": {
628 | "version": "7.1.1",
629 | "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
630 | "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
631 | "dev": true,
632 | "dependencies": {
633 | "to-regex-range": "^5.0.1"
634 | },
635 | "engines": {
636 | "node": ">=8"
637 | }
638 | },
639 | "node_modules/finalhandler": {
640 | "version": "1.3.1",
641 | "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
642 | "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
643 | "dependencies": {
644 | "debug": "2.6.9",
645 | "encodeurl": "~2.0.0",
646 | "escape-html": "~1.0.3",
647 | "on-finished": "2.4.1",
648 | "parseurl": "~1.3.3",
649 | "statuses": "2.0.1",
650 | "unpipe": "~1.0.0"
651 | },
652 | "engines": {
653 | "node": ">= 0.8"
654 | }
655 | },
656 | "node_modules/forwarded": {
657 | "version": "0.2.0",
658 | "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
659 | "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
660 | "engines": {
661 | "node": ">= 0.6"
662 | }
663 | },
664 | "node_modules/fresh": {
665 | "version": "0.5.2",
666 | "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
667 | "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
668 | "engines": {
669 | "node": ">= 0.6"
670 | }
671 | },
672 | "node_modules/fsevents": {
673 | "version": "2.3.3",
674 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
675 | "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
676 | "dev": true,
677 | "hasInstallScript": true,
678 | "optional": true,
679 | "os": [
680 | "darwin"
681 | ],
682 | "engines": {
683 | "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
684 | }
685 | },
686 | "node_modules/function-bind": {
687 | "version": "1.1.2",
688 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
689 | "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
690 | "funding": {
691 | "url": "https://github.com/sponsors/ljharb"
692 | }
693 | },
694 | "node_modules/get-intrinsic": {
695 | "version": "1.2.6",
696 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
697 | "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==",
698 | "dependencies": {
699 | "call-bind-apply-helpers": "^1.0.1",
700 | "dunder-proto": "^1.0.0",
701 | "es-define-property": "^1.0.1",
702 | "es-errors": "^1.3.0",
703 | "es-object-atoms": "^1.0.0",
704 | "function-bind": "^1.1.2",
705 | "gopd": "^1.2.0",
706 | "has-symbols": "^1.1.0",
707 | "hasown": "^2.0.2",
708 | "math-intrinsics": "^1.0.0"
709 | },
710 | "engines": {
711 | "node": ">= 0.4"
712 | },
713 | "funding": {
714 | "url": "https://github.com/sponsors/ljharb"
715 | }
716 | },
717 | "node_modules/glob-parent": {
718 | "version": "5.1.2",
719 | "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
720 | "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
721 | "dev": true,
722 | "dependencies": {
723 | "is-glob": "^4.0.1"
724 | },
725 | "engines": {
726 | "node": ">= 6"
727 | }
728 | },
729 | "node_modules/gopd": {
730 | "version": "1.2.0",
731 | "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
732 | "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
733 | "engines": {
734 | "node": ">= 0.4"
735 | },
736 | "funding": {
737 | "url": "https://github.com/sponsors/ljharb"
738 | }
739 | },
740 | "node_modules/has-flag": {
741 | "version": "3.0.0",
742 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
743 | "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
744 | "dev": true,
745 | "engines": {
746 | "node": ">=4"
747 | }
748 | },
749 | "node_modules/has-property-descriptors": {
750 | "version": "1.0.2",
751 | "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
752 | "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
753 | "dependencies": {
754 | "es-define-property": "^1.0.0"
755 | },
756 | "funding": {
757 | "url": "https://github.com/sponsors/ljharb"
758 | }
759 | },
760 | "node_modules/has-symbols": {
761 | "version": "1.1.0",
762 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
763 | "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
764 | "engines": {
765 | "node": ">= 0.4"
766 | },
767 | "funding": {
768 | "url": "https://github.com/sponsors/ljharb"
769 | }
770 | },
771 | "node_modules/hasown": {
772 | "version": "2.0.2",
773 | "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
774 | "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
775 | "dependencies": {
776 | "function-bind": "^1.1.2"
777 | },
778 | "engines": {
779 | "node": ">= 0.4"
780 | }
781 | },
782 | "node_modules/http-errors": {
783 | "version": "2.0.0",
784 | "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
785 | "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
786 | "dependencies": {
787 | "depd": "2.0.0",
788 | "inherits": "2.0.4",
789 | "setprototypeof": "1.2.0",
790 | "statuses": "2.0.1",
791 | "toidentifier": "1.0.1"
792 | },
793 | "engines": {
794 | "node": ">= 0.8"
795 | }
796 | },
797 | "node_modules/httpdispatcher": {
798 | "version": "2.2.0",
799 | "resolved": "https://registry.npmjs.org/httpdispatcher/-/httpdispatcher-2.2.0.tgz",
800 | "integrity": "sha512-IRcU9nDIggM+v4QNpvhLh+KpVHdHocwIHpKJNaTguuwlSV3oizeysEhVLWekgAbjspBY2UjqtUREe/kJPmL9DA==",
801 | "dependencies": {
802 | "mime-types": ">=1.2.5"
803 | }
804 | },
805 | "node_modules/iconv-lite": {
806 | "version": "0.4.24",
807 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
808 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
809 | "dependencies": {
810 | "safer-buffer": ">= 2.1.2 < 3"
811 | },
812 | "engines": {
813 | "node": ">=0.10.0"
814 | }
815 | },
816 | "node_modules/ignore-by-default": {
817 | "version": "1.0.1",
818 | "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
819 | "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
820 | "dev": true
821 | },
822 | "node_modules/inherits": {
823 | "version": "2.0.4",
824 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
825 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
826 | },
827 | "node_modules/ipaddr.js": {
828 | "version": "1.9.1",
829 | "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
830 | "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
831 | "engines": {
832 | "node": ">= 0.10"
833 | }
834 | },
835 | "node_modules/is-binary-path": {
836 | "version": "2.1.0",
837 | "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
838 | "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
839 | "dev": true,
840 | "dependencies": {
841 | "binary-extensions": "^2.0.0"
842 | },
843 | "engines": {
844 | "node": ">=8"
845 | }
846 | },
847 | "node_modules/is-extglob": {
848 | "version": "2.1.1",
849 | "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
850 | "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
851 | "dev": true,
852 | "engines": {
853 | "node": ">=0.10.0"
854 | }
855 | },
856 | "node_modules/is-glob": {
857 | "version": "4.0.3",
858 | "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
859 | "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
860 | "dev": true,
861 | "dependencies": {
862 | "is-extglob": "^2.1.1"
863 | },
864 | "engines": {
865 | "node": ">=0.10.0"
866 | }
867 | },
868 | "node_modules/is-number": {
869 | "version": "7.0.0",
870 | "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
871 | "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
872 | "dev": true,
873 | "engines": {
874 | "node": ">=0.12.0"
875 | }
876 | },
877 | "node_modules/make-error": {
878 | "version": "1.3.6",
879 | "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
880 | "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="
881 | },
882 | "node_modules/math-intrinsics": {
883 | "version": "1.0.0",
884 | "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.0.0.tgz",
885 | "integrity": "sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA==",
886 | "engines": {
887 | "node": ">= 0.4"
888 | }
889 | },
890 | "node_modules/media-typer": {
891 | "version": "0.3.0",
892 | "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
893 | "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
894 | "engines": {
895 | "node": ">= 0.6"
896 | }
897 | },
898 | "node_modules/merge-descriptors": {
899 | "version": "1.0.3",
900 | "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
901 | "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
902 | "funding": {
903 | "url": "https://github.com/sponsors/sindresorhus"
904 | }
905 | },
906 | "node_modules/methods": {
907 | "version": "1.1.2",
908 | "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
909 | "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
910 | "engines": {
911 | "node": ">= 0.6"
912 | }
913 | },
914 | "node_modules/mime": {
915 | "version": "1.6.0",
916 | "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
917 | "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
918 | "bin": {
919 | "mime": "cli.js"
920 | },
921 | "engines": {
922 | "node": ">=4"
923 | }
924 | },
925 | "node_modules/mime-db": {
926 | "version": "1.52.0",
927 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
928 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
929 | "engines": {
930 | "node": ">= 0.6"
931 | }
932 | },
933 | "node_modules/mime-types": {
934 | "version": "2.1.35",
935 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
936 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
937 | "dependencies": {
938 | "mime-db": "1.52.0"
939 | },
940 | "engines": {
941 | "node": ">= 0.6"
942 | }
943 | },
944 | "node_modules/minimatch": {
945 | "version": "3.1.2",
946 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
947 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
948 | "dev": true,
949 | "dependencies": {
950 | "brace-expansion": "^1.1.7"
951 | },
952 | "engines": {
953 | "node": "*"
954 | }
955 | },
956 | "node_modules/ms": {
957 | "version": "2.0.0",
958 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
959 | "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
960 | },
961 | "node_modules/negotiator": {
962 | "version": "0.6.3",
963 | "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
964 | "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
965 | "engines": {
966 | "node": ">= 0.6"
967 | }
968 | },
969 | "node_modules/nodemon": {
970 | "version": "2.0.22",
971 | "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
972 | "integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
973 | "dev": true,
974 | "dependencies": {
975 | "chokidar": "^3.5.2",
976 | "debug": "^3.2.7",
977 | "ignore-by-default": "^1.0.1",
978 | "minimatch": "^3.1.2",
979 | "pstree.remy": "^1.1.8",
980 | "semver": "^5.7.1",
981 | "simple-update-notifier": "^1.0.7",
982 | "supports-color": "^5.5.0",
983 | "touch": "^3.1.0",
984 | "undefsafe": "^2.0.5"
985 | },
986 | "bin": {
987 | "nodemon": "bin/nodemon.js"
988 | },
989 | "engines": {
990 | "node": ">=8.10.0"
991 | },
992 | "funding": {
993 | "type": "opencollective",
994 | "url": "https://opencollective.com/nodemon"
995 | }
996 | },
997 | "node_modules/nodemon/node_modules/debug": {
998 | "version": "3.2.7",
999 | "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
1000 | "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
1001 | "dev": true,
1002 | "dependencies": {
1003 | "ms": "^2.1.1"
1004 | }
1005 | },
1006 | "node_modules/nodemon/node_modules/ms": {
1007 | "version": "2.1.3",
1008 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1009 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1010 | "dev": true
1011 | },
1012 | "node_modules/normalize-path": {
1013 | "version": "3.0.0",
1014 | "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
1015 | "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
1016 | "dev": true,
1017 | "engines": {
1018 | "node": ">=0.10.0"
1019 | }
1020 | },
1021 | "node_modules/object-assign": {
1022 | "version": "4.1.1",
1023 | "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1024 | "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1025 | "engines": {
1026 | "node": ">=0.10.0"
1027 | }
1028 | },
1029 | "node_modules/object-inspect": {
1030 | "version": "1.13.3",
1031 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz",
1032 | "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==",
1033 | "engines": {
1034 | "node": ">= 0.4"
1035 | },
1036 | "funding": {
1037 | "url": "https://github.com/sponsors/ljharb"
1038 | }
1039 | },
1040 | "node_modules/on-finished": {
1041 | "version": "2.4.1",
1042 | "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
1043 | "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
1044 | "dependencies": {
1045 | "ee-first": "1.1.1"
1046 | },
1047 | "engines": {
1048 | "node": ">= 0.8"
1049 | }
1050 | },
1051 | "node_modules/parseurl": {
1052 | "version": "1.3.3",
1053 | "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
1054 | "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
1055 | "engines": {
1056 | "node": ">= 0.8"
1057 | }
1058 | },
1059 | "node_modules/path-to-regexp": {
1060 | "version": "0.1.12",
1061 | "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
1062 | "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
1063 | },
1064 | "node_modules/picomatch": {
1065 | "version": "2.3.1",
1066 | "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
1067 | "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
1068 | "dev": true,
1069 | "engines": {
1070 | "node": ">=8.6"
1071 | },
1072 | "funding": {
1073 | "url": "https://github.com/sponsors/jonschlinkert"
1074 | }
1075 | },
1076 | "node_modules/proxy-addr": {
1077 | "version": "2.0.7",
1078 | "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
1079 | "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
1080 | "dependencies": {
1081 | "forwarded": "0.2.0",
1082 | "ipaddr.js": "1.9.1"
1083 | },
1084 | "engines": {
1085 | "node": ">= 0.10"
1086 | }
1087 | },
1088 | "node_modules/pstree.remy": {
1089 | "version": "1.1.8",
1090 | "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
1091 | "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
1092 | "dev": true
1093 | },
1094 | "node_modules/qs": {
1095 | "version": "6.13.0",
1096 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
1097 | "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
1098 | "dependencies": {
1099 | "side-channel": "^1.0.6"
1100 | },
1101 | "engines": {
1102 | "node": ">=0.6"
1103 | },
1104 | "funding": {
1105 | "url": "https://github.com/sponsors/ljharb"
1106 | }
1107 | },
1108 | "node_modules/range-parser": {
1109 | "version": "1.2.1",
1110 | "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
1111 | "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
1112 | "engines": {
1113 | "node": ">= 0.6"
1114 | }
1115 | },
1116 | "node_modules/raw-body": {
1117 | "version": "2.5.2",
1118 | "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
1119 | "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
1120 | "dependencies": {
1121 | "bytes": "3.1.2",
1122 | "http-errors": "2.0.0",
1123 | "iconv-lite": "0.4.24",
1124 | "unpipe": "1.0.0"
1125 | },
1126 | "engines": {
1127 | "node": ">= 0.8"
1128 | }
1129 | },
1130 | "node_modules/readdirp": {
1131 | "version": "3.6.0",
1132 | "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
1133 | "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
1134 | "dev": true,
1135 | "dependencies": {
1136 | "picomatch": "^2.2.1"
1137 | },
1138 | "engines": {
1139 | "node": ">=8.10.0"
1140 | }
1141 | },
1142 | "node_modules/safe-buffer": {
1143 | "version": "5.2.1",
1144 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
1145 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
1146 | "funding": [
1147 | {
1148 | "type": "github",
1149 | "url": "https://github.com/sponsors/feross"
1150 | },
1151 | {
1152 | "type": "patreon",
1153 | "url": "https://www.patreon.com/feross"
1154 | },
1155 | {
1156 | "type": "consulting",
1157 | "url": "https://feross.org/support"
1158 | }
1159 | ]
1160 | },
1161 | "node_modules/safer-buffer": {
1162 | "version": "2.1.2",
1163 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
1164 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
1165 | },
1166 | "node_modules/semver": {
1167 | "version": "5.7.2",
1168 | "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
1169 | "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
1170 | "dev": true,
1171 | "bin": {
1172 | "semver": "bin/semver"
1173 | }
1174 | },
1175 | "node_modules/send": {
1176 | "version": "0.19.0",
1177 | "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
1178 | "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
1179 | "dependencies": {
1180 | "debug": "2.6.9",
1181 | "depd": "2.0.0",
1182 | "destroy": "1.2.0",
1183 | "encodeurl": "~1.0.2",
1184 | "escape-html": "~1.0.3",
1185 | "etag": "~1.8.1",
1186 | "fresh": "0.5.2",
1187 | "http-errors": "2.0.0",
1188 | "mime": "1.6.0",
1189 | "ms": "2.1.3",
1190 | "on-finished": "2.4.1",
1191 | "range-parser": "~1.2.1",
1192 | "statuses": "2.0.1"
1193 | },
1194 | "engines": {
1195 | "node": ">= 0.8.0"
1196 | }
1197 | },
1198 | "node_modules/send/node_modules/encodeurl": {
1199 | "version": "1.0.2",
1200 | "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
1201 | "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
1202 | "engines": {
1203 | "node": ">= 0.8"
1204 | }
1205 | },
1206 | "node_modules/send/node_modules/ms": {
1207 | "version": "2.1.3",
1208 | "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1209 | "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
1210 | },
1211 | "node_modules/serve-static": {
1212 | "version": "1.16.2",
1213 | "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
1214 | "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
1215 | "dependencies": {
1216 | "encodeurl": "~2.0.0",
1217 | "escape-html": "~1.0.3",
1218 | "parseurl": "~1.3.3",
1219 | "send": "0.19.0"
1220 | },
1221 | "engines": {
1222 | "node": ">= 0.8.0"
1223 | }
1224 | },
1225 | "node_modules/set-function-length": {
1226 | "version": "1.2.2",
1227 | "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
1228 | "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
1229 | "dependencies": {
1230 | "define-data-property": "^1.1.4",
1231 | "es-errors": "^1.3.0",
1232 | "function-bind": "^1.1.2",
1233 | "get-intrinsic": "^1.2.4",
1234 | "gopd": "^1.0.1",
1235 | "has-property-descriptors": "^1.0.2"
1236 | },
1237 | "engines": {
1238 | "node": ">= 0.4"
1239 | }
1240 | },
1241 | "node_modules/setprototypeof": {
1242 | "version": "1.2.0",
1243 | "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
1244 | "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
1245 | },
1246 | "node_modules/side-channel": {
1247 | "version": "1.1.0",
1248 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
1249 | "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
1250 | "dependencies": {
1251 | "es-errors": "^1.3.0",
1252 | "object-inspect": "^1.13.3",
1253 | "side-channel-list": "^1.0.0",
1254 | "side-channel-map": "^1.0.1",
1255 | "side-channel-weakmap": "^1.0.2"
1256 | },
1257 | "engines": {
1258 | "node": ">= 0.4"
1259 | },
1260 | "funding": {
1261 | "url": "https://github.com/sponsors/ljharb"
1262 | }
1263 | },
1264 | "node_modules/side-channel-list": {
1265 | "version": "1.0.0",
1266 | "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
1267 | "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
1268 | "dependencies": {
1269 | "es-errors": "^1.3.0",
1270 | "object-inspect": "^1.13.3"
1271 | },
1272 | "engines": {
1273 | "node": ">= 0.4"
1274 | },
1275 | "funding": {
1276 | "url": "https://github.com/sponsors/ljharb"
1277 | }
1278 | },
1279 | "node_modules/side-channel-map": {
1280 | "version": "1.0.1",
1281 | "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
1282 | "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
1283 | "dependencies": {
1284 | "call-bound": "^1.0.2",
1285 | "es-errors": "^1.3.0",
1286 | "get-intrinsic": "^1.2.5",
1287 | "object-inspect": "^1.13.3"
1288 | },
1289 | "engines": {
1290 | "node": ">= 0.4"
1291 | },
1292 | "funding": {
1293 | "url": "https://github.com/sponsors/ljharb"
1294 | }
1295 | },
1296 | "node_modules/side-channel-weakmap": {
1297 | "version": "1.0.2",
1298 | "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
1299 | "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
1300 | "dependencies": {
1301 | "call-bound": "^1.0.2",
1302 | "es-errors": "^1.3.0",
1303 | "get-intrinsic": "^1.2.5",
1304 | "object-inspect": "^1.13.3",
1305 | "side-channel-map": "^1.0.1"
1306 | },
1307 | "engines": {
1308 | "node": ">= 0.4"
1309 | },
1310 | "funding": {
1311 | "url": "https://github.com/sponsors/ljharb"
1312 | }
1313 | },
1314 | "node_modules/simple-update-notifier": {
1315 | "version": "1.1.0",
1316 | "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
1317 | "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
1318 | "dev": true,
1319 | "dependencies": {
1320 | "semver": "~7.0.0"
1321 | },
1322 | "engines": {
1323 | "node": ">=8.10.0"
1324 | }
1325 | },
1326 | "node_modules/simple-update-notifier/node_modules/semver": {
1327 | "version": "7.0.0",
1328 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
1329 | "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
1330 | "dev": true,
1331 | "bin": {
1332 | "semver": "bin/semver.js"
1333 | }
1334 | },
1335 | "node_modules/statuses": {
1336 | "version": "2.0.1",
1337 | "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
1338 | "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
1339 | "engines": {
1340 | "node": ">= 0.8"
1341 | }
1342 | },
1343 | "node_modules/supports-color": {
1344 | "version": "5.5.0",
1345 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
1346 | "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
1347 | "dev": true,
1348 | "dependencies": {
1349 | "has-flag": "^3.0.0"
1350 | },
1351 | "engines": {
1352 | "node": ">=4"
1353 | }
1354 | },
1355 | "node_modules/to-regex-range": {
1356 | "version": "5.0.1",
1357 | "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
1358 | "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
1359 | "dev": true,
1360 | "dependencies": {
1361 | "is-number": "^7.0.0"
1362 | },
1363 | "engines": {
1364 | "node": ">=8.0"
1365 | }
1366 | },
1367 | "node_modules/toidentifier": {
1368 | "version": "1.0.1",
1369 | "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
1370 | "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
1371 | "engines": {
1372 | "node": ">=0.6"
1373 | }
1374 | },
1375 | "node_modules/touch": {
1376 | "version": "3.1.1",
1377 | "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
1378 | "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
1379 | "dev": true,
1380 | "bin": {
1381 | "nodetouch": "bin/nodetouch.js"
1382 | }
1383 | },
1384 | "node_modules/ts-node": {
1385 | "version": "10.9.2",
1386 | "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
1387 | "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
1388 | "dependencies": {
1389 | "@cspotcode/source-map-support": "^0.8.0",
1390 | "@tsconfig/node10": "^1.0.7",
1391 | "@tsconfig/node12": "^1.0.7",
1392 | "@tsconfig/node14": "^1.0.0",
1393 | "@tsconfig/node16": "^1.0.2",
1394 | "acorn": "^8.4.1",
1395 | "acorn-walk": "^8.1.1",
1396 | "arg": "^4.1.0",
1397 | "create-require": "^1.1.0",
1398 | "diff": "^4.0.1",
1399 | "make-error": "^1.1.1",
1400 | "v8-compile-cache-lib": "^3.0.1",
1401 | "yn": "3.1.1"
1402 | },
1403 | "bin": {
1404 | "ts-node": "dist/bin.js",
1405 | "ts-node-cwd": "dist/bin-cwd.js",
1406 | "ts-node-esm": "dist/bin-esm.js",
1407 | "ts-node-script": "dist/bin-script.js",
1408 | "ts-node-transpile-only": "dist/bin-transpile.js",
1409 | "ts-script": "dist/bin-script-deprecated.js"
1410 | },
1411 | "peerDependencies": {
1412 | "@swc/core": ">=1.2.50",
1413 | "@swc/wasm": ">=1.2.50",
1414 | "@types/node": "*",
1415 | "typescript": ">=2.7"
1416 | },
1417 | "peerDependenciesMeta": {
1418 | "@swc/core": {
1419 | "optional": true
1420 | },
1421 | "@swc/wasm": {
1422 | "optional": true
1423 | }
1424 | }
1425 | },
1426 | "node_modules/type-is": {
1427 | "version": "1.6.18",
1428 | "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
1429 | "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
1430 | "dependencies": {
1431 | "media-typer": "0.3.0",
1432 | "mime-types": "~2.1.24"
1433 | },
1434 | "engines": {
1435 | "node": ">= 0.6"
1436 | }
1437 | },
1438 | "node_modules/typescript": {
1439 | "version": "5.5.4",
1440 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
1441 | "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
1442 | "bin": {
1443 | "tsc": "bin/tsc",
1444 | "tsserver": "bin/tsserver"
1445 | },
1446 | "engines": {
1447 | "node": ">=14.17"
1448 | }
1449 | },
1450 | "node_modules/undefsafe": {
1451 | "version": "2.0.5",
1452 | "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
1453 | "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
1454 | "dev": true
1455 | },
1456 | "node_modules/undici-types": {
1457 | "version": "6.11.1",
1458 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz",
1459 | "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ=="
1460 | },
1461 | "node_modules/unpipe": {
1462 | "version": "1.0.0",
1463 | "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
1464 | "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
1465 | "engines": {
1466 | "node": ">= 0.8"
1467 | }
1468 | },
1469 | "node_modules/utils-merge": {
1470 | "version": "1.0.1",
1471 | "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
1472 | "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
1473 | "engines": {
1474 | "node": ">= 0.4.0"
1475 | }
1476 | },
1477 | "node_modules/v8-compile-cache-lib": {
1478 | "version": "3.0.1",
1479 | "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
1480 | "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg=="
1481 | },
1482 | "node_modules/vary": {
1483 | "version": "1.1.2",
1484 | "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
1485 | "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
1486 | "engines": {
1487 | "node": ">= 0.8"
1488 | }
1489 | },
1490 | "node_modules/ws": {
1491 | "version": "8.18.0",
1492 | "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
1493 | "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
1494 | "engines": {
1495 | "node": ">=10.0.0"
1496 | },
1497 | "peerDependencies": {
1498 | "bufferutil": "^4.0.1",
1499 | "utf-8-validate": ">=5.0.2"
1500 | },
1501 | "peerDependenciesMeta": {
1502 | "bufferutil": {
1503 | "optional": true
1504 | },
1505 | "utf-8-validate": {
1506 | "optional": true
1507 | }
1508 | }
1509 | },
1510 | "node_modules/yn": {
1511 | "version": "3.1.1",
1512 | "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
1513 | "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
1514 | "engines": {
1515 | "node": ">=6"
1516 | }
1517 | }
1518 | }
1519 | }
1520 |
--------------------------------------------------------------------------------
/websocket-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twilio-realtime",
3 | "version": "1.0.0",
4 | "description": "Twilio real-time server with TypeScript",
5 | "main": "dist/server.js",
6 | "scripts": {
7 | "build": "tsc",
8 | "start": "node dist/server.js",
9 | "dev": "nodemon --watch 'src/**/*' --watch '.env' --ext 'ts,js,xml,env' --exec 'ts-node' src/server.ts",
10 | "test": "echo \"Error: no test specified\" && exit 1"
11 | },
12 | "keywords": [],
13 | "author": "",
14 | "license": "ISC",
15 | "dependencies": {
16 | "@types/node": "^22.0.0",
17 | "@types/ws": "^8.5.12",
18 | "cors": "^2.8.5",
19 | "dotenv": "^16.4.5",
20 | "express": "^4.21.2",
21 | "httpdispatcher": "^2.2.0",
22 | "ts-node": "^10.9.2",
23 | "typescript": "^5.5.4",
24 | "ws": "^8.18.0"
25 | },
26 | "devDependencies": {
27 | "@types/cors": "^2.8.17",
28 | "@types/dotenv": "^8.2.0",
29 | "@types/express": "^5.0.0",
30 | "nodemon": "^2.0.22"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/websocket-server/src/functionHandlers.ts:
--------------------------------------------------------------------------------
1 | import { FunctionHandler } from "./types";
2 |
3 | const functions: FunctionHandler[] = [];
4 |
5 | functions.push({
6 | schema: {
7 | name: "get_weather_from_coords",
8 | type: "function",
9 | description: "Get the current weather",
10 | parameters: {
11 | type: "object",
12 | properties: {
13 | latitude: {
14 | type: "number",
15 | },
16 | longitude: {
17 | type: "number",
18 | },
19 | },
20 | required: ["latitude", "longitude"],
21 | },
22 | },
23 | handler: async (args: { latitude: number; longitude: number }) => {
24 | const response = await fetch(
25 | `https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t=temperature_2m,wind_speed_10m&hourly=temperature_2m,relative_humidity_2m,wind_speed_10m`
26 | );
27 | const data = await response.json();
28 | const currentTemp = data.current?.temperature_2m;
29 | return JSON.stringify({ temp: currentTemp });
30 | },
31 | });
32 |
33 | export default functions;
34 |
--------------------------------------------------------------------------------
/websocket-server/src/server.ts:
--------------------------------------------------------------------------------
1 | import express from "express";
2 | import { WebSocketServer, WebSocket } from "ws";
3 | import { IncomingMessage } from "http";
4 | import dotenv from "dotenv";
5 | import http from "http";
6 | import { readFileSync } from "fs";
7 | import { join } from "path";
8 | import cors from "cors";
9 | import {
10 | handleCallConnection,
11 | handleFrontendConnection,
12 | } from "./sessionManager";
13 | import functions from "./functionHandlers";
14 |
15 | dotenv.config();
16 |
17 | const PORT = parseInt(process.env.PORT || "8081", 10);
18 | const PUBLIC_URL = process.env.PUBLIC_URL || "";
19 | const OPENAI_API_KEY = process.env.OPENAI_API_KEY || "";
20 |
21 | if (!OPENAI_API_KEY) {
22 | console.error("OPENAI_API_KEY environment variable is required");
23 | process.exit(1);
24 | }
25 |
26 | const app = express();
27 | app.use(cors());
28 | const server = http.createServer(app);
29 | const wss = new WebSocketServer({ server });
30 |
31 | app.use(express.urlencoded({ extended: false }));
32 |
33 | const twimlPath = join(__dirname, "twiml.xml");
34 | const twimlTemplate = readFileSync(twimlPath, "utf-8");
35 |
36 | app.get("/public-url", (req, res) => {
37 | res.json({ publicUrl: PUBLIC_URL });
38 | });
39 |
40 | app.all("/twiml", (req, res) => {
41 | const wsUrl = new URL(PUBLIC_URL);
42 | wsUrl.protocol = "wss:";
43 | wsUrl.pathname = `/call`;
44 |
45 | const twimlContent = twimlTemplate.replace("{{WS_URL}}", wsUrl.toString());
46 | res.type("text/xml").send(twimlContent);
47 | });
48 |
49 | // New endpoint to list available tools (schemas)
50 | app.get("/tools", (req, res) => {
51 | res.json(functions.map((f) => f.schema));
52 | });
53 |
54 | let currentCall: WebSocket | null = null;
55 | let currentLogs: WebSocket | null = null;
56 |
57 | wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
58 | const url = new URL(req.url || "", `http://${req.headers.host}`);
59 | const parts = url.pathname.split("/").filter(Boolean);
60 |
61 | if (parts.length < 1) {
62 | ws.close();
63 | return;
64 | }
65 |
66 | const type = parts[0];
67 |
68 | if (type === "call") {
69 | if (currentCall) currentCall.close();
70 | currentCall = ws;
71 | handleCallConnection(currentCall, OPENAI_API_KEY);
72 | } else if (type === "logs") {
73 | if (currentLogs) currentLogs.close();
74 | currentLogs = ws;
75 | handleFrontendConnection(currentLogs);
76 | } else {
77 | ws.close();
78 | }
79 | });
80 |
81 | server.listen(PORT, () => {
82 | console.log(`Server running on http://localhost:${PORT}`);
83 | });
84 |
--------------------------------------------------------------------------------
/websocket-server/src/sessionManager.ts:
--------------------------------------------------------------------------------
1 | import { RawData, WebSocket } from "ws";
2 | import functions from "./functionHandlers";
3 |
4 | interface Session {
5 | twilioConn?: WebSocket;
6 | frontendConn?: WebSocket;
7 | modelConn?: WebSocket;
8 | streamSid?: string;
9 | saved_config?: any;
10 | lastAssistantItem?: string;
11 | responseStartTimestamp?: number;
12 | latestMediaTimestamp?: number;
13 | openAIApiKey?: string;
14 | }
15 |
16 | let session: Session = {};
17 |
18 | export function handleCallConnection(ws: WebSocket, openAIApiKey: string) {
19 | cleanupConnection(session.twilioConn);
20 | session.twilioConn = ws;
21 | session.openAIApiKey = openAIApiKey;
22 |
23 | ws.on("message", handleTwilioMessage);
24 | ws.on("error", ws.close);
25 | ws.on("close", () => {
26 | cleanupConnection(session.modelConn);
27 | cleanupConnection(session.twilioConn);
28 | session.twilioConn = undefined;
29 | session.modelConn = undefined;
30 | session.streamSid = undefined;
31 | session.lastAssistantItem = undefined;
32 | session.responseStartTimestamp = undefined;
33 | session.latestMediaTimestamp = undefined;
34 | if (!session.frontendConn) session = {};
35 | });
36 | }
37 |
38 | export function handleFrontendConnection(ws: WebSocket) {
39 | cleanupConnection(session.frontendConn);
40 | session.frontendConn = ws;
41 |
42 | ws.on("message", handleFrontendMessage);
43 | ws.on("close", () => {
44 | cleanupConnection(session.frontendConn);
45 | session.frontendConn = undefined;
46 | if (!session.twilioConn && !session.modelConn) session = {};
47 | });
48 | }
49 |
50 | async function handleFunctionCall(item: { name: string; arguments: string }) {
51 | console.log("Handling function call:", item);
52 | const fnDef = functions.find((f) => f.schema.name === item.name);
53 | if (!fnDef) {
54 | throw new Error(`No handler found for function: ${item.name}`);
55 | }
56 |
57 | let args: unknown;
58 | try {
59 | args = JSON.parse(item.arguments);
60 | } catch {
61 | return JSON.stringify({
62 | error: "Invalid JSON arguments for function call.",
63 | });
64 | }
65 |
66 | try {
67 | console.log("Calling function:", fnDef.schema.name, args);
68 | const result = await fnDef.handler(args as any);
69 | return result;
70 | } catch (err: any) {
71 | console.error("Error running function:", err);
72 | return JSON.stringify({
73 | error: `Error running function ${item.name}: ${err.message}`,
74 | });
75 | }
76 | }
77 |
78 | function handleTwilioMessage(data: RawData) {
79 | const msg = parseMessage(data);
80 | if (!msg) return;
81 |
82 | switch (msg.event) {
83 | case "start":
84 | session.streamSid = msg.start.streamSid;
85 | session.latestMediaTimestamp = 0;
86 | session.lastAssistantItem = undefined;
87 | session.responseStartTimestamp = undefined;
88 | tryConnectModel();
89 | break;
90 | case "media":
91 | session.latestMediaTimestamp = msg.media.timestamp;
92 | if (isOpen(session.modelConn)) {
93 | jsonSend(session.modelConn, {
94 | type: "input_audio_buffer.append",
95 | audio: msg.media.payload,
96 | });
97 | }
98 | break;
99 | case "close":
100 | closeAllConnections();
101 | break;
102 | }
103 | }
104 |
105 | function handleFrontendMessage(data: RawData) {
106 | const msg = parseMessage(data);
107 | if (!msg) return;
108 |
109 | if (isOpen(session.modelConn)) {
110 | jsonSend(session.modelConn, msg);
111 | }
112 |
113 | if (msg.type === "session.update") {
114 | session.saved_config = msg.session;
115 | }
116 | }
117 |
118 | function tryConnectModel() {
119 | if (!session.twilioConn || !session.streamSid || !session.openAIApiKey)
120 | return;
121 | if (isOpen(session.modelConn)) return;
122 |
123 | session.modelConn = new WebSocket(
124 | "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17",
125 | {
126 | headers: {
127 | Authorization: `Bearer ${session.openAIApiKey}`,
128 | "OpenAI-Beta": "realtime=v1",
129 | },
130 | }
131 | );
132 |
133 | session.modelConn.on("open", () => {
134 | const config = session.saved_config || {};
135 | jsonSend(session.modelConn, {
136 | type: "session.update",
137 | session: {
138 | modalities: ["text", "audio"],
139 | turn_detection: { type: "server_vad" },
140 | voice: "ash",
141 | input_audio_transcription: { model: "whisper-1" },
142 | input_audio_format: "g711_ulaw",
143 | output_audio_format: "g711_ulaw",
144 | ...config,
145 | },
146 | });
147 | });
148 |
149 | session.modelConn.on("message", handleModelMessage);
150 | session.modelConn.on("error", closeModel);
151 | session.modelConn.on("close", closeModel);
152 | }
153 |
154 | function handleModelMessage(data: RawData) {
155 | const event = parseMessage(data);
156 | if (!event) return;
157 |
158 | jsonSend(session.frontendConn, event);
159 |
160 | switch (event.type) {
161 | case "input_audio_buffer.speech_started":
162 | handleTruncation();
163 | break;
164 |
165 | case "response.audio.delta":
166 | if (session.twilioConn && session.streamSid) {
167 | if (session.responseStartTimestamp === undefined) {
168 | session.responseStartTimestamp = session.latestMediaTimestamp || 0;
169 | }
170 | if (event.item_id) session.lastAssistantItem = event.item_id;
171 |
172 | jsonSend(session.twilioConn, {
173 | event: "media",
174 | streamSid: session.streamSid,
175 | media: { payload: event.delta },
176 | });
177 |
178 | jsonSend(session.twilioConn, {
179 | event: "mark",
180 | streamSid: session.streamSid,
181 | });
182 | }
183 | break;
184 |
185 | case "response.output_item.done": {
186 | const { item } = event;
187 | if (item.type === "function_call") {
188 | handleFunctionCall(item)
189 | .then((output) => {
190 | if (session.modelConn) {
191 | jsonSend(session.modelConn, {
192 | type: "conversation.item.create",
193 | item: {
194 | type: "function_call_output",
195 | call_id: item.call_id,
196 | output: JSON.stringify(output),
197 | },
198 | });
199 | jsonSend(session.modelConn, { type: "response.create" });
200 | }
201 | })
202 | .catch((err) => {
203 | console.error("Error handling function call:", err);
204 | });
205 | }
206 | break;
207 | }
208 | }
209 | }
210 |
211 | function handleTruncation() {
212 | if (
213 | !session.lastAssistantItem ||
214 | session.responseStartTimestamp === undefined
215 | )
216 | return;
217 |
218 | const elapsedMs =
219 | (session.latestMediaTimestamp || 0) - (session.responseStartTimestamp || 0);
220 | const audio_end_ms = elapsedMs > 0 ? elapsedMs : 0;
221 |
222 | if (isOpen(session.modelConn)) {
223 | jsonSend(session.modelConn, {
224 | type: "conversation.item.truncate",
225 | item_id: session.lastAssistantItem,
226 | content_index: 0,
227 | audio_end_ms,
228 | });
229 | }
230 |
231 | if (session.twilioConn && session.streamSid) {
232 | jsonSend(session.twilioConn, {
233 | event: "clear",
234 | streamSid: session.streamSid,
235 | });
236 | }
237 |
238 | session.lastAssistantItem = undefined;
239 | session.responseStartTimestamp = undefined;
240 | }
241 |
242 | function closeModel() {
243 | cleanupConnection(session.modelConn);
244 | session.modelConn = undefined;
245 | if (!session.twilioConn && !session.frontendConn) session = {};
246 | }
247 |
248 | function closeAllConnections() {
249 | if (session.twilioConn) {
250 | session.twilioConn.close();
251 | session.twilioConn = undefined;
252 | }
253 | if (session.modelConn) {
254 | session.modelConn.close();
255 | session.modelConn = undefined;
256 | }
257 | if (session.frontendConn) {
258 | session.frontendConn.close();
259 | session.frontendConn = undefined;
260 | }
261 | session.streamSid = undefined;
262 | session.lastAssistantItem = undefined;
263 | session.responseStartTimestamp = undefined;
264 | session.latestMediaTimestamp = undefined;
265 | session.saved_config = undefined;
266 | }
267 |
268 | function cleanupConnection(ws?: WebSocket) {
269 | if (isOpen(ws)) ws.close();
270 | }
271 |
272 | function parseMessage(data: RawData): any {
273 | try {
274 | return JSON.parse(data.toString());
275 | } catch {
276 | return null;
277 | }
278 | }
279 |
280 | function jsonSend(ws: WebSocket | undefined, obj: unknown) {
281 | if (!isOpen(ws)) return;
282 | ws.send(JSON.stringify(obj));
283 | }
284 |
285 | function isOpen(ws?: WebSocket): ws is WebSocket {
286 | return !!ws && ws.readyState === WebSocket.OPEN;
287 | }
288 |
--------------------------------------------------------------------------------
/websocket-server/src/twiml.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Connected
4 |
5 |
6 |
7 | Disconnected
8 |
9 |
--------------------------------------------------------------------------------
/websocket-server/src/types.ts:
--------------------------------------------------------------------------------
1 | import { WebSocket } from "ws";
2 |
3 | export interface Session {
4 | twilioConn?: WebSocket;
5 | frontendConn?: WebSocket;
6 | modelConn?: WebSocket;
7 | config?: any;
8 | streamSid?: string;
9 | }
10 |
11 | export interface FunctionCallItem {
12 | name: string;
13 | arguments: string;
14 | call_id?: string;
15 | }
16 |
17 | export interface FunctionSchema {
18 | name: string;
19 | type: "function";
20 | description?: string;
21 | parameters: {
22 | type: string;
23 | properties: Record;
24 | required: string[];
25 | };
26 | }
27 |
28 | export interface FunctionHandler {
29 | schema: FunctionSchema;
30 | handler: (args: any) => Promise;
31 | }
32 |
--------------------------------------------------------------------------------
/websocket-server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6",
4 | "module": "commonjs",
5 | "moduleResolution": "node",
6 | "strict": true,
7 | "esModuleInterop": true,
8 | "skipLibCheck": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "outDir": "./dist",
11 | "rootDir": "./src",
12 | "noEmitOnError": true,
13 | "resolveJsonModule": true,
14 | "incremental": true
15 | },
16 | "include": ["src/**/*"],
17 | "exclude": ["node_modules"]
18 | }
19 |
--------------------------------------------------------------------------------