bring back twilio integration

This commit is contained in:
Ramnique Singh 2025-07-15 23:34:28 +05:30
parent e735795e7f
commit 27ad5d675a
10 changed files with 320 additions and 41 deletions

View file

@ -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`,

View 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);
}

View 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);
}

View 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(),
});

View file

@ -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([

View file

@ -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(),
})

View file

@ -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>
);
}

View file

@ -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,