mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
feat: add asterisk ARI websocket interface (#159)
* chore: remove old files * feat: ari outbound dialing * feat: add websocket configuration for ARI * feat: handling inbound calls * delete ext channel from redis on stasis end * fix: add lock in workflow run update, refactor _handle_stasis_start * chore: update submodule --------- Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
parent
ee4a874e54
commit
7552b6c819
37 changed files with 2076 additions and 4172 deletions
|
|
@ -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,12 @@ interface TelephonyConfigForm {
|
|||
// Cloudonix fields
|
||||
bearer_token?: string;
|
||||
domain_id?: string;
|
||||
// ARI fields
|
||||
ari_endpoint?: string;
|
||||
app_name?: string;
|
||||
app_password?: string;
|
||||
ws_client_name?: string;
|
||||
inbound_workflow_id?: number;
|
||||
// Common field - multiple phone numbers
|
||||
from_numbers: string[];
|
||||
}
|
||||
|
|
@ -140,6 +148,19 @@ 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("ws_client_name", ariConfig.ws_client_name);
|
||||
setValue(
|
||||
"inbound_workflow_id",
|
||||
typeof ariConfig.inbound_workflow_id === "number" ? ariConfig.inbound_workflow_id : undefined
|
||||
);
|
||||
setValue("from_numbers", ariConfig.from_numbers?.length > 0 ? ariConfig.from_numbers : [""]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -161,12 +182,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 +207,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 +246,24 @@ 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!,
|
||||
ws_client_name: data.ws_client_name || "",
|
||||
inbound_workflow_id: data.inbound_workflow_id || undefined,
|
||||
} as AriConfigurationRequest;
|
||||
}
|
||||
|
||||
const response = await saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost({
|
||||
|
|
@ -276,11 +312,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 +368,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 +470,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 +835,140 @@ 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 htmlFor="ws_client_name">WebSocket Client Name</Label>
|
||||
<Input
|
||||
id="ws_client_name"
|
||||
placeholder="dograh_staging"
|
||||
{...register("ws_client_name")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Connection name from Asterisk's websocket_client.conf for external media streaming
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inbound_workflow_id">Inbound Workflow ID (Optional)</Label>
|
||||
<Input
|
||||
id="inbound_workflow_id"
|
||||
type="number"
|
||||
placeholder="e.g. 42"
|
||||
{...register("inbound_workflow_id", { valueAsNumber: true })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Workflow to activate for inbound calls received via ARI
|
||||
</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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -219,9 +238,14 @@ export const PhoneCallDialog = ({
|
|||
{callLoading ? "Calling..." : "Start Call"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" onClick={() => { setCallSuccessMsg(null); setCallError(null); }}>
|
||||
Call Again
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,50 @@ 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;
|
||||
/**
|
||||
* websocket_client.conf connection name for externalMedia (e.g., dograh_staging)
|
||||
*/
|
||||
ws_client_name?: string;
|
||||
/**
|
||||
* Workflow ID for inbound calls
|
||||
*/
|
||||
inbound_workflow_id?: number | null;
|
||||
/**
|
||||
* 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;
|
||||
ws_client_name?: string;
|
||||
inbound_workflow_id?: number | null;
|
||||
from_numbers: Array<string>;
|
||||
};
|
||||
|
||||
export type AccessTokenResponse = {
|
||||
access_token: string | null;
|
||||
refresh_token: string | null;
|
||||
|
|
@ -795,6 +839,7 @@ export type TelephonyConfigurationResponse = {
|
|||
vonage?: VonageConfigurationResponse | null;
|
||||
vobiz?: VobizConfigurationResponse | null;
|
||||
cloudonix?: CloudonixConfigurationResponse | null;
|
||||
ari?: AriConfigurationResponse | null;
|
||||
};
|
||||
|
||||
export type TestSessionResponse = {
|
||||
|
|
@ -3461,7 +3506,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;
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ export const WORKFLOW_RUN_MODES = {
|
|||
VONAGE: 'vonage',
|
||||
VOBIZ: 'vobiz',
|
||||
CLOUDONIX: 'cloudonix',
|
||||
STASIS: 'stasis',
|
||||
WEBRTC: 'webrtc',
|
||||
SMALL_WEBRTC: 'smallwebrtc',
|
||||
ARI: 'ari'
|
||||
} as const;
|
||||
|
||||
export type WorkflowRunMode = typeof WORKFLOW_RUN_MODES[keyof typeof WORKFLOW_RUN_MODES];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue