Merge pull request #48 from rowboatlabs/voice-rebased

add Twilio voice handler
This commit is contained in:
Ramnique Singh 2025-03-27 12:37:11 +05:30 committed by GitHub
commit 19cb3d3de8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2322 additions and 86 deletions

View file

@ -0,0 +1,280 @@
'use server';
import { TwilioConfigParams, TwilioConfigResponse, TwilioConfig, InboundConfigResponse } from "../lib/types/voice_types";
import { twilioConfigsCollection } from "../lib/mongodb";
import { ObjectId } from "mongodb";
import twilio from 'twilio';
import { Twilio } from 'twilio';
// Helper function to serialize MongoDB documents
function serializeConfig(config: any) {
return {
...config,
_id: config._id.toString(),
createdAt: config.createdAt.toISOString(),
};
}
// Real implementation for configuring Twilio number
export async function configureTwilioNumber(params: TwilioConfigParams): Promise<TwilioConfigResponse> {
console.log('configureTwilioNumber - Received params:', params);
try {
const client = twilio(params.account_sid, params.auth_token);
try {
// List all phone numbers and find the matching one
const numbers = await client.incomingPhoneNumbers.list();
console.log('Twilio numbers for this account:', numbers);
const phoneExists = numbers.some(
number => number.phoneNumber === params.phone_number
);
if (!phoneExists) {
throw new Error('Phone number not found in this account');
}
} catch (error) {
console.error('Error verifying phone number:', error);
throw new Error(
error instanceof Error
? error.message
: 'Invalid phone number or phone number does not belong to this account'
);
}
// Save to MongoDB after successful validation
const savedConfig = await saveTwilioConfig(params);
console.log('configureTwilioNumber - Saved config result:', savedConfig);
return { success: true };
} catch (error) {
console.error('Error in configureTwilioNumber:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to configure Twilio number'
};
}
}
// Save Twilio configuration to MongoDB
export async function saveTwilioConfig(params: TwilioConfigParams): Promise<TwilioConfig> {
console.log('saveTwilioConfig - Incoming params:', {
...params,
label: {
value: params.label,
type: typeof params.label,
length: params.label?.length,
isEmpty: params.label === ''
}
});
// First, list all configs to see what's in the database
const allConfigs = await twilioConfigsCollection
.find({ status: 'active' as const })
.toArray();
console.log('saveTwilioConfig - All active configs in DB:', allConfigs);
// Find existing config for this project
const existingConfig = await twilioConfigsCollection.findOne({
project_id: params.project_id,
status: 'active' as const
});
console.log('saveTwilioConfig - Existing config search by project:', {
searchCriteria: {
project_id: params.project_id,
status: 'active'
},
found: existingConfig
});
const configToSave = {
phone_number: params.phone_number,
account_sid: params.account_sid,
auth_token: params.auth_token,
label: params.label || '', // Use empty string instead of undefined
project_id: params.project_id,
workflow_id: params.workflow_id,
createdAt: existingConfig?.createdAt || new Date(),
status: 'active' as const
};
console.log('saveTwilioConfig - Config to save:', configToSave);
try {
// Configure inbound calls first
await configureInboundCall(
params.phone_number,
params.account_sid,
params.auth_token,
params.workflow_id
);
// Then save/update the config in database
if (existingConfig) {
console.log('saveTwilioConfig - Updating existing config:', existingConfig._id);
const result = await twilioConfigsCollection.updateOne(
{ _id: existingConfig._id },
{ $set: configToSave }
);
console.log('saveTwilioConfig - Update result:', result);
} else {
console.log('saveTwilioConfig - No existing config found, creating new');
const result = await twilioConfigsCollection.insertOne(configToSave);
console.log('saveTwilioConfig - Insert result:', result);
}
const savedConfig = await twilioConfigsCollection.findOne({
project_id: params.project_id,
status: 'active'
});
if (!savedConfig) {
throw new Error('Failed to save Twilio configuration');
}
console.log('configureTwilioNumber - Saved config result:', savedConfig);
return savedConfig;
} catch (error) {
console.error('Error saving Twilio config:', error);
throw error;
}
}
// Get Twilio configuration for a workflow
export async function getTwilioConfigs(projectId: string) {
console.log('getTwilioConfigs - Fetching for projectId:', projectId);
const configs = await twilioConfigsCollection
.find({
project_id: projectId,
status: 'active' as const
})
.sort({ createdAt: -1 })
.limit(1)
.toArray();
console.log('getTwilioConfigs - Raw configs:', configs);
const serializedConfigs = configs.map(serializeConfig);
console.log('getTwilioConfigs - Serialized configs:', serializedConfigs);
return serializedConfigs;
}
// Delete a Twilio configuration (soft delete)
export async function deleteTwilioConfig(projectId: string, configId: string) {
console.log('deleteTwilioConfig - Deleting config:', { projectId, configId });
const result = await twilioConfigsCollection.updateOne(
{
_id: new ObjectId(configId),
project_id: projectId
},
{
$set: { status: 'deleted' as const }
}
);
console.log('deleteTwilioConfig - Delete result:', result);
return result;
}
// Mock implementation for testing/development
export async function mockConfigureTwilioNumber(params: TwilioConfigParams): Promise<TwilioConfigResponse> {
await new Promise(resolve => setTimeout(resolve, 1000));
await saveTwilioConfig(params);
return { success: true };
}
export async function configureInboundCall(
phone_number: string,
account_sid: string,
auth_token: string,
workflow_id: string
): Promise<InboundConfigResponse> {
try {
// Normalize phone number format
if (!phone_number.startsWith('+')) {
phone_number = '+' + phone_number;
}
console.log('Configuring inbound call for:', {
phone_number,
workflow_id
});
// Initialize Twilio client
const client = new Twilio(account_sid, auth_token);
// Find the phone number in Twilio account
const incomingPhoneNumbers = await client.incomingPhoneNumbers.list({ phoneNumber: phone_number });
console.log('Found Twilio numbers:', incomingPhoneNumbers.map(n => ({
phoneNumber: n.phoneNumber,
currentVoiceUrl: n.voiceUrl,
currentStatusCallback: n.statusCallback,
sid: n.sid
})));
if (!incomingPhoneNumbers.length) {
throw new Error(`Phone number ${phone_number} not found in Twilio account`);
}
const phoneSid = incomingPhoneNumbers[0].sid;
const currentVoiceUrl = incomingPhoneNumbers[0].voiceUrl;
const wasPreviouslyConfigured = Boolean(currentVoiceUrl);
// Get base URL from environment - MUST be a public URL
const baseUrl = process.env.VOICE_API_URL;
if (!baseUrl) {
throw new Error('Voice service URL not configured. Please set VOICE_API_URL environment variable.');
}
// Validate URL is not localhost
if (baseUrl.includes('localhost')) {
throw new Error('Voice service must use a public URL, not localhost.');
}
const inboundUrl = `${baseUrl}/inbound?workflow_id=${workflow_id}`;
console.log('Setting up webhooks:', {
voiceUrl: inboundUrl,
statusCallback: `${baseUrl}/call-status`,
currentConfig: {
voiceUrl: currentVoiceUrl,
statusCallback: incomingPhoneNumbers[0].statusCallback
}
});
// Update the phone number configuration
const updatedNumber = await client.incomingPhoneNumbers(phoneSid).update({
voiceUrl: inboundUrl,
voiceMethod: 'POST',
statusCallback: `${baseUrl}/call-status`,
statusCallbackMethod: 'POST'
});
console.log('Webhook configuration complete:', {
phoneNumber: updatedNumber.phoneNumber,
newVoiceUrl: updatedNumber.voiceUrl,
newStatusCallback: updatedNumber.statusCallback,
success: updatedNumber.voiceUrl === inboundUrl
});
return {
status: wasPreviouslyConfigured ? 'reconfigured' : 'configured',
phone_number: phone_number,
workflow_id: workflow_id,
previous_webhook: wasPreviouslyConfigured ? currentVoiceUrl : undefined
};
} catch (err: unknown) {
console.error('Error configuring inbound call:', err);
// Type guard for error with message property
if (err instanceof Error) {
if (err.message.includes('localhost')) {
throw new Error('Voice service needs to be accessible from the internet. Please check your configuration.');
}
// Type guard for Twilio error
if ('code' in err && err.code === 21402) {
throw new Error('Invalid voice service URL. Please make sure it\'s a public, secure URL.');
}
}
// If we can't determine the specific error, throw a generic one
throw new Error('Failed to configure phone number. Please check your settings and try again.');
}
}

View file

@ -0,0 +1,204 @@
import { Button, Input, Textarea } from "@heroui/react";
import { useEffect, useRef, useState } from "react";
import { useClickAway } from "../../../hooks/use-click-away";
import MarkdownContent from "./markdown-content";
import clsx from "clsx";
import { Label } from "./label";
import { SparklesIcon } from "lucide-react";
interface EditableFieldProps {
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
markdown?: boolean;
multiline?: boolean;
locked?: boolean;
className?: string;
validate?: (value: string) => { valid: boolean; errorMessage?: string };
light?: boolean;
error?: string | null;
inline?: boolean;
showGenerateButton?: {
show: boolean;
setShow: (show: boolean) => void;
};
disabled?: boolean;
type?: string;
}
export function EditableField({
value,
onChange,
label,
placeholder = "Click to edit...",
markdown = false,
multiline = false,
locked = false,
className = "flex flex-col gap-1 w-full",
validate,
light = false,
error,
inline = false,
showGenerateButton,
disabled = false,
type = "text",
}: EditableFieldProps) {
const [isEditing, setIsEditing] = useState(false);
const [localValue, setLocalValue] = useState(value);
const ref = useRef<HTMLDivElement>(null);
const validationResult = validate?.(localValue);
const isValid = !validate || validationResult?.valid;
useEffect(() => {
setLocalValue(value);
}, [value]);
useClickAway(ref, () => {
if (isEditing) {
if (isValid && localValue !== value) {
onChange(localValue);
} else {
setLocalValue(value);
}
setIsEditing(false);
}
});
const onValueChange = (newValue: string) => {
setLocalValue(newValue);
onChange(newValue); // Always save immediately
};
const commonProps = {
autoFocus: true,
value: localValue,
onValueChange: onValueChange,
variant: "bordered" as const,
labelPlacement: "outside" as const,
placeholder: markdown ? '' : placeholder,
classNames: {
input: "rounded-md",
inputWrapper: "rounded-md border-medium"
},
radius: "md" as const,
isInvalid: !isValid,
errorMessage: validationResult?.errorMessage,
onKeyDown: (e: React.KeyboardEvent) => {
if (!multiline && e.key === "Enter") {
e.preventDefault();
if (isValid && localValue !== value) {
onChange(localValue);
}
setIsEditing(false);
}
if (e.key === "Escape") {
setLocalValue(value);
setIsEditing(false);
}
},
};
if (isEditing) {
return (
<div ref={ref} className={clsx("flex flex-col gap-1 w-full", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
<div className="flex gap-2 items-center">
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
</div>
)}
{multiline && <Textarea
{...commonProps}
minRows={3}
maxRows={20}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
{!multiline && <Input
{...commonProps}
type={type}
className="w-full"
classNames={{
...commonProps.classNames,
input: "rounded-md py-2",
inputWrapper: "rounded-md border-medium py-1"
}}
/>}
</div>
);
}
return (
<div ref={ref} className={clsx("cursor-text", className)}>
{label && (
<div className="flex justify-between items-center">
<Label label={label} />
{showGenerateButton && (
<Button
variant="light"
size="sm"
startContent={<SparklesIcon size={16} />}
onPress={() => showGenerateButton.setShow(true)}
>
Generate
</Button>
)}
</div>
)}
<div
className={clsx(
{
"border border-gray-300 dark:border-gray-600 rounded px-3 py-3": !inline,
"bg-transparent focus:outline-none focus:ring-0 border-0 rounded-none text-gray-900 dark:text-gray-100": inline,
}
)}
style={inline ? {
border: 'none',
borderRadius: '0',
padding: '0'
} : undefined}
onClick={() => !locked && setIsEditing(true)}
>
{value ? (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto">
<MarkdownContent content={value} />
</div>}
{!markdown && <div className={`${multiline ? 'whitespace-pre-wrap max-h-[420px] overflow-y-auto' : 'flex items-center'}`}>
{value}
</div>}
</>
) : (
<>
{markdown && <div className="max-h-[420px] overflow-y-auto text-gray-400">
<MarkdownContent content={placeholder} />
</div>}
{!markdown && <span className="text-gray-400">{placeholder}</span>}
</>
)}
{error && (
<div className="text-xs text-red-500 mt-1">
{error}
</div>
)}
</div>
</div>
);
}

View file

@ -8,6 +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 { z } from 'zod';
import { apiV1 } from "rowboat-shared";
@ -28,4 +29,16 @@ export const testSimulationsCollection = db.collection<z.infer<typeof TestSimula
export const testRunsCollection = db.collection<z.infer<typeof TestRun>>("test_runs");
export const testResultsCollection = db.collection<z.infer<typeof TestResult>>("test_results");
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 chatMessagesCollection = db.collection<z.infer<typeof apiV1.ChatMessage>>("chat_messages");
export const twilioConfigsCollection = db.collection<z.infer<typeof TwilioConfig>>("twilio_configs");
// Create indexes
twilioConfigsCollection.createIndexes([
{
key: { workflow_id: 1, status: 1 },
name: "workflow_status_idx",
// This ensures only one active config per workflow
unique: true,
partialFilterExpression: { status: "active" }
}
]);

View file

@ -0,0 +1,32 @@
import { z } from 'zod';
import { WithId } from 'mongodb';
export const TwilioConfigParams = z.object({
phone_number: z.string(),
account_sid: z.string(),
auth_token: z.string(),
label: z.string(),
project_id: z.string(),
workflow_id: z.string(),
});
export const TwilioConfig = TwilioConfigParams.extend({
createdAt: z.date(),
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;
}
export interface InboundConfigResponse {
status: 'configured' | 'reconfigured';
phone_number: string;
workflow_id: string;
previous_webhook?: string;
error?: string;
}

View file

@ -7,12 +7,21 @@ import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, de
import { updateMcpServers } from "../../../actions/mcp_actions";
import { CopyButton } from "../../../lib/components/copy-button";
import { EditableField } from "../../../lib/components/editable-field";
import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon } from "lucide-react";
import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon, CheckCircleIcon, XCircleIcon } from "lucide-react";
import { WithStringId } from "../../../lib/types/types";
import { ApiKey } from "../../../lib/types/project_types";
import { z } from "zod";
import { RelativeTime } from "@primer/react";
import { Label } from "../../../lib/components/label";
import { ListItem } from "../../../lib/components/structured-list";
import { FormSection } from "../../../lib/components/form-section";
import { StructuredPanel } from "../../../lib/components/structured-panel";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "../../../../components/ui/resizable"
import { VoiceSection } from './voice';
export const metadata: Metadata = {
title: "Project config",
@ -79,38 +88,29 @@ export function BasicSettingsSection({
}
return <Section title="Basic settings">
<SectionRow>
<LeftLabel label="Project name" />
<RightContent>
<div className="flex flex-row gap-2 items-center">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={projectName || ''}
onChange={updateName}
className="w-full"
/>}
</div>
</RightContent>
</SectionRow>
<FormSection label="Project name">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={projectName || ''}
onChange={updateName}
className="w-full"
/>}
</FormSection>
<Divider />
<SectionRow>
<LeftLabel label="Project ID" />
<RightContent>
<div className="flex flex-row gap-2 items-center">
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
<CopyButton
onCopy={() => {
navigator.clipboard.writeText(projectId);
}}
label="Copy"
successLabel="Copied"
/>
</div>
</RightContent>
</SectionRow>
<FormSection label="Project ID">
<div className="flex flex-row gap-2 items-center">
<div className="text-gray-600 text-sm font-mono">{projectId}</div>
<CopyButton
onCopy={() => {
navigator.clipboard.writeText(projectId);
}}
label="Copy"
successLabel="Copied"
/>
</div>
</FormSection>
</Section>;
}
@ -630,20 +630,15 @@ export function WebhookUrlSection({
In workflow editor, tool calls will be posted to this URL, unless they are mocked.
</p>
<Divider />
<SectionRow>
<LeftLabel label="Webhook URL" />
<RightContent>
<div className="flex flex-row gap-2 items-center">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={webhookUrl || ''}
onChange={update}
validate={validate}
className="w-full"
/>}
</div>
</RightContent>
</SectionRow>
<FormSection label="Webhook URL">
{loading && <Spinner size="sm" />}
{!loading && <EditableField
value={webhookUrl || ''}
onChange={update}
validate={validate}
className="w-full"
/>}
</FormSection>
</Section>;
}
@ -789,7 +784,30 @@ export function DeleteProjectSection({
);
}
export default function App({
function NavigationMenu({
selected,
onSelect
}: {
selected: string;
onSelect: (page: string) => void;
}) {
const items = ['Project', 'Tools', 'Voice'];
return (
<StructuredPanel title="SETTINGS">
{items.map((item) => (
<ListItem
key={item}
name={item}
isSelected={selected === item}
onClick={() => onSelect(item)}
/>
))}
</StructuredPanel>
);
}
export function ConfigApp({
projectId,
useChatWidget,
chatWidgetHost,
@ -798,22 +816,54 @@ export default function App({
useChatWidget: boolean;
chatWidgetHost: string;
}) {
return <div className="flex flex-col h-full">
<div className="shrink-0 flex justify-between items-center pb-4 border-b border-border">
<div className="flex flex-col">
<h1 className="text-lg">Project config</h1>
</div>
</div>
<div className="grow overflow-auto py-4">
<div className="max-w-[768px] mx-auto flex flex-col gap-4">
<BasicSettingsSection projectId={projectId} />
<SecretSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
<McpServersSection projectId={projectId} />
<WebhookUrlSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
<DeleteProjectSection projectId={projectId} />
</div>
</div>
</div>;
}
const [selectedPage, setSelectedPage] = useState('Project');
const renderContent = () => {
switch (selectedPage) {
case 'Project':
return (
<div className="h-full overflow-auto p-6 space-y-6">
<BasicSettingsSection projectId={projectId} />
<SecretSection projectId={projectId} />
<McpServersSection projectId={projectId} />
<WebhookUrlSection projectId={projectId} />
<ApiKeysSection projectId={projectId} />
{useChatWidget && <ChatWidgetSection projectId={projectId} chatWidgetHost={chatWidgetHost} />}
<DeleteProjectSection projectId={projectId} />
</div>
);
case 'Tools':
return (
<div className="h-full overflow-auto p-6">
<WebhookUrlSection projectId={projectId} />
</div>
);
case 'Voice':
return (
<div className="h-full overflow-auto p-6">
<VoiceSection projectId={projectId} />
</div>
);
default:
return null;
}
};
return (
<ResizablePanelGroup direction="horizontal" className="h-screen gap-1">
<ResizablePanel minSize={10} defaultSize={15}>
<NavigationMenu
selected={selectedPage}
onSelect={setSelectedPage}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={20} defaultSize={85}>
{renderContent()}
</ResizablePanel>
</ResizablePanelGroup>
);
}
// Add default export
export default ConfigApp;

View file

@ -0,0 +1,229 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from "@heroui/react";
import { configureTwilioNumber, mockConfigureTwilioNumber, getTwilioConfigs, deleteTwilioConfig } from "../../../actions/voice_actions";
import { FormSection } from "../../../lib/components/form-section";
import { EditableField } from "../../../lib/components/editable-field-with-immediate-save";
import { StructuredPanel } from "../../../lib/components/structured-panel";
import { TwilioConfig } from "../../../lib/types/voice_types";
import { CheckCircleIcon, XCircleIcon, InfoIcon } from "lucide-react";
export function VoiceSection({
projectId,
}: {
projectId: string;
}) {
const [formState, setFormState] = useState({
phone: '',
accountSid: '',
authToken: '',
label: ''
});
const [existingConfig, setExistingConfig] = useState<TwilioConfig | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [configurationValid, setConfigurationValid] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const loadConfig = useCallback(async () => {
try {
const configs = await getTwilioConfigs(projectId);
if (configs.length > 0) {
const config = configs[0];
setExistingConfig(config);
setFormState({
phone: config.phone_number,
accountSid: config.account_sid,
authToken: config.auth_token,
label: config.label || ''
});
setConfigurationValid(true);
setIsDirty(false);
}
} catch (err) {
console.error('Error loading config:', err);
}
}, [projectId]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
const handleFieldChange = (field: string, value: string) => {
setFormState(prev => ({
...prev,
[field]: value
}));
setIsDirty(true);
setError(null);
};
const handleConfigureTwilio = async () => {
if (!formState.phone || !formState.accountSid || !formState.authToken) {
setError('Please fill in all required fields');
setConfigurationValid(false);
return;
}
const workflowId = localStorage.getItem(`lastWorkflowId_${projectId}`);
if (!workflowId) {
setError('No workflow selected. Please select a workflow first.');
setConfigurationValid(false);
return;
}
setLoading(true);
setError(null);
const configParams = {
phone_number: formState.phone,
account_sid: formState.accountSid,
auth_token: formState.authToken,
label: formState.label,
project_id: projectId,
workflow_id: workflowId,
};
const result = await configureTwilioNumber(configParams);
if (result.success) {
await loadConfig();
setSuccess(true);
setConfigurationValid(true);
setIsDirty(false);
setTimeout(() => setSuccess(false), 3000);
} else {
setError(result.error || 'Failed to validate Twilio credentials or phone number');
setConfigurationValid(false);
}
setLoading(false);
};
const handleDeleteConfig = async () => {
if (!existingConfig) return;
if (confirm('Are you sure you want to delete this phone number configuration?')) {
await deleteTwilioConfig(projectId, existingConfig._id.toString());
setExistingConfig(null);
setFormState({
phone: '',
accountSid: '',
authToken: '',
label: ''
});
setConfigurationValid(false);
setIsDirty(false);
}
};
return (
<div className="flex flex-col gap-4">
<StructuredPanel title="CONFIGURE TWILIO PHONE NUMBER">
<div className="flex flex-col gap-4 p-6">
{success && (
<div className="bg-green-50 text-green-700 p-4 rounded-md flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5" />
<span>
{existingConfig
? 'Twilio number validated and updated successfully!'
: 'Twilio number validated and configured successfully!'}
</span>
</div>
)}
{error && (
<div className="bg-red-50 text-red-700 p-4 rounded-md flex items-center gap-2">
<XCircleIcon className="w-5 h-5" />
<span>{error}</span>
</div>
)}
{existingConfig && configurationValid && !error && (
<div className="bg-blue-50 text-blue-700 p-4 rounded-md flex items-center gap-2">
<InfoIcon className="w-5 h-5" />
<span>This is your currently assigned phone number for this project</span>
</div>
)}
<FormSection label="TWILIO PHONE NUMBER">
<EditableField
value={formState.phone}
onChange={(value) => handleFieldChange('phone', value)}
placeholder="+14156021922"
disabled={loading}
/>
</FormSection>
<FormSection label="TWILIO ACCOUNT SID">
<EditableField
value={formState.accountSid}
onChange={(value) => handleFieldChange('accountSid', value)}
placeholder="AC5588686d3ec65df89615274..."
disabled={loading}
/>
</FormSection>
<FormSection label="TWILIO AUTH TOKEN">
<EditableField
value={formState.authToken}
onChange={(value) => handleFieldChange('authToken', value)}
placeholder="b74e48f9098764ef834cf6bd..."
type="password"
disabled={loading}
/>
</FormSection>
<FormSection label="LABEL">
<EditableField
value={formState.label}
onChange={(value) => handleFieldChange('label', value)}
placeholder="Enter a label for this number..."
disabled={loading}
/>
</FormSection>
<div className="flex gap-2 mt-4">
<Button
color="primary"
onClick={handleConfigureTwilio}
isLoading={loading}
disabled={loading || !isDirty}
>
{existingConfig ? 'Update Twilio Config' : 'Import from Twilio'}
</Button>
{existingConfig ? (
<Button
color="danger"
variant="flat"
onClick={handleDeleteConfig}
disabled={loading}
>
Delete Configuration
</Button>
) : (
<Button
variant="flat"
onClick={() => {
setFormState({
phone: '',
accountSid: '',
authToken: '',
label: ''
});
setError(null);
setIsDirty(false);
}}
disabled={loading}
>
Cancel
</Button>
)}
</div>
</div>
</StructuredPanel>
</div>
);
}

View file

@ -52,6 +52,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"tiktoken": "^1.0.17",
"twilio": "^5.4.5",
"typewriter-effect": "^2.21.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.5"
@ -13991,6 +13992,17 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/agentkeepalive": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz",
@ -14398,9 +14410,9 @@
}
},
"node_modules/axios": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz",
"integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==",
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@ -14604,6 +14616,11 @@
"node": ">=16.20.1"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -15197,6 +15214,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -15498,6 +15520,14 @@
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@ -16385,6 +16415,20 @@
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@ -17129,6 +17173,18 @@
"node": ">= 0.8"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
@ -17911,6 +17967,27 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"dependencies": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@ -17926,6 +18003,25 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"dependencies": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -18064,6 +18160,16 @@
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"node_modules/lodash.isempty": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
@ -18075,17 +18181,42 @@
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead."
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"node_modules/lodash.isobject": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/lodash.isobject/-/lodash.isobject-3.0.2.tgz",
"integrity": "sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA=="
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"dev": true
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@ -20170,11 +20301,11 @@
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dependencies": {
"side-channel": "^1.0.6"
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
@ -20747,6 +20878,11 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/scmp": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz",
"integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q=="
},
"node_modules/scroll-into-view-if-needed": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.0.10.tgz",
@ -21713,6 +21849,23 @@
"fsevents": "~2.3.3"
}
},
"node_modules/twilio": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/twilio/-/twilio-5.4.5.tgz",
"integrity": "sha512-PIteif0CBOrA42SWZiT8IwUuqTNakAFgvXYWsrjEPGaDSczu/GvBs3vUock4S+UguXj7cV4qBswWgXs5ySjGNg==",
"dependencies": {
"axios": "^1.7.8",
"dayjs": "^1.11.9",
"https-proxy-agent": "^5.0.0",
"jsonwebtoken": "^9.0.2",
"qs": "^6.9.4",
"scmp": "^2.1.0",
"xmlbuilder": "^13.0.2"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -22460,6 +22613,14 @@
}
}
},
"node_modules/xmlbuilder": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz",
"integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==",
"engines": {
"node": ">=6.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -58,6 +58,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"tiktoken": "^1.0.17",
"twilio": "^5.4.5",
"typewriter-effect": "^2.21.0",
"zod": "^3.23.8",
"zod-to-json-schema": "^3.23.5"