mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-13 17:22:37 +02:00
bring back twilio integration
This commit is contained in:
parent
e735795e7f
commit
27ad5d675a
10 changed files with 320 additions and 41 deletions
|
|
@ -5,6 +5,8 @@ import { twilioConfigsCollection } from "../lib/mongodb";
|
|||
import { ObjectId } from "mongodb";
|
||||
import twilio from 'twilio';
|
||||
import { Twilio } from 'twilio';
|
||||
import { z } from "zod";
|
||||
import { WithStringId } from "../lib/types/types";
|
||||
|
||||
// Helper function to serialize MongoDB documents
|
||||
function serializeConfig(config: any) {
|
||||
|
|
@ -16,7 +18,7 @@ function serializeConfig(config: any) {
|
|||
}
|
||||
|
||||
// Real implementation for configuring Twilio number
|
||||
export async function configureTwilioNumber(params: TwilioConfigParams): Promise<TwilioConfigResponse> {
|
||||
export async function configureTwilioNumber(params: z.infer<typeof TwilioConfigParams>): Promise<TwilioConfigResponse> {
|
||||
console.log('configureTwilioNumber - Received params:', params);
|
||||
try {
|
||||
const client = twilio(params.account_sid, params.auth_token);
|
||||
|
|
@ -56,7 +58,7 @@ export async function configureTwilioNumber(params: TwilioConfigParams): Promise
|
|||
}
|
||||
|
||||
// Save Twilio configuration to MongoDB
|
||||
export async function saveTwilioConfig(params: TwilioConfigParams): Promise<TwilioConfig> {
|
||||
async function saveTwilioConfig(params: z.infer<typeof TwilioConfigParams>): Promise<z.infer<typeof TwilioConfig>> {
|
||||
console.log('saveTwilioConfig - Incoming params:', {
|
||||
...params,
|
||||
label: {
|
||||
|
|
@ -140,7 +142,7 @@ export async function saveTwilioConfig(params: TwilioConfigParams): Promise<Twil
|
|||
}
|
||||
|
||||
// Get Twilio configuration for a workflow
|
||||
export async function getTwilioConfigs(projectId: string) {
|
||||
export async function getTwilioConfigs(projectId: string): Promise<WithStringId<z.infer<typeof TwilioConfig>>[]> {
|
||||
console.log('getTwilioConfigs - Fetching for projectId:', projectId);
|
||||
const configs = await twilioConfigsCollection
|
||||
.find({
|
||||
|
|
@ -174,13 +176,13 @@ export async function deleteTwilioConfig(projectId: string, configId: string) {
|
|||
}
|
||||
|
||||
// Mock implementation for testing/development
|
||||
export async function mockConfigureTwilioNumber(params: TwilioConfigParams): Promise<TwilioConfigResponse> {
|
||||
export async function mockConfigureTwilioNumber(params: z.infer<typeof TwilioConfigParams>): Promise<TwilioConfigResponse> {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await saveTwilioConfig(params);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function configureInboundCall(
|
||||
async function configureInboundCall(
|
||||
phone_number: string,
|
||||
account_sid: string,
|
||||
auth_token: string,
|
||||
|
|
@ -228,7 +230,7 @@ export async function configureInboundCall(
|
|||
throw new Error('Voice service must use a public URL, not localhost.');
|
||||
}
|
||||
|
||||
const inboundUrl = `${baseUrl}/inbound?workflow_id=${workflow_id}`;
|
||||
const inboundUrl = `${baseUrl}/api/twilio/inbound_call`;
|
||||
console.log('Setting up webhooks:', {
|
||||
voiceUrl: inboundUrl,
|
||||
statusCallback: `${baseUrl}/call-status`,
|
||||
|
|
|
|||
120
apps/rowboat/app/api/twilio/inbound_call/route.ts
Normal file
120
apps/rowboat/app/api/twilio/inbound_call/route.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { getResponse } from "@/app/lib/agents";
|
||||
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
|
||||
import { collectProjectTools } from "@/app/lib/project_tools";
|
||||
import { PrefixLogger } from "@/app/lib/utils";
|
||||
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { z } from "zod";
|
||||
import { TwilioInboundCall } from "@/app/lib/types/voice_types";
|
||||
import { hangup, reject, XmlResponse, ZStandardRequestParams } from "../utils";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let logger = new PrefixLogger("twilioInboundCall");
|
||||
logger.log("Received inbound call request");
|
||||
const recvdAt = new Date();
|
||||
|
||||
/*
|
||||
form data example
|
||||
...
|
||||
{
|
||||
Called: '+1571XXXXXXX',
|
||||
ToState: 'VA',
|
||||
CallerCountry: 'IN',
|
||||
Direction: 'inbound',
|
||||
CallerState: 'PXXXXXXX',
|
||||
ToZip: '',
|
||||
CallSid: 'CA...b0',
|
||||
To: '+1571XXXXXXX',
|
||||
CallerZip: '',
|
||||
ToCountry: 'US',
|
||||
StirVerstat: 'TN-Validation-Passed-C',
|
||||
CallToken: '%7B...',
|
||||
CalledZip: '',
|
||||
ApiVersion: '2010-04-01',
|
||||
CalledCity: '',
|
||||
CallStatus: 'ringing',
|
||||
From: '+919XXXXXXXXX',
|
||||
AccountSid: 'A....1c',
|
||||
CalledCountry: 'US',
|
||||
CallerCity: '',
|
||||
ToCity: '',
|
||||
FromCountry: 'IN',
|
||||
Caller: '+919XXXXXXXXX'
|
||||
FromCity: '',
|
||||
CalledState: 'VA',
|
||||
FromZip: '',
|
||||
FromState: 'PXXXXXXX'
|
||||
}
|
||||
*/
|
||||
// parse and validate form data
|
||||
const formData = await request.formData();
|
||||
logger.log('request body:', JSON.stringify(Object.fromEntries(formData)));
|
||||
const data = ZStandardRequestParams.parse(Object.fromEntries(formData));
|
||||
logger = logger.child(data.To);
|
||||
|
||||
// get a matching twilio config for this phone number.
|
||||
// if not found, reject the call
|
||||
const twilioConfig = await twilioConfigsCollection.findOne({
|
||||
phone_number: data.To,
|
||||
status: 'active',
|
||||
});
|
||||
if (!twilioConfig) {
|
||||
logger.log('No active twilio config found for this phone number');
|
||||
return reject('rejected');
|
||||
}
|
||||
|
||||
// extract workflow and project id and fetch workflow from db
|
||||
// if workflow not found, reject the call
|
||||
const projectId = twilioConfig.project_id;
|
||||
const workflowId = twilioConfig.workflow_id;
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
projectId: projectId,
|
||||
_id: new ObjectId(workflowId),
|
||||
});
|
||||
if (!workflow) {
|
||||
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
|
||||
return reject('rejected');
|
||||
}
|
||||
|
||||
// fetch project tools
|
||||
const projectTools = await collectProjectTools(projectId);
|
||||
|
||||
// this is the first turn, get the initial assistant response
|
||||
// and validate it
|
||||
const { messages } = await getResponse(workflow, projectTools, []);
|
||||
if (messages.length === 0) {
|
||||
logger.log('Agent response is empty');
|
||||
return hangup();
|
||||
}
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.role !== 'assistant' || !lastMessage.content) {
|
||||
logger.log('Invalid last message');
|
||||
return hangup();
|
||||
}
|
||||
|
||||
// save call state
|
||||
const call: z.infer<typeof TwilioInboundCall> = {
|
||||
callSid: data.CallSid,
|
||||
to: data.To,
|
||||
from: data.From,
|
||||
projectId,
|
||||
workflowId,
|
||||
messages,
|
||||
createdAt: recvdAt.toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
};
|
||||
await twilioInboundCallsCollection.insertOne(call);
|
||||
|
||||
// speak out response
|
||||
const response = new VoiceResponse();
|
||||
response.say(lastMessage.content);
|
||||
response.gather({
|
||||
input: ['speech'],
|
||||
speechTimeout: 'auto',
|
||||
language: 'en-US',
|
||||
enhanced: true,
|
||||
speechModel: 'phone_call',
|
||||
action: `/api/twilio/turn/${data.CallSid}`,
|
||||
});
|
||||
return XmlResponse(response);
|
||||
}
|
||||
97
apps/rowboat/app/api/twilio/turn/[callSid]/route.ts
Normal file
97
apps/rowboat/app/api/twilio/turn/[callSid]/route.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { getResponse } from "@/app/lib/agents";
|
||||
import { agentWorkflowsCollection, twilioConfigsCollection, twilioInboundCallsCollection } from "@/app/lib/mongodb";
|
||||
import { collectProjectTools } from "@/app/lib/project_tools";
|
||||
import { PrefixLogger } from "@/app/lib/utils";
|
||||
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { z } from "zod";
|
||||
import { hangup, XmlResponse, ZStandardRequestParams } from "../../utils";
|
||||
import { Message } from "@/app/lib/types/types";
|
||||
|
||||
const ZRequestData = ZStandardRequestParams.extend({
|
||||
SpeechResult: z.string(),
|
||||
Confidence: z.string(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ callSid: string }> }
|
||||
) {
|
||||
const { callSid } = await params;
|
||||
let logger = new PrefixLogger(`turn:${callSid}`);
|
||||
logger.log("Received turn");
|
||||
|
||||
// parse and validate form data
|
||||
const formData = await request.formData();
|
||||
logger.log('request body:', JSON.stringify(Object.fromEntries(formData)));
|
||||
const data = ZRequestData.parse(Object.fromEntries(formData));
|
||||
|
||||
// get call state from db
|
||||
// if not found, hangup the call
|
||||
const call = await twilioInboundCallsCollection.findOne({
|
||||
callSid,
|
||||
});
|
||||
if (!call) {
|
||||
logger.log('Call not found');
|
||||
return hangup();
|
||||
}
|
||||
const { workflowId, projectId } = call;
|
||||
|
||||
// fetch workflow
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
projectId: projectId,
|
||||
_id: new ObjectId(workflowId),
|
||||
});
|
||||
if (!workflow) {
|
||||
logger.log(`Workflow ${workflowId} not found for project ${projectId}`);
|
||||
return hangup();
|
||||
}
|
||||
|
||||
// fetch project tools
|
||||
const projectTools = await collectProjectTools(projectId);
|
||||
|
||||
// add user speech as user message, and get assistant response
|
||||
const reqMessages: z.infer<typeof Message>[] = [
|
||||
...call.messages,
|
||||
{
|
||||
role: 'user',
|
||||
content: data.SpeechResult,
|
||||
}
|
||||
];
|
||||
const { messages } = await getResponse(workflow, projectTools, reqMessages);
|
||||
if (messages.length === 0) {
|
||||
logger.log('Agent response is empty');
|
||||
return hangup();
|
||||
}
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (lastMessage.role !== 'assistant' || !lastMessage.content) {
|
||||
logger.log('Invalid last message');
|
||||
return hangup();
|
||||
}
|
||||
|
||||
// save call state
|
||||
await twilioInboundCallsCollection.updateOne({
|
||||
_id: call._id,
|
||||
}, {
|
||||
$set: {
|
||||
messages: [
|
||||
...reqMessages,
|
||||
...messages,
|
||||
],
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
}
|
||||
});
|
||||
|
||||
// speak out response
|
||||
const response = new VoiceResponse();
|
||||
response.say(lastMessage.content);
|
||||
response.gather({
|
||||
input: ['speech'],
|
||||
speechTimeout: 'auto',
|
||||
language: 'en-US',
|
||||
enhanced: true,
|
||||
speechModel: 'phone_call',
|
||||
action: `/api/twilio/turn/${callSid}`,
|
||||
});
|
||||
return XmlResponse(response);
|
||||
}
|
||||
32
apps/rowboat/app/api/twilio/utils.ts
Normal file
32
apps/rowboat/app/api/twilio/utils.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import TwiML from "twilio/lib/twiml/TwiML";
|
||||
import VoiceResponse from "twilio/lib/twiml/VoiceResponse";
|
||||
import { z } from "zod";
|
||||
|
||||
export function XmlResponse(content: TwiML) {
|
||||
return new Response(content.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/xml",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function reject(reason: VoiceResponse.RejectAttributes['reason']) {
|
||||
return XmlResponse(new VoiceResponse()
|
||||
.reject({
|
||||
reason,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function hangup() {
|
||||
return XmlResponse(new VoiceResponse()
|
||||
.hangup()
|
||||
);
|
||||
}
|
||||
|
||||
export const ZStandardRequestParams = z.object({
|
||||
To: z.string(),
|
||||
Direction: z.literal('inbound'),
|
||||
CallSid: z.string(),
|
||||
From: z.string(),
|
||||
});
|
||||
|
|
@ -8,7 +8,7 @@ import { EmbeddingDoc } from "./types/datasource_types";
|
|||
import { DataSourceDoc } from "./types/datasource_types";
|
||||
import { DataSource } from "./types/datasource_types";
|
||||
import { TestScenario, TestResult, TestRun, TestProfile, TestSimulation } from "./types/testing_types";
|
||||
import { TwilioConfig } from "./types/voice_types";
|
||||
import { TwilioConfig, TwilioInboundCall } from "./types/voice_types";
|
||||
import { z } from 'zod';
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
|
||||
|
|
@ -32,6 +32,7 @@ export const chatsCollection = db.collection<z.infer<typeof apiV1.Chat>>("chats"
|
|||
export const chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
|
||||
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
|
||||
export const usersCollection = db.collection<z.infer<typeof User>>("users");
|
||||
export const twilioInboundCallsCollection = db.collection<z.infer<typeof TwilioInboundCall>>("twilio_inbound_calls");
|
||||
|
||||
// Create indexes
|
||||
twilioConfigsCollection.createIndexes([
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from 'zod';
|
||||
import { WithId } from 'mongodb';
|
||||
import { Message } from './types';
|
||||
|
||||
export const TwilioConfigParams = z.object({
|
||||
phone_number: z.string(),
|
||||
|
|
@ -15,9 +16,6 @@ export const TwilioConfig = TwilioConfigParams.extend({
|
|||
status: z.enum(['active', 'deleted']),
|
||||
});
|
||||
|
||||
export type TwilioConfigParams = z.infer<typeof TwilioConfigParams>;
|
||||
export type TwilioConfig = WithId<z.infer<typeof TwilioConfig>>;
|
||||
|
||||
export interface TwilioConfigResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
|
|
@ -29,4 +27,15 @@ export interface InboundConfigResponse {
|
|||
workflow_id: string;
|
||||
previous_webhook?: string;
|
||||
error?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export const TwilioInboundCall = z.object({
|
||||
callSid: z.string(),
|
||||
to: z.string(),
|
||||
from: z.string(),
|
||||
projectId: z.string(),
|
||||
workflowId: z.string(),
|
||||
messages: z.array(Message),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUpdatedAt: z.string().datetime().optional(),
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Metadata } from "next";
|
||||
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@heroui/react";
|
||||
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider, Tab, Tabs } from "@heroui/react";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
|
||||
import { CopyButton } from "../../../../components/common/copy-button";
|
||||
|
|
@ -15,6 +15,7 @@ import { Label } from "../../../lib/components/label";
|
|||
import { FormSection } from "../../../lib/components/form-section";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { ProjectSection } from './components/project';
|
||||
import { VoiceSection } from "./components/voice";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Project config",
|
||||
|
|
@ -446,7 +447,7 @@ export function DeleteProjectSection({
|
|||
const [projectName, setProjectName] = useState("");
|
||||
const [projectNameInput, setProjectNameInput] = useState("");
|
||||
const [confirmationInput, setConfirmationInput] = useState("");
|
||||
|
||||
|
||||
const isValid = projectNameInput === projectName && confirmationInput === "delete project";
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -473,8 +474,8 @@ export function DeleteProjectSection({
|
|||
This action cannot be undone.
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
color="danger"
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
onPress={onOpen}
|
||||
isDisabled={loading}
|
||||
|
|
@ -510,8 +511,8 @@ export function DeleteProjectSection({
|
|||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={handleDelete}
|
||||
isDisabled={!isValid}
|
||||
>
|
||||
|
|
@ -565,25 +566,39 @@ export function ConfigApp({
|
|||
useChatWidget: boolean;
|
||||
chatWidgetHost: string;
|
||||
}) {
|
||||
const [selected, setSelected] = useState("general");
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<Panel
|
||||
variant="projects"
|
||||
title={
|
||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Project Settings</span>
|
||||
</div>
|
||||
}
|
||||
<Tabs
|
||||
selectedKey={selected}
|
||||
onSelectionChange={(key) => setSelected(key.toString())}
|
||||
fullWidth
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<ProjectSection
|
||||
projectId={projectId}
|
||||
useChatWidget={useChatWidget}
|
||||
chatWidgetHost={chatWidgetHost}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
<Tab
|
||||
key="general"
|
||||
title="Project settings"
|
||||
>
|
||||
<Panel title="Project settings">
|
||||
<ProjectSection
|
||||
projectId={projectId}
|
||||
useChatWidget={useChatWidget}
|
||||
chatWidgetHost={chatWidgetHost}
|
||||
/>
|
||||
</Panel>
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
key="twilio"
|
||||
title="Twilio"
|
||||
>
|
||||
<Panel title="Twilio settings">
|
||||
<VoiceSection
|
||||
projectId={projectId}
|
||||
/>
|
||||
</Panel>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { TwilioConfig } from "../../../../lib/types/voice_types";
|
|||
import { CheckCircleIcon, XCircleIcon, InfoIcon, EyeOffIcon, EyeIcon } from "lucide-react";
|
||||
import { Section } from './project';
|
||||
import { clsx } from 'clsx';
|
||||
import { WithStringId } from "../../../../lib/types/types";
|
||||
import { z } from 'zod';
|
||||
|
||||
function PhoneNumberSection({
|
||||
value,
|
||||
|
|
@ -149,7 +151,7 @@ export function VoiceSection({ projectId }: { projectId: string }) {
|
|||
authToken: '',
|
||||
label: ''
|
||||
});
|
||||
const [existingConfig, setExistingConfig] = useState<TwilioConfig | null>(null);
|
||||
const [existingConfig, setExistingConfig] = useState<WithStringId<z.infer<typeof TwilioConfig>> | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
|
@ -207,7 +209,7 @@ export function VoiceSection({ projectId }: { projectId: string }) {
|
|||
setError(null);
|
||||
|
||||
const configParams = {
|
||||
phone_number: formState.phone,
|
||||
phone_number: formState.phone.replaceAll(/[^0-9\+]/g, ''),
|
||||
account_sid: formState.accountSid,
|
||||
auth_token: formState.authToken,
|
||||
label: formState.label,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue