mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-25 08:48:13 +02:00
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:
parent
d39a8111a6
commit
8e2e5c9327
21 changed files with 891 additions and 191 deletions
14
ui/src/app/configure-telephony/layout.tsx
Normal file
14
ui/src/app/configure-telephony/layout.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import BaseHeader from "@/components/header/BaseHeader"
|
||||
|
||||
export default function ConfigureTelephonyLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
270
ui/src/app/configure-telephony/page.tsx
Normal file
270
ui/src/app/configure-telephony/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue