feat: ari outbound dialing

This commit is contained in:
Abhishek Kumar 2026-02-15 14:07:29 +05:30
parent 1349654c75
commit e0f43ccf27
11 changed files with 1165 additions and 18 deletions

View file

@ -8,6 +8,8 @@ import { toast } from "sonner";
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost } from "@/client/sdk.gen";
import type {
AriConfigurationRequest,
AriConfigurationResponse,
CloudonixConfigurationRequest,
CloudonixConfigurationResponse,
TelephonyConfigurationResponse,
@ -51,6 +53,10 @@ interface TelephonyConfigForm {
// Cloudonix fields
bearer_token?: string;
domain_id?: string;
// ARI fields
ari_endpoint?: string;
app_name?: string;
app_password?: string;
// Common field - multiple phone numbers
from_numbers: string[];
}
@ -140,6 +146,14 @@ export default function ConfigureTelephonyPage() {
setValue("bearer_token", cloudonixConfig.bearer_token);
setValue("domain_id", cloudonixConfig.domain_id);
setValue("from_numbers", cloudonixConfig.from_numbers?.length > 0 ? cloudonixConfig.from_numbers : [""]);
} else if ((response.data as TelephonyConfigurationResponse)?.ari) {
const ariConfig = (response.data as TelephonyConfigurationResponse).ari as AriConfigurationResponse;
setHasExistingConfig(true);
setValue("provider", "ari");
setValue("ari_endpoint", ariConfig.ari_endpoint);
setValue("app_name", ariConfig.app_name);
setValue("app_password", ariConfig.app_password);
setValue("from_numbers", ariConfig.from_numbers?.length > 0 ? ariConfig.from_numbers : [""]);
}
}
} catch (error) {
@ -161,12 +175,13 @@ export default function ConfigureTelephonyPage() {
| TwilioConfigurationRequest
| VonageConfigurationRequest
| VobizConfigurationRequest
| CloudonixConfigurationRequest;
| CloudonixConfigurationRequest
| AriConfigurationRequest;
const filteredNumbers = data.from_numbers.filter(n => n.trim() !== "");
// Validate phone numbers are provided (except for Cloudonix where optional)
if (data.provider !== "cloudonix" && filteredNumbers.length === 0) {
// Validate phone numbers are provided (except for Cloudonix/ARI where optional)
if (data.provider !== "cloudonix" && data.provider !== "ari" && filteredNumbers.length === 0) {
toast.error("At least one phone number is required");
setIsLoading(false);
return;
@ -185,6 +200,10 @@ export default function ConfigureTelephonyPage() {
} else if (data.provider === "cloudonix") {
pattern = cloudonixPattern;
formatMessage = "(e.g., +1234567890)";
} else if (data.provider === "ari") {
// ARI uses SIP extensions - skip phone number validation
pattern = /^.+$/;
formatMessage = "(SIP extension or number)";
} else {
pattern = vonageVobizPattern;
formatMessage = "without + prefix (e.g., 14155551234)";
@ -220,14 +239,22 @@ export default function ConfigureTelephonyPage() {
auth_id: data.auth_id,
auth_token: data.vobiz_auth_token,
} as VobizConfigurationRequest;
} else {
// Cloudonix
} else if (data.provider === "cloudonix") {
requestBody = {
provider: data.provider,
from_numbers: filteredNumbers,
bearer_token: data.bearer_token!,
domain_id: data.domain_id!,
} as CloudonixConfigurationRequest;
} else {
// ARI
requestBody = {
provider: data.provider,
from_numbers: filteredNumbers,
ari_endpoint: data.ari_endpoint!,
app_name: data.app_name!,
app_password: data.app_password!,
} as AriConfigurationRequest;
}
const response = await saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost({
@ -276,11 +303,18 @@ export default function ConfigureTelephonyPage() {
? "Vonage"
: selectedProvider === "vobiz"
? "Vobiz"
: selectedProvider === "ari"
? "Asterisk ARI"
: "Cloudonix"}{" "}
Setup Guide
</CardTitle>
<CardDescription>
{selectedProvider === "cloudonix" ? (
{selectedProvider === "ari" ? (
<>
Connect Dograh to your Asterisk PBX using the Asterisk REST Interface (ARI).
ARI provides a WebSocket-based event model for controlling calls via Stasis applications.
</>
) : selectedProvider === "cloudonix" ? (
<>
Cloudonix is an AI Connectivity platform, enabling you to connect Dograh to any SIP product or SIP Telephony Provider.<br/><br/>
<iframe
@ -325,7 +359,27 @@ export default function ConfigureTelephonyPage() {
</CardDescription>
</CardHeader>
<CardContent>
{selectedProvider === "twilio" || selectedProvider === "vonage" ? (
{selectedProvider === "ari" ? (
<div className="space-y-4 text-sm">
<div>
<h4 className="font-semibold mb-2">Getting Started with Asterisk ARI:</h4>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
<li>Enable the ARI module in your Asterisk configuration (ari.conf)</li>
<li>Create an ARI user with a password in ari.conf</li>
<li>Create a Stasis application in your dialplan (extensions.conf)</li>
<li>Ensure the ARI HTTP endpoint is accessible from Dograh</li>
<li>Enter your ARI endpoint URL, app name, and password below</li>
</ol>
</div>
<div className="bg-muted border border-border rounded p-3">
<p className="text-sm">
<strong>Note:</strong> ARI uses WebSocket connections for real-time
event listening. The ARI manager process will automatically connect
to your Asterisk instance once configured.
</p>
</div>
</div>
) : selectedProvider === "twilio" || selectedProvider === "vonage" ? (
<div className="aspect-video">
<iframe
style={{ border: 0 }}
@ -407,6 +461,7 @@ export default function ConfigureTelephonyPage() {
<SelectItem value="vonage">Vonage</SelectItem>
<SelectItem value="vobiz">Vobiz</SelectItem>
<SelectItem value="cloudonix">Cloudonix</SelectItem>
<SelectItem value="ari">Asterisk (ARI)</SelectItem>
</SelectContent>
</Select>
{hasExistingConfig && (
@ -771,6 +826,115 @@ export default function ConfigureTelephonyPage() {
</>
)}
{/* ARI-specific fields */}
{selectedProvider === "ari" && (
<>
<div className="space-y-2">
<Label htmlFor="ari_endpoint">ARI Endpoint URL</Label>
<Input
id="ari_endpoint"
placeholder="http://asterisk.example.com:8088"
{...register("ari_endpoint", {
required:
selectedProvider === "ari"
? "ARI endpoint URL is required"
: false,
})}
/>
{errors.ari_endpoint && (
<p className="text-sm text-red-500">
{errors.ari_endpoint.message}
</p>
)}
<p className="text-xs text-muted-foreground">
The HTTP base URL for your Asterisk ARI (e.g., http://host:8088)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="app_name">Stasis App Name</Label>
<Input
id="app_name"
placeholder="dograh"
{...register("app_name", {
required:
selectedProvider === "ari"
? "Stasis app name is required"
: false,
})}
/>
{errors.app_name && (
<p className="text-sm text-red-500">
{errors.app_name.message}
</p>
)}
<p className="text-xs text-muted-foreground">
The ARI username and Stasis application name configured in ari.conf
</p>
</div>
<div className="space-y-2">
<Label htmlFor="app_password">App Password</Label>
<Input
id="app_password"
type="password"
autoComplete="current-password"
placeholder={
hasExistingConfig
? "Leave masked to keep existing"
: "Enter your ARI password"
}
{...register("app_password", {
required:
selectedProvider === "ari" && !hasExistingConfig
? "App password is required"
: false,
})}
/>
{errors.app_password && (
<p className="text-sm text-red-500">
{errors.app_password.message}
</p>
)}
</div>
<div className="space-y-2">
<Label>SIP Extensions / Numbers (Optional)</Label>
{fromNumbers.map((number, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="PJSIP/6001 or 6001"
value={number}
onChange={(e) => updatePhoneNumber(index, e.target.value)}
/>
{fromNumbers.length > 1 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Extension
</Button>
<p className="text-xs text-muted-foreground">
SIP extensions or trunk numbers for outbound calls
</p>
</div>
</>
)}
<div className="pt-4 space-y-3">
<Button
type="submit"

View file

@ -21,6 +21,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useUserConfig } from "@/context/UserConfigContext";
interface PhoneCallDialogProps {
@ -47,6 +48,7 @@ export const PhoneCallDialog = ({
const [phoneChanged, setPhoneChanged] = useState(false);
const [checkingConfig, setCheckingConfig] = useState(false);
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
// Check telephony configuration when dialog opens
useEffect(() => {
@ -60,7 +62,7 @@ export const PhoneCallDialog = ({
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz && !configResponse.data?.cloudonix)) {
if (configResponse.error || (!configResponse.data?.twilio && !configResponse.data?.vonage && !configResponse.data?.vobiz && !configResponse.data?.cloudonix && !configResponse.data?.ari)) {
setNeedsConfiguration(true);
} else {
setNeedsConfiguration(false);
@ -89,7 +91,9 @@ export const PhoneCallDialog = ({
// Keep phoneNumber in sync with userConfig when dialog opens
useEffect(() => {
if (open) {
setPhoneNumber(userConfig?.test_phone_number || "");
const saved = userConfig?.test_phone_number || "";
setPhoneNumber(saved);
setSipMode(/^(PJSIP|SIP)\//i.test(saved));
setPhoneChanged(false);
setCallError(null);
setCallSuccessMsg(null);
@ -189,14 +193,29 @@ export const PhoneCallDialog = ({
<DialogHeader>
<DialogTitle>Phone Call</DialogTitle>
<DialogDescription>
Enter the phone number to call. The number will be saved automatically.
Enter the phone number or SIP endpoint to call. The number will be saved automatically.
</DialogDescription>
</DialogHeader>
<PhoneInput
defaultCountry="in"
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
{sipMode ? (
<Input
value={phoneNumber}
onChange={(e) => handlePhoneInputChange(e.target.value)}
placeholder="PJSIP/1234 or SIP/1234"
/>
) : (
<PhoneInput
defaultCountry="in"
value={phoneNumber}
onChange={handlePhoneInputChange}
/>
)}
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground underline"
onClick={() => { setSipMode(!sipMode); setPhoneNumber(""); setPhoneChanged(true); }}
>
{sipMode ? "Use phone number instead" : "Use SIP endpoint instead"}
</button>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="outline"

View file

@ -19,6 +19,40 @@ export type ApiKeyStatusResponse = {
status: Array<ApiKeyStatus>;
};
/**
* Request schema for Asterisk ARI configuration.
*/
export type AriConfigurationRequest = {
provider?: string;
/**
* ARI base URL (e.g., http://asterisk.example.com:8088)
*/
ari_endpoint: string;
/**
* Stasis application name registered in Asterisk
*/
app_name: string;
/**
* ARI user password
*/
app_password: string;
/**
* List of SIP extensions/numbers for outbound calls (optional)
*/
from_numbers?: Array<string>;
};
/**
* Response schema for ARI configuration with masked sensitive fields.
*/
export type AriConfigurationResponse = {
provider: string;
ari_endpoint: string;
app_name: string;
app_password: string;
from_numbers: Array<string>;
};
export type AccessTokenResponse = {
access_token: string | null;
refresh_token: string | null;
@ -795,6 +829,7 @@ export type TelephonyConfigurationResponse = {
vonage?: VonageConfigurationResponse | null;
vobiz?: VobizConfigurationResponse | null;
cloudonix?: CloudonixConfigurationResponse | null;
ari?: AriConfigurationResponse | null;
};
export type TestSessionResponse = {
@ -3461,7 +3496,7 @@ export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetRespons
export type GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse = GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses[keyof GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponses];
export type SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData = {
body: TwilioConfigurationRequest | VonageConfigurationRequest | VobizConfigurationRequest | CloudonixConfigurationRequest;
body: TwilioConfigurationRequest | VonageConfigurationRequest | VobizConfigurationRequest | CloudonixConfigurationRequest | AriConfigurationRequest;
headers?: {
authorization?: string | null;
'X-API-Key'?: string | null;