Add twilio configuration user flow

This commit is contained in:
akhisud3195 2025-03-02 18:58:10 +05:30 committed by Ramnique Singh
parent 4b3395ea3a
commit de6b3cbbbb
11 changed files with 926 additions and 65 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";
@ -29,3 +30,15 @@ export const testRunsCollection = db.collection<z.infer<typeof TestRun>>("test_r
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 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 "@nextui-org/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

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

27
apps/voice/Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first to leverage Docker cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Debug: List files in /app
RUN ls -la /app
# Set environment variables
ENV PYTHONPATH=/app
# Expose the port the app runs on
EXPOSE 3009
# Command to run the application directly with Python
CMD ["python", "twilio_server.py"]

1
apps/voice/__init__.py Normal file
View file

@ -0,0 +1 @@
# Empty file to mark directory as Python package

6
apps/voice/load_env.py Normal file
View file

@ -0,0 +1,6 @@
from dotenv import load_dotenv
import os
def load_environment():
"""Load environment variables from .env file"""
load_dotenv()

View file

@ -35,6 +35,7 @@ services:
- CHAT_WIDGET_SESSION_JWT_SECRET=${CHAT_WIDGET_SESSION_JWT_SECRET}
- MAX_QUERIES_PER_MINUTE=${MAX_QUERIES_PER_MINUTE}
- MAX_PROJECTS_PER_USER=${MAX_PROJECTS_PER_USER}
- VOICE_API_URL=${VOICE_API_URL}
restart: unless-stopped
rowboat_agents:
@ -161,4 +162,21 @@ services:
profiles: [ "docs" ]
ports:
- "8000:8000"
voice:
build:
context: ./apps/voice
dockerfile: Dockerfile
ports:
- "3009:3009"
environment:
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
- DEEPGRAM_API_KEY=${DEEPGRAM_API_KEY}
- ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY}
- ROWBOAT_API_HOST=http://rowboat:3000
- ROWBOAT_PROJECT_ID=${ROWBOAT_PROJECT_ID}
- ROWBOAT_API_KEY=${ROWBOAT_API_KEY}
- REDIS_URL=redis://redis:6379
- BASE_URL=${BASE_URL}
restart: unless-stopped