feat: Enable telephony for OSS (#21)

* fix: fix tooltip bug

* feat: add Twilio with CloudFlare configuration

* chore: update Tella Video
This commit is contained in:
Abhishek 2025-10-04 12:22:50 +05:30 committed by GitHub
parent d39a8111a6
commit 8e2e5c9327
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 891 additions and 191 deletions

View file

@ -0,0 +1,14 @@
import BaseHeader from "@/components/header/BaseHeader"
export default function ConfigureTelephonyLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<BaseHeader />
{children}
</>
)
}

View file

@ -0,0 +1,270 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost } from "@/client/sdk.gen";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuth } from "@/lib/auth";
interface TelephonyConfigForm {
provider: string;
account_sid: string;
auth_token: string;
from_number: string;
}
export default function ConfigureTelephonyPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { user, getAccessToken, loading: authLoading } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
// Get returnTo parameter from URL
const returnTo = searchParams.get("returnTo") || "/workflow";
const {
register,
handleSubmit,
formState: { errors },
setValue,
watch,
} = useForm<TelephonyConfigForm>({
defaultValues: {
provider: "twilio",
},
});
const selectedProvider = watch("provider");
useEffect(() => {
// Don't fetch config while auth is still loading
if (authLoading || !user) {
return;
}
// Fetch existing configuration with masked sensitive fields
const fetchConfig = async () => {
try {
const accessToken = await getAccessToken();
const response = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.error && response.data?.twilio) {
setHasExistingConfig(true);
// Masked values like "****************def0" from backend
setValue("account_sid", response.data.twilio.account_sid);
setValue("auth_token", response.data.twilio.auth_token);
if (response.data.twilio.from_numbers?.length > 0) {
setValue("from_number", response.data.twilio.from_numbers[0]);
}
}
} catch (error) {
console.error("Failed to fetch config:", error);
}
};
fetchConfig();
}, [setValue, getAccessToken, authLoading, user]);
const onSubmit = async (data: TelephonyConfigForm) => {
setIsLoading(true);
try {
const accessToken = await getAccessToken();
const response = await saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost({
headers: { Authorization: `Bearer ${accessToken}` },
body: {
provider: data.provider,
account_sid: data.account_sid,
auth_token: data.auth_token,
from_numbers: [data.from_number],
},
});
if (response.error) {
const errorMsg = typeof response.error === 'string'
? response.error
: (response.error as { detail?: string })?.detail || "Failed to save configuration";
throw new Error(errorMsg);
}
toast.success("Telephony configuration saved successfully");
// Redirect back to the page that sent us here
router.push(returnTo);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to save configuration"
);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">Configure Telephony</h1>
<p className="text-gray-600 mb-6">
Set up your telephony provider to make phone calls
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<Card className="h-full">
<CardHeader>
<CardTitle>Setup Guide</CardTitle>
<CardDescription>
Watch this video to learn how to setup telephony
</CardDescription>
</CardHeader>
<CardContent>
<div className="aspect-video">
<iframe
style={{ border: 0 }}
width="100%"
height="100%"
src="https://www.tella.tv/video/cmgbvzkrt00jk0clacu16blm3/embed?b=0&title=1&a=1&loop=0&t=0&muted=0&wt=0"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
</CardContent>
</Card>
</div>
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Provider Configuration</CardTitle>
<CardDescription>
Configure your telephony provider settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Provider Selection */}
<div className="space-y-2">
<Label>Provider</Label>
<Select
value={selectedProvider}
onValueChange={(value) => setValue("provider", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="twilio">Twilio</SelectItem>
</SelectContent>
</Select>
</div>
{/* Twilio-specific fields */}
{selectedProvider === "twilio" && (
<>
<div className="space-y-2">
<Label htmlFor="account_sid">Account SID</Label>
<Input
id="account_sid"
autoComplete="username"
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{...register("account_sid", {
required: "Account SID is required",
})}
/>
{errors.account_sid && (
<p className="text-sm text-red-500">
{errors.account_sid.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="auth_token">Auth Token</Label>
<Input
id="auth_token"
type="password"
autoComplete="current-password"
placeholder={
hasExistingConfig
? "Leave masked to keep existing"
: "Enter your auth token"
}
{...register("auth_token", {
required: !hasExistingConfig
? "Auth token is required"
: false,
})}
/>
{errors.auth_token && (
<p className="text-sm text-red-500">
{errors.auth_token.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="from_number">From Phone Number</Label>
<Input
id="from_number"
autoComplete="tel"
placeholder="+1234567890"
{...register("from_number", {
required: "Phone number is required",
pattern: {
value: /^\+[1-9]\d{1,14}$/,
message:
"Enter a valid phone number with country code (e.g., +1234567890)",
},
})}
/>
{errors.from_number && (
<p className="text-sm text-red-500">
{errors.from_number.message}
</p>
)}
</div>
</>
)}
<div className="pt-4">
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Saving..." : "Save Configuration"}
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
</div>
</div>
</div>
);
}

View file

@ -2,10 +2,11 @@ import 'react-international-phone/style.css';
import { ReactFlowInstance, ReactFlowJsonObject } from "@xyflow/react";
import { AlertTriangle, CheckCheck, Download, LoaderCircle, Phone, ShieldCheck } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { PhoneInput } from 'react-international-phone';
import { initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, initiateCallApiV1TwilioInitiateCallPost } from '@/client/sdk.gen';
import { WorkflowError } from '@/client/types.gen';
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { OnboardingTooltip } from '@/components/onboarding/OnboardingTooltip';
@ -16,7 +17,6 @@ import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
import { useOnboarding } from '@/context/OnboardingContext';
import { useUserConfig } from "@/context/UserConfigContext";
import { useAuth } from '@/lib/auth';
import logger from '@/lib/logger';
interface WorkflowHeaderProps {
isDirty: boolean;
@ -58,24 +58,22 @@ const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonO
};
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
const router = useRouter();
const { userConfig, saveUserConfig } = useUserConfig();
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
const [dialogOpen, setDialogOpen] = useState(false);
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
const [saving, setSaving] = useState(false);
const [savingWorkflow, setSavingWorkflow] = useState(false);
const [callLoading, setCallLoading] = useState(false);
const [callError, setCallError] = useState<string | null>(null);
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
const [phoneChanged, setPhoneChanged] = useState(false);
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
const { user, getAccessToken } = useAuth();
const webCallButtonRef = useRef<HTMLButtonElement>(null);
const hasValidationErrors = workflowValidationErrors.length > 0;
const isOSSDeployment = process.env.NEXT_PUBLIC_DEPLOYMENT_MODE === 'oss';
logger.info(`isOSSDeployment: ${isOSSDeployment}`);
// Reset call-related state whenever the dialog is closed so that a new call can be placed
useEffect(() => {
@ -95,7 +93,6 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
setCallError(null);
setCallSuccessMsg(null);
setCallLoading(false);
setSaving(false);
}
};
@ -111,31 +108,54 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
setCallSuccessMsg(null);
};
const handleSavePhone = async () => {
if (!userConfig) return;
setSaving(true);
const handlePhoneCallClick = async () => {
// Check telephony configuration before opening dialog
try {
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
setPhoneChanged(false);
const accessToken = await getAccessToken();
const configResponse = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
headers: { 'Authorization': `Bearer ${accessToken}` },
});
// If no configuration exists, show configure dialog
if (configResponse.error || !configResponse.data?.twilio) {
setConfigureDialogOpen(true);
return;
}
// Configuration exists, open the phone call dialog
setDialogOpen(true);
} catch (err: unknown) {
setCallError(err instanceof Error ? err.message : "Failed to save phone number");
} finally {
setSaving(false);
console.error("Failed to check telephony config:", err);
// Still open dialog to show the error
setDialogOpen(true);
}
};
const handleConfigureContinue = () => {
setConfigureDialogOpen(false);
router.push(`/configure-telephony?returnTo=/workflow/${workflowId}`);
};
const handleStartCall = async () => {
setCallLoading(true);
setCallError(null);
setCallSuccessMsg(null);
try {
if (!user) return;
if (!user || !userConfig) return;
const accessToken = await getAccessToken();
// Save phone number if it has changed
if (phoneChanged) {
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
setPhoneChanged(false);
}
// Configuration exists, proceed with call initiation
const response = await initiateCallApiV1TwilioInitiateCallPost({
body: { workflow_id: workflowId },
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (response.error) {
let errMsg = "Failed to initiate call";
if (typeof response.error === "string") {
@ -211,17 +231,15 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
<Phone className="mr-2 h-4 w-4" />
Web Call
</Button>
{!isOSSDeployment && (
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Phone Call
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handlePhoneCallClick}
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Phone Call
</Button>
{isDirty ? (
<Button
@ -293,7 +311,7 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
<DialogHeader>
<DialogTitle>Phone Call</DialogTitle>
<DialogDescription>
Enter the phone number to call. This will be saved to your user config.
Enter the phone number to call. The number will be saved automatically.
</DialogDescription>
</DialogHeader>
<PhoneInput
@ -301,43 +319,65 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
{phoneChanged && (
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSavePhone}
disabled={saving}
onClick={() => {
setDialogOpen(false);
router.push(`/configure-telephony?returnTo=/workflow/${workflowId}`);
}}
>
{saving ? "Saving..." : "Save Number"}
Configure Telephony
</Button>
)}
<DialogFooter>
{!callSuccessMsg ? (
<Button
onClick={handleStartCall}
disabled={callLoading || phoneChanged || !phoneNumber || saving}
>
{callLoading ? "Calling..." : "Start Call"}
</Button>
) : (
<Button onClick={() => setDialogOpen(false)}>
Close
</Button>
)}
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<div className="flex gap-2 flex-1 justify-end">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
{!callSuccessMsg ? (
<Button
onClick={handleStartCall}
disabled={callLoading || !phoneNumber}
>
{callLoading ? "Calling..." : "Start Call"}
</Button>
) : (
<Button onClick={() => setDialogOpen(false)}>
Close
</Button>
)}
</div>
</DialogFooter>
{callError && <div className="text-red-500 text-sm mt-2">{callError}</div>}
{callSuccessMsg && <div className="text-green-600 text-sm mt-2">{callSuccessMsg}</div>}
</DialogContent>
</Dialog>
{/* Configure Telephony Dialog */}
<Dialog open={configureDialogOpen} onOpenChange={setConfigureDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Configure Telephony</DialogTitle>
<DialogDescription>
You need to configure your telephony settings before making phone calls.
You will be redirected to the telephony configuration page.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setConfigureDialogOpen(false)}>
Do it Later
</Button>
<Button onClick={handleConfigureContinue}>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Onboarding Tooltip */}
<OnboardingTooltip
title='Test your Voice Agent'
targetRef={webCallButtonRef}
message="Test this workflow now in your browser (no phone required)"
message="Test this workflow now in your browser using Web Call"
onDismiss={() => markTooltipSeen('web_call')}
showNext={false}
isVisible={!hasSeenTooltip('web_call') && !hasValidationErrors}

File diff suppressed because one or more lines are too long

View file

@ -358,6 +358,13 @@ export type SuperuserWorkflowRunsListResponse = {
total_pages: number;
};
/**
* Top-level telephony configuration response.
*/
export type TelephonyConfigurationResponse = {
twilio?: TwilioConfigurationResponse | null;
};
export type TestSessionResponse = {
id: number;
name: string;
@ -378,6 +385,35 @@ export type TestSessionResponse = {
completed_at: string | null;
};
/**
* Request schema for Twilio configuration.
*/
export type TwilioConfigurationRequest = {
provider?: string;
/**
* Twilio Account SID
*/
account_sid: string;
/**
* Twilio Auth Token
*/
auth_token: string;
/**
* List of Twilio phone numbers
*/
from_numbers: Array<string>;
};
/**
* Response schema for Twilio configuration with masked sensitive fields.
*/
export type TwilioConfigurationResponse = {
provider: string;
account_sid: string;
auth_token: string;
from_numbers: Array<string>;
};
export type UpdateIntegrationRequest = {
selected_files: Array<{
[key: string]: unknown;
@ -1854,6 +1890,68 @@ export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet
export type GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse = GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses[keyof GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponses];
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData = {
body?: never;
headers?: {
authorization?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/organizations/telephony-config';
};
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError = GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors[keyof GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetErrors];
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses = {
/**
* Successful Response
*/
200: TelephonyConfigurationResponse;
};
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse = GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses[keyof GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses];
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData = {
body: TwilioConfigurationRequest;
headers?: {
authorization?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/organizations/telephony-config';
};
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError = SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors[keyof SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostErrors];
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetSignedUrlApiV1S3SignedUrlGetData = {
body?: never;
headers?: {

View file

@ -1,6 +1,6 @@
"use client";
import { CircleDollarSign, Loader2, Star } from 'lucide-react';
import { CircleDollarSign, Star } from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import React from 'react';
@ -23,7 +23,7 @@ interface BaseHeaderProps {
}
export default function BaseHeader({ headerActions, backButton, showFeaturesNav = true }: BaseHeaderProps) {
const { loading, permissions } = useUserConfig();
const { permissions } = useUserConfig();
const { provider, user } = useAuth();
const pathname = usePathname();
const router = useRouter();
@ -109,14 +109,21 @@ export default function BaseHeader({ headerActions, backButton, showFeaturesNav
{/* Use key to force remount when user changes to avoid hooks issues */}
<div className="flex items-center gap-5" key={user ? 'logged-in' : 'logged-out'}>
{provider === 'stack' ? (
<React.Suspense fallback={<Loader2 className="w-5 h-5 animate-spin text-gray-600" />}>
{!loading && (
<React.Suspense fallback={
<div className="flex items-center gap-5">
{/* Match StackTeamSwitcher's internal skeleton */}
<div className="h-9 w-40 animate-pulse bg-gray-100 rounded" />
{/* Match StackUserButton dimensions: h-[34px] w-[34px] */}
<div className="h-[34px] w-[34px] animate-pulse bg-gray-100 rounded-full" />
</div>
}>
<div className="w-40 shrink-0">
<StackTeamSwitcher
onChange={() => {
router.refresh();
}}
/>
)}
</div>
<StackUserButton
extraItems={[{
text: 'Usage',

View file

@ -23,24 +23,27 @@ const defaultState: OnboardingState = {
const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined);
export const OnboardingProvider = ({ children }: { children: React.ReactNode }) => {
const [onboardingState, setOnboardingState] = useState<OnboardingState>(defaultState);
// Load state from localStorage on mount
useEffect(() => {
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
if (savedState) {
try {
const parsed = JSON.parse(savedState);
setOnboardingState({ ...defaultState, ...parsed });
} catch (error) {
console.error('Failed to parse onboarding state:', error);
const [onboardingState, setOnboardingState] = useState<OnboardingState>(() => {
// Initialize state from localStorage on first render
if (typeof window !== 'undefined') {
const savedState = localStorage.getItem(ONBOARDING_STORAGE_KEY);
if (savedState) {
try {
const parsed = JSON.parse(savedState);
return { ...defaultState, ...parsed };
} catch (error) {
console.error('Failed to parse onboarding state:', error);
}
}
}
}, []);
return defaultState;
});
// Save state to localStorage whenever it changes
useEffect(() => {
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
if (typeof window !== 'undefined') {
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(onboardingState));
}
}, [onboardingState]);
const hasSeenTooltip = (key: TooltipKey): boolean => {