mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-13 08:15:21 +02:00
feat: add 3CX telephony provider with Asterisk ARA provisioning
Registers a new `three_cx` provider that fronts a 3CX cloud PBX through an intermediate Asterisk bridge. Save-time hook writes the matching PJSIP endpoint/aor/auth/registration and dialplan rows to the Asterisk Realtime Architecture Postgres (via `ASTERISK_ARA_DSN`), so a config change in the Dograh UI is immediately picked up by Asterisk without a `pjsip reload`. Strip prefix is honoured at the dialplan layer. Inbound calls are matched back to a configuration by the dialled extension (`account_id_credential_field="extension"`), allowing one shared Asterisk to serve multiple Dograh orgs without collision. Touches `providers/__init__.py` and `schemas/telephony_config.py` only — per `providers/AGENTS.md`. Provider/transport/strategies are duplicated from `ari/` rather than imported, in line with the cross-provider-import prohibition. See `docs/providers/three_cx.md` for the Asterisk ARA setup runbook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3df5730076
commit
533a873ab7
13 changed files with 1916 additions and 0 deletions
|
|
@ -28,6 +28,10 @@ from api.services.telephony.providers.telnyx.config import (
|
|||
TelnyxConfigurationRequest,
|
||||
TelnyxConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony.providers.three_cx.config import (
|
||||
ThreeCxConfigurationRequest,
|
||||
ThreeCxConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony.providers.twilio.config import (
|
||||
TwilioConfigurationRequest,
|
||||
TwilioConfigurationResponse,
|
||||
|
|
@ -50,6 +54,7 @@ TelephonyConfigRequest = Annotated[
|
|||
CloudonixConfigurationRequest,
|
||||
PlivoConfigurationRequest,
|
||||
TelnyxConfigurationRequest,
|
||||
ThreeCxConfigurationRequest,
|
||||
TwilioConfigurationRequest,
|
||||
VobizConfigurationRequest,
|
||||
VonageConfigurationRequest,
|
||||
|
|
@ -73,6 +78,7 @@ class TelephonyConfigurationResponse(BaseModel):
|
|||
cloudonix: Optional[CloudonixConfigurationResponse] = None
|
||||
ari: Optional[ARIConfigurationResponse] = None
|
||||
telnyx: Optional[TelnyxConfigurationResponse] = None
|
||||
three_cx: Optional[ThreeCxConfigurationResponse] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -142,6 +148,8 @@ __all__ = [
|
|||
"TelephonyConfigurationResponse",
|
||||
"TelnyxConfigurationRequest",
|
||||
"TelnyxConfigurationResponse",
|
||||
"ThreeCxConfigurationRequest",
|
||||
"ThreeCxConfigurationResponse",
|
||||
"TwilioConfigurationRequest",
|
||||
"TwilioConfigurationResponse",
|
||||
"VobizConfigurationRequest",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from api.services.telephony.providers import ( # noqa: F401 -- import for side
|
|||
cloudonix,
|
||||
plivo,
|
||||
telnyx,
|
||||
three_cx,
|
||||
twilio,
|
||||
vobiz,
|
||||
vonage,
|
||||
|
|
|
|||
129
api/services/telephony/providers/three_cx/__init__.py
Normal file
129
api/services/telephony/providers/three_cx/__init__.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""3CX (PJSIP trunk via Asterisk bridge) telephony provider package."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.services.telephony.registry import (
|
||||
ProviderSpec,
|
||||
ProviderUIField,
|
||||
ProviderUIMetadata,
|
||||
register,
|
||||
)
|
||||
|
||||
from .config import ThreeCxConfigurationRequest, ThreeCxConfigurationResponse
|
||||
from .provider import ThreeCxProvider
|
||||
from .provisioning import _provision_3cx_trunk, endpoint_id_for
|
||||
from .transport import create_transport
|
||||
|
||||
|
||||
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Reshape stored JSONB credentials into the provider constructor dict."""
|
||||
return {
|
||||
"provider": "three_cx",
|
||||
"ari_endpoint": value.get("ari_endpoint"),
|
||||
"app_name": value.get("app_name"),
|
||||
"app_password": value.get("app_password"),
|
||||
"ws_client_name": value.get("ws_client_name", ""),
|
||||
"sip_domain": value.get("sip_domain"),
|
||||
"extension": value.get("extension"),
|
||||
"strip_prefix": value.get("strip_prefix", ""),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
||||
_UI_METADATA = ProviderUIMetadata(
|
||||
display_name="3CX (Asterisk bridge)",
|
||||
docs_url="https://docs.dograh.com/integrations/telephony/three-cx",
|
||||
fields=[
|
||||
ProviderUIField(
|
||||
name="ari_endpoint",
|
||||
label="ARI Endpoint",
|
||||
type="text",
|
||||
description="ARI base URL of the bridging Asterisk (http://host:8088)",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="app_name",
|
||||
label="Stasis App Name",
|
||||
type="text",
|
||||
description="Stasis application name registered in Asterisk",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="app_password",
|
||||
label="ARI Password",
|
||||
type="password",
|
||||
sensitive=True,
|
||||
),
|
||||
ProviderUIField(
|
||||
name="ws_client_name",
|
||||
label="websocket_client.conf Name",
|
||||
type="text",
|
||||
description="websocket_client.conf connection name for externalMedia",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="sip_domain",
|
||||
label="3CX SIP Domain",
|
||||
type="text",
|
||||
description="Your 3CX cloud host (e.g. 1156.3cx.cloud)",
|
||||
placeholder="1156.3cx.cloud",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="extension",
|
||||
label="3CX Extension",
|
||||
type="text",
|
||||
description="Extension number registered for Dograh (e.g. 12611)",
|
||||
placeholder="12611",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="sip_password",
|
||||
label="SIP Password",
|
||||
type="password",
|
||||
sensitive=True,
|
||||
description="SIP auth password for the extension on 3CX",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="strip_prefix",
|
||||
label="Strip Prefix (regex)",
|
||||
type="text",
|
||||
required=False,
|
||||
description=(
|
||||
"Optional regex stripped from outbound numbers before dialling. "
|
||||
"Only the literal '^\\+<digits>' form is supported "
|
||||
"(Italian deployments use '^\\+39')."
|
||||
),
|
||||
placeholder="^\\+39",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="From Numbers",
|
||||
type="string-array",
|
||||
required=False,
|
||||
description="E.164 caller-IDs permitted on outbound calls",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
SPEC = ProviderSpec(
|
||||
name="three_cx",
|
||||
provider_cls=ThreeCxProvider,
|
||||
config_loader=_config_loader,
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=8000,
|
||||
config_request_cls=ThreeCxConfigurationRequest,
|
||||
config_response_cls=ThreeCxConfigurationResponse,
|
||||
ui_metadata=_UI_METADATA,
|
||||
account_id_credential_field="extension",
|
||||
preprocess_credentials_on_save=_provision_3cx_trunk,
|
||||
)
|
||||
|
||||
|
||||
register(SPEC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC",
|
||||
"ThreeCxConfigurationRequest",
|
||||
"ThreeCxConfigurationResponse",
|
||||
"ThreeCxProvider",
|
||||
"create_transport",
|
||||
"endpoint_id_for",
|
||||
]
|
||||
69
api/services/telephony/providers/three_cx/ara_db.py
Normal file
69
api/services/telephony/providers/three_cx/ara_db.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Async connection pool to the Asterisk Realtime Architecture Postgres.
|
||||
|
||||
Lives separate from Dograh's primary SQLAlchemy engine because the ARA
|
||||
Postgres is operationally distinct (Asterisk-owned schema, typically a
|
||||
different host, different credentials). DSN comes from the
|
||||
``ASTERISK_ARA_DSN`` environment variable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
import asyncpg
|
||||
from loguru import logger
|
||||
|
||||
_POOL: Optional[asyncpg.Pool] = None
|
||||
_DSN_ENV = "ASTERISK_ARA_DSN"
|
||||
|
||||
|
||||
class AraNotConfiguredError(RuntimeError):
|
||||
"""Raised when ASTERISK_ARA_DSN is missing.
|
||||
|
||||
The 3CX provider can't provision its trunk without an ARA Postgres to
|
||||
write to — callers translate this into a user-visible HTTP 400 with a
|
||||
pointer to docs/providers/three_cx.md.
|
||||
"""
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
"""Return the lazily-initialised ARA pool. Idempotent across awaits."""
|
||||
global _POOL
|
||||
if _POOL is not None:
|
||||
return _POOL
|
||||
|
||||
dsn = os.getenv(_DSN_ENV)
|
||||
if not dsn:
|
||||
raise AraNotConfiguredError(
|
||||
f"{_DSN_ENV} not set — 3CX provider needs an Asterisk Realtime "
|
||||
f"Postgres DSN to provision the PJSIP trunk. See "
|
||||
f"docs/providers/three_cx.md for setup."
|
||||
)
|
||||
|
||||
logger.info(f"[3CX/ARA] opening asyncpg pool to {_dsn_for_log(dsn)}")
|
||||
_POOL = await asyncpg.create_pool(
|
||||
dsn=dsn,
|
||||
min_size=1,
|
||||
max_size=4,
|
||||
command_timeout=10,
|
||||
)
|
||||
return _POOL
|
||||
|
||||
|
||||
async def close_pool() -> None:
|
||||
"""Close the pool — exposed for test teardown and graceful shutdown."""
|
||||
global _POOL
|
||||
if _POOL is not None:
|
||||
await _POOL.close()
|
||||
_POOL = None
|
||||
|
||||
|
||||
def _dsn_for_log(dsn: str) -> str:
|
||||
"""Strip the password from a DSN before logging it."""
|
||||
if "@" not in dsn or "://" not in dsn:
|
||||
return "<dsn>"
|
||||
scheme, rest = dsn.split("://", 1)
|
||||
creds, host = rest.split("@", 1)
|
||||
user = creds.split(":", 1)[0] if ":" in creds else creds
|
||||
return f"{scheme}://{user}:***@{host}"
|
||||
83
api/services/telephony/providers/three_cx/config.py
Normal file
83
api/services/telephony/providers/three_cx/config.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"""3CX (via Asterisk PJSIP trunk) telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class ThreeCxConfigurationRequest(BaseModel):
|
||||
"""Request schema for a 3CX trunk fronted by an Asterisk ARA instance.
|
||||
|
||||
The provider owns two distinct credential groups:
|
||||
|
||||
* **Asterisk side** (``ari_endpoint``, ``app_name``, ``app_password``,
|
||||
``ws_client_name``) — how Dograh's REST + externalMedia loop talks to
|
||||
the bridging Asterisk box at call time. Identical in role to the ARI
|
||||
provider.
|
||||
* **3CX side** (``sip_domain``, ``extension``, ``sip_password``,
|
||||
``strip_prefix``) — the upstream PBX peer credentials. Dograh never
|
||||
speaks SIP itself; these are consumed at save time by
|
||||
``preprocess_credentials_on_save`` to provision the matching PJSIP
|
||||
endpoint/aor/auth/registration rows on the Asterisk ARA Postgres.
|
||||
"""
|
||||
|
||||
provider: Literal["three_cx"] = Field(default="three_cx")
|
||||
|
||||
ari_endpoint: str = Field(
|
||||
..., description="ARI base URL of the bridging Asterisk (e.g., http://asterisk:8088)"
|
||||
)
|
||||
app_name: str = Field(
|
||||
..., description="Stasis application name registered in Asterisk"
|
||||
)
|
||||
app_password: str = Field(..., description="ARI user password")
|
||||
ws_client_name: str = Field(
|
||||
default="",
|
||||
description="websocket_client.conf connection name for externalMedia",
|
||||
)
|
||||
|
||||
sip_domain: str = Field(
|
||||
..., description="3CX SIP host/domain (e.g., 1156.3cx.cloud)"
|
||||
)
|
||||
extension: str = Field(..., description="3CX extension number (e.g., 12611)")
|
||||
sip_password: str = Field(..., description="SIP auth password for the extension")
|
||||
strip_prefix: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Optional regex stripped from outbound destinations before the call "
|
||||
"hits the trunk. Italian deployments typically use '^\\+39'."
|
||||
),
|
||||
)
|
||||
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="E.164 numbers permitted as caller-id for outbound calls",
|
||||
)
|
||||
|
||||
@field_validator("sip_domain")
|
||||
@classmethod
|
||||
def _strip_sip_domain(cls, v: str) -> str:
|
||||
return (v or "").strip().lower()
|
||||
|
||||
@field_validator("extension")
|
||||
@classmethod
|
||||
def _strip_extension(cls, v: str) -> str:
|
||||
return (v or "").strip()
|
||||
|
||||
|
||||
class ThreeCxConfigurationResponse(BaseModel):
|
||||
"""Response schema for a 3CX configuration.
|
||||
|
||||
``app_password`` and ``sip_password`` are masked by the org route layer
|
||||
before serialization — see ``ProviderUIField.sensitive`` in __init__.py.
|
||||
"""
|
||||
|
||||
provider: Literal["three_cx"] = Field(default="three_cx")
|
||||
ari_endpoint: str
|
||||
app_name: str
|
||||
app_password: str # Masked
|
||||
ws_client_name: str = ""
|
||||
sip_domain: str
|
||||
extension: str
|
||||
sip_password: str # Masked
|
||||
strip_prefix: str = ""
|
||||
from_numbers: List[str]
|
||||
92
api/services/telephony/providers/three_cx/dialplan.py
Normal file
92
api/services/telephony/providers/three_cx/dialplan.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Builds Asterisk dialplan rows for a 3CX trunk.
|
||||
|
||||
Two contexts are generated per trunk:
|
||||
|
||||
* ``<endpoint_id>-outbound`` — dialed by the Stasis app when Dograh
|
||||
originates a call. Honours ``strip_prefix`` by translating the regex
|
||||
to an Asterisk pattern-match exten and using ``${EXTEN:N}`` to skip
|
||||
the matched prefix on the way out.
|
||||
* ``<endpoint_id>-inbound`` — the ``context=`` on the PJSIP endpoint.
|
||||
Routes any incoming call from the trunk straight into the Stasis
|
||||
app so Dograh's ari_manager picks it up.
|
||||
|
||||
We deliberately keep the dialplan minimal — anything fancier (IVR,
|
||||
office-hours routing) belongs in a hand-written context the admin can
|
||||
include before/after this generated one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
# Asterisk understands its own ad-hoc pattern syntax — not POSIX/PCRE
|
||||
# regex. We translate the small subset Italian deployments need
|
||||
# (``^\+39``) and fall back to a verbatim match when the prefix is empty.
|
||||
_SUPPORTED_PREFIX_RE = re.compile(r"^\^\\?\+(\d+)$")
|
||||
|
||||
|
||||
def _prefix_to_pattern(strip_prefix: str) -> Tuple[str, int]:
|
||||
"""Translate a small regex into (Asterisk extension pattern, chars-to-skip).
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> _prefix_to_pattern("^\\+39")
|
||||
('_+39N.', 3)
|
||||
>>> _prefix_to_pattern("")
|
||||
('_X.', 0)
|
||||
"""
|
||||
if not strip_prefix:
|
||||
return ("_X.", 0)
|
||||
m = _SUPPORTED_PREFIX_RE.match(strip_prefix)
|
||||
if not m:
|
||||
raise ValueError(
|
||||
f"Unsupported strip_prefix regex {strip_prefix!r}. "
|
||||
f"Only literal '^\\+<digits>' is supported."
|
||||
)
|
||||
digits = m.group(1)
|
||||
return (f"_+{digits}N.", len(digits) + 1) # +1 for the literal '+'
|
||||
|
||||
|
||||
def build_dialplan_rows(
|
||||
*,
|
||||
endpoint_id: str,
|
||||
extension: str,
|
||||
stasis_app: str,
|
||||
strip_prefix: str,
|
||||
) -> List[dict]:
|
||||
"""Return ARA ``extensions`` rows for this trunk's inbound + outbound contexts."""
|
||||
pattern, skip = _prefix_to_pattern(strip_prefix)
|
||||
dest = f"${{EXTEN:{skip}}}" if skip else "${EXTEN}"
|
||||
|
||||
outbound_context = f"{endpoint_id}-outbound"
|
||||
inbound_context = f"{endpoint_id}-inbound"
|
||||
|
||||
return [
|
||||
{
|
||||
"context": outbound_context,
|
||||
"exten": pattern,
|
||||
"priority": 1,
|
||||
"app": "Dial",
|
||||
"appdata": f"PJSIP/{dest}@{endpoint_id},60",
|
||||
},
|
||||
{
|
||||
"context": inbound_context,
|
||||
"exten": extension,
|
||||
"priority": 1,
|
||||
"app": "Stasis",
|
||||
"appdata": f"{stasis_app},inbound,{endpoint_id}",
|
||||
},
|
||||
{
|
||||
"context": inbound_context,
|
||||
"exten": "_X.",
|
||||
"priority": 1,
|
||||
"app": "Stasis",
|
||||
"appdata": f"{stasis_app},inbound,{endpoint_id}",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def outbound_context_for(endpoint_id: str) -> str:
|
||||
"""The dialplan context name the Stasis app should Originate into."""
|
||||
return f"{endpoint_id}-outbound"
|
||||
439
api/services/telephony/providers/three_cx/provider.py
Normal file
439
api/services/telephony/providers/three_cx/provider.py
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
"""3CX telephony provider — Asterisk PJSIP trunk to a 3CX cloud PBX.
|
||||
|
||||
Functionally a specialisation of ARI: the runtime call control flow is
|
||||
identical (REST originate + Stasis externalMedia), but the provider
|
||||
carries the 3CX trunk credentials and matches inbound calls back to a
|
||||
configuration by ``extension``.
|
||||
|
||||
We duplicate the ARI provider body rather than subclassing it because
|
||||
``providers/AGENTS.md`` forbids cross-provider imports. A future
|
||||
``services/telephony/asterisk_base.py`` extraction should consolidate the
|
||||
shared logic.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import WebSocket
|
||||
|
||||
|
||||
class ThreeCxProvider(TelephonyProvider):
|
||||
"""3CX-over-Asterisk implementation of TelephonyProvider."""
|
||||
|
||||
PROVIDER_NAME = "three_cx"
|
||||
WEBHOOK_ENDPOINT = None # 3CX uses WebSocket events via Asterisk, not webhooks
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialise from the normalised config dict produced by _config_loader."""
|
||||
self.ari_endpoint = (config.get("ari_endpoint") or "").rstrip("/")
|
||||
self.app_name = config.get("app_name", "")
|
||||
self.app_password = config.get("app_password", "")
|
||||
self.from_numbers = config.get("from_numbers", [])
|
||||
|
||||
# 3CX trunk identity — carried for inbound matching and for the
|
||||
# provisioning hook to address the right ARA rows. Not used at
|
||||
# runtime by REST call control (Asterisk owns the SIP leg).
|
||||
self.sip_domain = (config.get("sip_domain") or "").strip().lower()
|
||||
self.extension = (config.get("extension") or "").strip()
|
||||
self.strip_prefix = config.get("strip_prefix", "")
|
||||
|
||||
if isinstance(self.from_numbers, str):
|
||||
self.from_numbers = [self.from_numbers]
|
||||
|
||||
self.base_url = f"{self.ari_endpoint}/ari"
|
||||
|
||||
def _get_auth(self) -> aiohttp.BasicAuth:
|
||||
return aiohttp.BasicAuth(self.app_name, self.app_password)
|
||||
|
||||
async def initiate_call(
|
||||
self,
|
||||
to_number: str,
|
||||
webhook_url: str,
|
||||
workflow_run_id: Optional[int] = None,
|
||||
from_number: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> CallInitiationResult:
|
||||
"""Originate an outbound call via the bridging Asterisk.
|
||||
|
||||
The dialled number is routed through the generated outbound dialplan
|
||||
context (``<endpoint_id>-outbound``) so the ``strip_prefix`` regex
|
||||
the admin saved is honoured at the dialplan layer, not in Python.
|
||||
"""
|
||||
if not self.validate_config():
|
||||
raise ValueError("3CX provider not properly configured")
|
||||
|
||||
endpoint = f"{self.base_url}/channels"
|
||||
|
||||
# Local-channel into the generated outbound context, which contains
|
||||
# the strip_prefix-aware Dial(PJSIP/...@<endpoint_id>) row.
|
||||
endpoint_id = self._endpoint_id()
|
||||
sip_endpoint = f"Local/{to_number}@{endpoint_id}-outbound"
|
||||
|
||||
params = {
|
||||
"endpoint": sip_endpoint,
|
||||
"app": self.app_name,
|
||||
"appArgs": ",".join(
|
||||
filter(
|
||||
None,
|
||||
[
|
||||
f"workflow_run_id={workflow_run_id}",
|
||||
f"workflow_id={kwargs.get('workflow_id', '')}",
|
||||
f"user_id={kwargs.get('user_id', '')}",
|
||||
],
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
if from_number:
|
||||
params["callerId"] = from_number
|
||||
|
||||
logger.info(
|
||||
f"[3CX] Initiating call to {to_number} via {sip_endpoint} "
|
||||
f"(workflow_run_id={workflow_run_id})"
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
endpoint,
|
||||
params=params,
|
||||
auth=self._get_auth(),
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
|
||||
if response.status != 200:
|
||||
logger.error(
|
||||
f"[3CX] Channel creation failed: "
|
||||
f"HTTP {response.status} - {response_text}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=response.status,
|
||||
detail=f"Failed to create 3CX channel: {response_text}",
|
||||
)
|
||||
|
||||
response_data = json.loads(response_text)
|
||||
channel_id = response_data.get("id", "")
|
||||
|
||||
return CallInitiationResult(
|
||||
call_id=channel_id,
|
||||
status=response_data.get("state", "created"),
|
||||
caller_number=from_number,
|
||||
provider_metadata={
|
||||
"call_id": channel_id,
|
||||
"channel_name": response_data.get("name", ""),
|
||||
},
|
||||
raw_response=response_data,
|
||||
)
|
||||
|
||||
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
|
||||
if not self.validate_config():
|
||||
raise ValueError("3CX provider not properly configured")
|
||||
url = f"{self.base_url}/channels/{call_id}"
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, auth=self._get_auth()) as response:
|
||||
if response.status != 200:
|
||||
raise Exception(
|
||||
f"Failed to get channel status: {await response.text()}"
|
||||
)
|
||||
return await response.json()
|
||||
|
||||
async def get_available_phone_numbers(self) -> List[str]:
|
||||
return self.from_numbers
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""Asterisk-side credentials are the only ones required at runtime.
|
||||
|
||||
3CX-side credentials (``sip_password`` etc.) are consumed at save time
|
||||
by the provisioning hook; they're not needed for REST call control.
|
||||
"""
|
||||
return bool(self.ari_endpoint and self.app_name and self.app_password)
|
||||
|
||||
async def verify_webhook_signature(
|
||||
self, url: str, params: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
async def get_webhook_response(
|
||||
self, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
) -> str:
|
||||
logger.warning(
|
||||
"get_webhook_response called for 3CX — not applicable, "
|
||||
"control plane is Asterisk REST."
|
||||
)
|
||||
return ""
|
||||
|
||||
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"cost_usd": 0.0,
|
||||
"duration": 0,
|
||||
"status": "unknown",
|
||||
"error": "3CX does not surface call cost to Dograh",
|
||||
}
|
||||
|
||||
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state_map = {
|
||||
"Up": "answered",
|
||||
"Down": "completed",
|
||||
"Ringing": "ringing",
|
||||
"Ring": "ringing",
|
||||
"Busy": "busy",
|
||||
"Unavailable": "failed",
|
||||
}
|
||||
channel_state = data.get("channel", {}).get("state", "")
|
||||
event_type = data.get("type", "")
|
||||
|
||||
if event_type == "StasisStart":
|
||||
status = "answered"
|
||||
elif event_type in ("StasisEnd", "ChannelDestroyed"):
|
||||
status = "completed"
|
||||
else:
|
||||
status = state_map.get(channel_state, channel_state.lower())
|
||||
|
||||
channel = data.get("channel", {})
|
||||
return {
|
||||
"call_id": channel.get("id", ""),
|
||||
"status": status,
|
||||
"from_number": channel.get("caller", {}).get("number"),
|
||||
"to_number": channel.get("dialplan", {}).get("exten"),
|
||||
"direction": None,
|
||||
"duration": None,
|
||||
"extra": data,
|
||||
}
|
||||
|
||||
async def handle_websocket(
|
||||
self,
|
||||
websocket: "WebSocket",
|
||||
workflow_id: int,
|
||||
user_id: int,
|
||||
workflow_run_id: int,
|
||||
) -> None:
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
|
||||
channel_id = ""
|
||||
if workflow_run and workflow_run.gathered_context:
|
||||
channel_id = workflow_run.gathered_context.get("call_id", "")
|
||||
|
||||
logger.info(
|
||||
f"[3CX] Starting pipeline for workflow_run {workflow_run_id}, "
|
||||
f"channel={channel_id}"
|
||||
)
|
||||
|
||||
await run_pipeline_telephony(
|
||||
websocket,
|
||||
provider_name=self.PROVIDER_NAME,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=channel_id,
|
||||
transport_kwargs={"channel_id": channel_id},
|
||||
)
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""3CX uses no HTTP webhook layer — inbound arrives via Stasis events."""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""Parse a Stasis event into normalised inbound data.
|
||||
|
||||
``account_id`` is populated with the dialled extension so the
|
||||
inbound dispatcher can match it against
|
||||
``credentials['extension']`` and pick the right 3CX configuration
|
||||
when multiple coexist in one org.
|
||||
"""
|
||||
channel = webhook_data.get("channel", {})
|
||||
caller = channel.get("caller", {})
|
||||
exten = channel.get("dialplan", {}).get("exten", "")
|
||||
|
||||
return NormalizedInboundData(
|
||||
provider=ThreeCxProvider.PROVIDER_NAME,
|
||||
call_id=channel.get("id", ""),
|
||||
from_number=caller.get("number", ""),
|
||||
to_number=exten,
|
||||
direction="inbound",
|
||||
call_status=channel.get("state", ""),
|
||||
account_id=exten or None,
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""Match the dialled extension against the saved trunk's extension."""
|
||||
stored = (config_data or {}).get("extension")
|
||||
if not stored or not webhook_account_id:
|
||||
return False
|
||||
return stored == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""3CX authenticates via the Asterisk WebSocket creds; no payload signature."""
|
||||
return True
|
||||
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data,
|
||||
backend_endpoint: str,
|
||||
):
|
||||
from fastapi import Response
|
||||
|
||||
return Response(content="", status_code=204)
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
from fastapi import Response
|
||||
|
||||
return Response(
|
||||
content=json.dumps({"error": error_type, "message": message}),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
# ======== CALL TRANSFER METHODS ========
|
||||
|
||||
def supports_transfers(self) -> bool:
|
||||
return True
|
||||
|
||||
async def transfer_call(
|
||||
self,
|
||||
destination: str,
|
||||
transfer_id: str,
|
||||
conference_name: str,
|
||||
timeout: int = 30,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Transfer by originating a destination channel and bridge-swapping it in."""
|
||||
if not self.validate_config():
|
||||
raise ValueError("3CX provider not properly configured")
|
||||
|
||||
from api.services.telephony.call_transfer_manager import (
|
||||
get_call_transfer_manager,
|
||||
)
|
||||
|
||||
call_transfer_manager = await get_call_transfer_manager()
|
||||
|
||||
endpoint_id = self._endpoint_id()
|
||||
sip_endpoint = f"Local/{destination}@{endpoint_id}-outbound"
|
||||
|
||||
app_args = f"transfer,{transfer_id}"
|
||||
|
||||
try:
|
||||
endpoint = f"{self.base_url}/channels"
|
||||
params = {
|
||||
"endpoint": sip_endpoint,
|
||||
"app": self.app_name,
|
||||
"appArgs": app_args,
|
||||
"timeout": timeout,
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
endpoint, params=params, auth=self._get_auth()
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
if response.status != 200:
|
||||
await call_transfer_manager.remove_transfer_context(
|
||||
transfer_id
|
||||
)
|
||||
raise Exception(
|
||||
f"3CX channel creation failed: "
|
||||
f"{response.status} {response_text}"
|
||||
)
|
||||
result = json.loads(response_text)
|
||||
|
||||
destination_channel_id = result.get("id", "")
|
||||
if not destination_channel_id:
|
||||
await call_transfer_manager.remove_transfer_context(transfer_id)
|
||||
raise Exception("Failed to create destination channel")
|
||||
|
||||
await call_transfer_manager.store_transfer_channel_mapping(
|
||||
destination_channel_id, transfer_id
|
||||
)
|
||||
|
||||
return {
|
||||
"call_sid": destination_channel_id,
|
||||
"status": "initiated",
|
||||
"provider": self.PROVIDER_NAME,
|
||||
"raw_response": result,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[3CX Transfer] Failed: {e}")
|
||||
await call_transfer_manager.remove_transfer_context(transfer_id)
|
||||
raise
|
||||
|
||||
# ======== 3CX-SPECIFIC HELPERS ========
|
||||
|
||||
def _endpoint_id(self) -> str:
|
||||
"""Globally unique Asterisk endpoint name for this trunk.
|
||||
|
||||
Matches the naming used by the provisioning hook so dialplan + REST
|
||||
agree on which PJSIP endpoint to address. See provisioning.py.
|
||||
"""
|
||||
from .provisioning import endpoint_id_for
|
||||
|
||||
return endpoint_id_for(self.sip_domain, self.extension)
|
||||
|
||||
async def hangup_channel(self, channel_id: str, reason: str = "normal") -> bool:
|
||||
endpoint = f"{self.base_url}/channels/{channel_id}"
|
||||
params = {"reason_code": reason}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.delete(
|
||||
endpoint, params=params, auth=self._get_auth()
|
||||
) as response:
|
||||
if response.status in (200, 204):
|
||||
return True
|
||||
logger.error(
|
||||
f"[3CX] Failed to hangup channel {channel_id}: "
|
||||
f"{await response.text()}"
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[3CX] Exception hanging up channel {channel_id}: {e}")
|
||||
return False
|
||||
|
||||
async def answer_channel(self, channel_id: str) -> bool:
|
||||
endpoint = f"{self.base_url}/channels/{channel_id}/answer"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(endpoint, auth=self._get_auth()) as response:
|
||||
return response.status in (200, 204)
|
||||
except Exception as e:
|
||||
logger.error(f"[3CX] Exception answering channel {channel_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_ws_url(self) -> str:
|
||||
"""ARI WebSocket URL for the standalone event listener (ari_manager)."""
|
||||
parsed = urlparse(self.ari_endpoint)
|
||||
ws_scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
return (
|
||||
f"{ws_scheme}://{parsed.netloc}/ari/events"
|
||||
f"?api_key={self.app_name}:{self.app_password}"
|
||||
f"&app={self.app_name}"
|
||||
f"&subscribeAll=true"
|
||||
)
|
||||
262
api/services/telephony/providers/three_cx/provisioning.py
Normal file
262
api/services/telephony/providers/three_cx/provisioning.py
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"""Provision a 3CX PJSIP trunk on the bridging Asterisk via ARA Postgres.
|
||||
|
||||
Called by ``ProviderSpec.preprocess_credentials_on_save`` whenever a
|
||||
TelephonyConfiguration of type ``three_cx`` is created or updated. Writes
|
||||
the standard six-table PJSIP realtime set:
|
||||
|
||||
* ``ps_auths`` — userpass auth for outbound REGISTER + inbound 401 challenge
|
||||
* ``ps_aors`` — single contact, qualify keepalive
|
||||
* ``ps_endpoints`` — codec list, dialplan context, auth/aor references
|
||||
* ``ps_registrations`` — outbound REGISTER toward the 3CX cloud SBC
|
||||
* ``extensions`` (x N) — dialplan rows produced by ``dialplan.build_dialplan_rows``
|
||||
|
||||
Idempotent on re-save: every row keyed by the deterministic
|
||||
``endpoint_id_for(sip_domain, extension)`` is deleted first and then
|
||||
re-inserted in the same transaction. The preprocessor is allowed to do
|
||||
I/O (registry.py docstring) but must remain re-entrant from the route
|
||||
layer's point of view.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from .ara_db import AraNotConfiguredError, get_pool
|
||||
from .dialplan import build_dialplan_rows
|
||||
|
||||
# Stasis app name as configured in the bridging Asterisk's
|
||||
# websocket_client.conf. Mirrors the ``app_name`` field on the
|
||||
# configuration — see runbook §1.
|
||||
_STASIS_APP_KEY = "app_name"
|
||||
|
||||
# Default codecs: G.711a + G.711μ cover 3CX defaults; "ulaw,alaw" is the
|
||||
# ordered allow list, "all" the disallow base.
|
||||
_DEFAULT_ALLOW = "ulaw,alaw"
|
||||
_DEFAULT_DISALLOW = "all"
|
||||
|
||||
# Asterisk-side transport name configured by the admin in pjsip.conf
|
||||
# (e.g. ``transport-udp``). The runbook tells the admin how to set this
|
||||
# up; the provider just references it by name. Override per-deployment
|
||||
# via env var on the calling process if necessary.
|
||||
_TRANSPORT_NAME_DEFAULT = "transport-udp"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def endpoint_id_for(sip_domain: str, extension: str) -> str:
|
||||
"""Deterministic, globally-unique Asterisk endpoint id for this trunk.
|
||||
|
||||
Form: ``dograh_<slug(sip_domain)>_<extension>``. Two TelephonyConfigurations
|
||||
can't legitimately collide because two Asterisks can't simultaneously
|
||||
register the same (domain, extension) pair upstream anyway.
|
||||
|
||||
>>> endpoint_id_for('1156.3cx.cloud', '12611')
|
||||
'dograh_1156_3cx_cloud_12611'
|
||||
"""
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", (sip_domain or "").lower()).strip("_")
|
||||
ext = re.sub(r"[^A-Za-z0-9]+", "", extension or "")
|
||||
if not slug or not ext:
|
||||
raise ValueError(
|
||||
f"Cannot derive endpoint_id from sip_domain={sip_domain!r} "
|
||||
f"extension={extension!r}"
|
||||
)
|
||||
return f"dograh_{slug}_{ext}"
|
||||
|
||||
|
||||
async def _provision_3cx_trunk(credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Preprocessor hook — writes the ARA rows for this trunk.
|
||||
|
||||
Returns the credentials dict unchanged (the provider re-derives
|
||||
``endpoint_id`` deterministically at runtime, so nothing extra needs
|
||||
to be persisted).
|
||||
|
||||
Raises ``HTTPException`` on validation failure or ARA write failure so
|
||||
the route layer aborts the DB save — matches the Cloudonix pattern.
|
||||
"""
|
||||
required = ("sip_domain", "extension", "sip_password", "app_name")
|
||||
missing = [k for k in required if not credentials.get(k)]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"3CX provision: missing required credential(s): {missing}",
|
||||
)
|
||||
|
||||
sip_domain = credentials["sip_domain"].strip().lower()
|
||||
extension = credentials["extension"].strip()
|
||||
sip_password = credentials["sip_password"]
|
||||
stasis_app = credentials[_STASIS_APP_KEY]
|
||||
strip_prefix = credentials.get("strip_prefix", "")
|
||||
|
||||
endpoint_id = endpoint_id_for(sip_domain, extension)
|
||||
transport_name = credentials.get("transport_name", _TRANSPORT_NAME_DEFAULT)
|
||||
|
||||
try:
|
||||
pool = await get_pool()
|
||||
except AraNotConfiguredError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
dialplan_rows = build_dialplan_rows(
|
||||
endpoint_id=endpoint_id,
|
||||
extension=extension,
|
||||
stasis_app=stasis_app,
|
||||
strip_prefix=strip_prefix,
|
||||
)
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await _delete_existing(conn, endpoint_id)
|
||||
await _insert_auth(conn, endpoint_id, extension, sip_password)
|
||||
await _insert_aor(conn, endpoint_id)
|
||||
await _insert_endpoint(
|
||||
conn, endpoint_id, transport_name, sip_domain
|
||||
)
|
||||
await _insert_registration(
|
||||
conn,
|
||||
endpoint_id=endpoint_id,
|
||||
transport_name=transport_name,
|
||||
sip_domain=sip_domain,
|
||||
extension=extension,
|
||||
)
|
||||
await _insert_extensions(conn, dialplan_rows)
|
||||
except Exception as e:
|
||||
logger.exception(f"[3CX/ARA] provisioning failed for {endpoint_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
f"3CX provisioning failed while writing to Asterisk ARA: {e}. "
|
||||
f"No TelephonyConfiguration was saved."
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[3CX/ARA] provisioned endpoint={endpoint_id} "
|
||||
f"(dialplan rows: {len(dialplan_rows)})"
|
||||
)
|
||||
return credentials
|
||||
|
||||
|
||||
async def _deprovision_3cx_trunk(credentials: Dict[str, Any]) -> None:
|
||||
"""Remove all ARA rows for a given trunk.
|
||||
|
||||
Not wired into a hook today — the registry only exposes the
|
||||
save-time hook. Exposed as a callable so a future
|
||||
``post_delete`` extension or admin tooling can use it.
|
||||
"""
|
||||
sip_domain = (credentials.get("sip_domain") or "").strip().lower()
|
||||
extension = (credentials.get("extension") or "").strip()
|
||||
if not sip_domain or not extension:
|
||||
return
|
||||
endpoint_id = endpoint_id_for(sip_domain, extension)
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await _delete_existing(conn, endpoint_id)
|
||||
logger.info(f"[3CX/ARA] deprovisioned endpoint={endpoint_id}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-table writers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _delete_existing(conn, endpoint_id: str) -> None:
|
||||
"""Strip every ARA row owned by this endpoint_id. Idempotent."""
|
||||
await conn.execute("DELETE FROM ps_registrations WHERE id = $1", endpoint_id)
|
||||
await conn.execute("DELETE FROM ps_endpoints WHERE id = $1", endpoint_id)
|
||||
await conn.execute("DELETE FROM ps_aors WHERE id = $1", endpoint_id)
|
||||
await conn.execute("DELETE FROM ps_auths WHERE id = $1", endpoint_id)
|
||||
# Dialplan rows live under two derived contexts.
|
||||
await conn.execute(
|
||||
"DELETE FROM extensions WHERE context IN ($1, $2)",
|
||||
f"{endpoint_id}-inbound",
|
||||
f"{endpoint_id}-outbound",
|
||||
)
|
||||
|
||||
|
||||
async def _insert_auth(conn, endpoint_id: str, username: str, password: str) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ps_auths (id, auth_type, username, password)
|
||||
VALUES ($1, 'userpass', $2, $3)
|
||||
""",
|
||||
endpoint_id,
|
||||
username,
|
||||
password,
|
||||
)
|
||||
|
||||
|
||||
async def _insert_aor(conn, endpoint_id: str) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ps_aors (id, max_contacts, qualify_frequency)
|
||||
VALUES ($1, 1, 60)
|
||||
""",
|
||||
endpoint_id,
|
||||
)
|
||||
|
||||
|
||||
async def _insert_endpoint(
|
||||
conn, endpoint_id: str, transport_name: str, sip_domain: str
|
||||
) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ps_endpoints (
|
||||
id, transport, aors, auth, context,
|
||||
disallow, allow, from_domain, identify_by
|
||||
) VALUES ($1, $2, $1, $1, $3, $4, $5, $6, 'auth_username,username')
|
||||
""",
|
||||
endpoint_id,
|
||||
transport_name,
|
||||
f"{endpoint_id}-inbound",
|
||||
_DEFAULT_DISALLOW,
|
||||
_DEFAULT_ALLOW,
|
||||
sip_domain,
|
||||
)
|
||||
|
||||
|
||||
async def _insert_registration(
|
||||
conn,
|
||||
*,
|
||||
endpoint_id: str,
|
||||
transport_name: str,
|
||||
sip_domain: str,
|
||||
extension: str,
|
||||
) -> None:
|
||||
server_uri = f"sip:{sip_domain}"
|
||||
client_uri = f"sip:{extension}@{sip_domain}"
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO ps_registrations (
|
||||
id, transport, outbound_auth, server_uri, client_uri,
|
||||
contact_user, expiration, retry_interval
|
||||
) VALUES ($1, $2, $1, $3, $4, $5, 300, 60)
|
||||
""",
|
||||
endpoint_id,
|
||||
transport_name,
|
||||
server_uri,
|
||||
client_uri,
|
||||
extension,
|
||||
)
|
||||
|
||||
|
||||
async def _insert_extensions(conn, rows: list[dict]) -> None:
|
||||
for r in rows:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO extensions (context, exten, priority, app, appdata)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
r["context"],
|
||||
r["exten"],
|
||||
r["priority"],
|
||||
r["app"],
|
||||
r["appdata"],
|
||||
)
|
||||
11
api/services/telephony/providers/three_cx/serializers.py
Normal file
11
api/services/telephony/providers/three_cx/serializers.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Asterisk frame serializer (re-exported from pipecat).
|
||||
|
||||
3CX runs through an Asterisk bridge, so the wire format and serializer are
|
||||
identical to the ARI provider. We re-export rather than import from
|
||||
``..ari`` to keep providers/__init__ from accidentally creating cross-package
|
||||
coupling — see providers/AGENTS.md.
|
||||
"""
|
||||
|
||||
from pipecat.serializers.asterisk import AsteriskFrameSerializer
|
||||
|
||||
__all__ = ["AsteriskFrameSerializer"]
|
||||
175
api/services/telephony/providers/three_cx/strategies.py
Normal file
175
api/services/telephony/providers/three_cx/strategies.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"""3CX transfer/hangup strategies.
|
||||
|
||||
Functionally identical to ``providers/ari/strategies.py`` — 3CX rides on
|
||||
top of Asterisk so the bridge-swap and channel-delete REST calls are the
|
||||
same. Duplicated rather than imported because providers/AGENTS.md forbids
|
||||
cross-provider imports; a future asterisk_base extraction should
|
||||
consolidate the two.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.serializers.call_strategies import HangupStrategy, TransferStrategy
|
||||
|
||||
|
||||
class ThreeCxBridgeSwapStrategy(TransferStrategy):
|
||||
"""Bridge-swap transfer over the underlying Asterisk."""
|
||||
|
||||
async def execute_transfer(self, context: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
import aiohttp
|
||||
import redis.asyncio as aioredis
|
||||
from aiohttp import BasicAuth
|
||||
|
||||
channel_id = context["channel_id"]
|
||||
ari_endpoint = context["ari_endpoint"]
|
||||
app_name = context["app_name"]
|
||||
app_password = context["app_password"]
|
||||
|
||||
if not channel_id or not ari_endpoint:
|
||||
logger.warning(
|
||||
"Cannot execute transfer: missing channel_id or ari_endpoint"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"[3CX Transfer] Executing bridge swap for channel {channel_id}"
|
||||
)
|
||||
|
||||
from api.constants import REDIS_URL
|
||||
from api.db import db_client
|
||||
from api.services.telephony.call_transfer_manager import (
|
||||
get_call_transfer_manager,
|
||||
)
|
||||
|
||||
auth = BasicAuth(app_name, app_password)
|
||||
|
||||
call_transfer_manager = await get_call_transfer_manager()
|
||||
|
||||
transfer_context = (
|
||||
await call_transfer_manager.find_transfer_context_for_call(channel_id)
|
||||
)
|
||||
if not transfer_context:
|
||||
logger.error(
|
||||
f"[3CX Transfer] No active transfer context found for caller {channel_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
redis = aioredis.from_url(REDIS_URL, decode_responses=True)
|
||||
workflow_run_id = await redis.get(f"ari:channel:{channel_id}")
|
||||
if not workflow_run_id:
|
||||
logger.error(
|
||||
f"[3CX Transfer] No workflow run found for caller {channel_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
workflow_run = await db_client.get_workflow_run_by_id(int(workflow_run_id))
|
||||
if not workflow_run or not workflow_run.gathered_context:
|
||||
logger.error(
|
||||
f"[3CX Transfer] No workflow context for run {workflow_run_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
ctx = workflow_run.gathered_context
|
||||
bridge_id = ctx.get("bridge_id")
|
||||
ext_channel_id = ctx.get("ext_channel_id")
|
||||
|
||||
if not bridge_id or not ext_channel_id:
|
||||
logger.error(
|
||||
f"[3CX Transfer] Missing bridge/external channel info: {ctx}"
|
||||
)
|
||||
return False
|
||||
|
||||
destination_channel_id = transfer_context.call_sid
|
||||
if not destination_channel_id:
|
||||
logger.error(
|
||||
"[3CX Transfer] No destination channel in transfer context"
|
||||
)
|
||||
return False
|
||||
|
||||
workflow_run.gathered_context["transfer_state"] = "in-progress"
|
||||
await db_client.update_workflow_run(
|
||||
run_id=int(workflow_run_id),
|
||||
gathered_context=workflow_run.gathered_context,
|
||||
)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
add_url = f"{ari_endpoint}/ari/bridges/{bridge_id}/addChannel"
|
||||
async with session.post(
|
||||
add_url, auth=auth, params={"channel": destination_channel_id}
|
||||
) as response:
|
||||
if response.status not in (200, 204):
|
||||
error_text = await response.text()
|
||||
logger.error(
|
||||
f"[3CX Transfer] Failed to add destination to bridge: "
|
||||
f"{response.status} {error_text}"
|
||||
)
|
||||
return False
|
||||
|
||||
remove_url = f"{ari_endpoint}/ari/bridges/{bridge_id}/removeChannel"
|
||||
async with session.post(
|
||||
remove_url, auth=auth, params={"channel": ext_channel_id}
|
||||
) as response:
|
||||
if response.status not in (200, 204):
|
||||
error_text = await response.text()
|
||||
logger.error(
|
||||
f"[3CX Transfer] Failed to remove external media: "
|
||||
f"{response.status} {error_text}"
|
||||
)
|
||||
|
||||
hangup_url = f"{ari_endpoint}/ari/channels/{ext_channel_id}"
|
||||
async with session.delete(hangup_url, auth=auth) as response:
|
||||
if response.status not in (200, 204, 404):
|
||||
error_text = await response.text()
|
||||
logger.warning(
|
||||
f"[3CX Transfer] Failed to hang up external media: "
|
||||
f"{response.status} {error_text}"
|
||||
)
|
||||
|
||||
await call_transfer_manager.remove_transfer_context(
|
||||
transfer_context.transfer_id
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to execute 3CX transfer: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class ThreeCxHangupStrategy(HangupStrategy):
|
||||
"""Hang up an Asterisk channel that was bridging to the 3CX trunk."""
|
||||
|
||||
async def execute_hangup(self, context: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
import aiohttp
|
||||
from aiohttp import BasicAuth
|
||||
|
||||
channel_id = context["channel_id"]
|
||||
ari_endpoint = context["ari_endpoint"]
|
||||
app_name = context["app_name"]
|
||||
app_password = context["app_password"]
|
||||
|
||||
if not channel_id or not ari_endpoint:
|
||||
logger.warning(
|
||||
"Cannot hang up Asterisk channel: missing channel_id or ari_endpoint"
|
||||
)
|
||||
return False
|
||||
|
||||
endpoint = f"{ari_endpoint}/ari/channels/{channel_id}"
|
||||
auth = BasicAuth(app_name, app_password)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.delete(endpoint, auth=auth) as response:
|
||||
if response.status in (200, 204, 404):
|
||||
return True
|
||||
error_text = await response.text()
|
||||
logger.error(
|
||||
f"Failed to terminate channel {channel_id}: "
|
||||
f"{response.status} {error_text}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to hang up Asterisk channel: {e}")
|
||||
return False
|
||||
77
api/services/telephony/providers/three_cx/transport.py
Normal file
77
api/services/telephony/providers/three_cx/transport.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""3CX transport factory.
|
||||
|
||||
3CX rides on top of an Asterisk bridge, so transport is wire-identical to
|
||||
the ARI provider. The only difference is ``expected_provider="three_cx"``
|
||||
so ``load_credentials_for_transport`` validates the right config type.
|
||||
"""
|
||||
|
||||
from fastapi import WebSocket
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.pipecat.transport_params import realtime_param_overrides
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
|
||||
from .serializers import AsteriskFrameSerializer
|
||||
from .strategies import ThreeCxBridgeSwapStrategy, ThreeCxHangupStrategy
|
||||
|
||||
|
||||
async def create_transport(
|
||||
websocket: WebSocket,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
*,
|
||||
ambient_noise_config: dict | None = None,
|
||||
telephony_configuration_id: int | None = None,
|
||||
is_realtime: bool = False,
|
||||
channel_id: str,
|
||||
):
|
||||
"""Create a transport for 3CX-via-Asterisk connections."""
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="three_cx"
|
||||
)
|
||||
|
||||
ari_endpoint = config.get("ari_endpoint")
|
||||
app_name = config.get("app_name")
|
||||
app_password = config.get("app_password")
|
||||
|
||||
if not ari_endpoint or not app_name or not app_password:
|
||||
raise ValueError(
|
||||
f"Incomplete 3CX configuration for organization {organization_id}. "
|
||||
f"Required: ari_endpoint, app_name, app_password"
|
||||
)
|
||||
|
||||
serializer = AsteriskFrameSerializer(
|
||||
channel_id=channel_id,
|
||||
ari_endpoint=ari_endpoint,
|
||||
app_name=app_name,
|
||||
app_password=app_password,
|
||||
transfer_strategy=ThreeCxBridgeSwapStrategy(),
|
||||
hangup_strategy=ThreeCxHangupStrategy(),
|
||||
params=AsteriskFrameSerializer.InputParams(
|
||||
asterisk_sample_rate=audio_config.transport_in_sample_rate,
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
**realtime_param_overrides(is_realtime),
|
||||
),
|
||||
)
|
||||
367
api/tests/telephony/test_three_cx.py
Normal file
367
api/tests/telephony/test_three_cx.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
"""Unit tests for the 3CX telephony provider.
|
||||
|
||||
Scope:
|
||||
* Config schemas (validators, mask/unmask roundtrip via discriminated union)
|
||||
* Pure-Python helpers (endpoint_id, dialplan row generation)
|
||||
* Provider methods that don't need a transport (validate_config,
|
||||
parse_inbound_webhook, validate_account_id)
|
||||
* Provisioning hook with mocked asyncpg pool — no real Postgres
|
||||
* SPEC wiring (preprocessor + account_id field)
|
||||
|
||||
These tests deliberately use no DB fixtures, so they don't trigger the
|
||||
session-scoped test-database setup in ``api/conftest.py``. They still
|
||||
require ``api/.env.test`` to define ``DATABASE_URL`` and ``REDIS_URL``,
|
||||
because the root conftest reads ``api.constants`` at import time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.schemas.telephony_config import (
|
||||
TelephonyConfigurationResponse,
|
||||
ThreeCxConfigurationRequest,
|
||||
ThreeCxConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony import registry
|
||||
from api.services.telephony.providers.three_cx import SPEC
|
||||
from api.services.telephony.providers.three_cx.dialplan import (
|
||||
_prefix_to_pattern,
|
||||
build_dialplan_rows,
|
||||
outbound_context_for,
|
||||
)
|
||||
from api.services.telephony.providers.three_cx.provider import ThreeCxProvider
|
||||
from api.services.telephony.providers.three_cx.provisioning import (
|
||||
_provision_3cx_trunk,
|
||||
endpoint_id_for,
|
||||
)
|
||||
|
||||
_FULL_CREDS = {
|
||||
"ari_endpoint": "http://asterisk.example.com:8088",
|
||||
"app_name": "dograh",
|
||||
"app_password": "secret",
|
||||
"ws_client_name": "dograh_staging",
|
||||
"sip_domain": "1156.3cx.cloud",
|
||||
"extension": "12611",
|
||||
"sip_password": "3cx-sip-secret",
|
||||
"strip_prefix": "^\\+39",
|
||||
"from_numbers": ["+393331112222"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# endpoint_id_for
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_endpoint_id_for_italian_3cx_tenant():
|
||||
assert endpoint_id_for("1156.3cx.cloud", "12611") == "dograh_1156_3cx_cloud_12611"
|
||||
|
||||
|
||||
def test_endpoint_id_for_normalizes_uppercase_and_dots():
|
||||
assert (
|
||||
endpoint_id_for("ACME.PBX.3CX.cloud", "200")
|
||||
== "dograh_acme_pbx_3cx_cloud_200"
|
||||
)
|
||||
|
||||
|
||||
def test_endpoint_id_for_collapses_runs_of_separators():
|
||||
assert endpoint_id_for("foo..bar--baz", "9") == "dograh_foo_bar_baz_9"
|
||||
|
||||
|
||||
def test_endpoint_id_for_rejects_empty_sip_domain():
|
||||
with pytest.raises(ValueError):
|
||||
endpoint_id_for("", "12611")
|
||||
|
||||
|
||||
def test_endpoint_id_for_rejects_empty_extension():
|
||||
with pytest.raises(ValueError):
|
||||
endpoint_id_for("1156.3cx.cloud", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dialplan
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prefix_to_pattern_italian():
|
||||
pattern, skip = _prefix_to_pattern("^\\+39")
|
||||
assert pattern == "_+39N."
|
||||
assert skip == 3 # '+39' is 3 characters
|
||||
|
||||
|
||||
def test_prefix_to_pattern_empty_falls_back_to_match_all():
|
||||
pattern, skip = _prefix_to_pattern("")
|
||||
assert pattern == "_X."
|
||||
assert skip == 0
|
||||
|
||||
|
||||
def test_prefix_to_pattern_unsupported_regex_raises():
|
||||
with pytest.raises(ValueError):
|
||||
_prefix_to_pattern("^\\+[0-9]{2}")
|
||||
|
||||
|
||||
def test_build_dialplan_rows_outbound_dials_into_pjsip_endpoint_with_skip():
|
||||
rows = build_dialplan_rows(
|
||||
endpoint_id="dograh_1156_3cx_cloud_12611",
|
||||
extension="12611",
|
||||
stasis_app="dograh",
|
||||
strip_prefix="^\\+39",
|
||||
)
|
||||
outbound = next(r for r in rows if r["context"].endswith("-outbound"))
|
||||
assert outbound["app"] == "Dial"
|
||||
assert outbound["exten"] == "_+39N."
|
||||
assert outbound["appdata"] == "PJSIP/${EXTEN:3}@dograh_1156_3cx_cloud_12611,60"
|
||||
|
||||
|
||||
def test_build_dialplan_rows_inbound_routes_extension_and_wildcard_to_stasis():
|
||||
rows = build_dialplan_rows(
|
||||
endpoint_id="dograh_1156_3cx_cloud_12611",
|
||||
extension="12611",
|
||||
stasis_app="dograh",
|
||||
strip_prefix="",
|
||||
)
|
||||
inbound = [r for r in rows if r["context"].endswith("-inbound")]
|
||||
extens = {r["exten"] for r in inbound}
|
||||
assert extens == {"12611", "_X."}
|
||||
for r in inbound:
|
||||
assert r["app"] == "Stasis"
|
||||
assert r["appdata"].startswith("dograh,inbound,")
|
||||
|
||||
|
||||
def test_outbound_context_for_matches_dialplan_naming():
|
||||
rows = build_dialplan_rows(
|
||||
endpoint_id="ep1",
|
||||
extension="10",
|
||||
stasis_app="dograh",
|
||||
strip_prefix="",
|
||||
)
|
||||
outbound = next(r for r in rows if r["app"] == "Dial")
|
||||
assert outbound["context"] == outbound_context_for("ep1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_request_validators_strip_and_lowercase_sip_domain():
|
||||
req = ThreeCxConfigurationRequest(
|
||||
ari_endpoint="http://asterisk:8088",
|
||||
app_name="dograh",
|
||||
app_password="x",
|
||||
sip_domain=" 1156.3CX.Cloud ",
|
||||
extension=" 12611 ",
|
||||
sip_password="y",
|
||||
)
|
||||
assert req.sip_domain == "1156.3cx.cloud"
|
||||
assert req.extension == "12611"
|
||||
|
||||
|
||||
def test_config_request_provider_literal_defaults_to_three_cx():
|
||||
req = ThreeCxConfigurationRequest(
|
||||
ari_endpoint="x",
|
||||
app_name="x",
|
||||
app_password="x",
|
||||
sip_domain="1156.3cx.cloud",
|
||||
extension="12611",
|
||||
sip_password="x",
|
||||
)
|
||||
assert req.provider == "three_cx"
|
||||
|
||||
|
||||
def test_telephony_config_response_can_carry_three_cx():
|
||||
"""The top-level response model must expose a `three_cx` slot."""
|
||||
resp = TelephonyConfigurationResponse(
|
||||
three_cx=ThreeCxConfigurationResponse(
|
||||
ari_endpoint="x",
|
||||
app_name="dograh",
|
||||
app_password="***", # already masked by caller
|
||||
sip_domain="1156.3cx.cloud",
|
||||
extension="12611",
|
||||
sip_password="***", # already masked by caller
|
||||
from_numbers=["+393331112222"],
|
||||
)
|
||||
)
|
||||
assert resp.three_cx is not None
|
||||
assert resp.three_cx.app_password == "***"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_provider_validate_config_with_full_data():
|
||||
p = ThreeCxProvider(_FULL_CREDS)
|
||||
assert p.validate_config() is True
|
||||
|
||||
|
||||
def test_provider_validate_config_missing_ari_endpoint_is_false():
|
||||
creds = {**_FULL_CREDS, "ari_endpoint": ""}
|
||||
assert ThreeCxProvider(creds).validate_config() is False
|
||||
|
||||
|
||||
def test_provider_parse_inbound_webhook_populates_account_id_from_extension():
|
||||
webhook = {
|
||||
"channel": {
|
||||
"id": "ch-1",
|
||||
"state": "Ringing",
|
||||
"caller": {"number": "+393331112222"},
|
||||
"dialplan": {"exten": "12611"},
|
||||
}
|
||||
}
|
||||
n = ThreeCxProvider.parse_inbound_webhook(webhook)
|
||||
assert n.provider == "three_cx"
|
||||
assert n.to_number == "12611"
|
||||
assert n.account_id == "12611"
|
||||
assert n.from_number == "+393331112222"
|
||||
|
||||
|
||||
def test_provider_parse_inbound_webhook_uses_none_for_missing_extension():
|
||||
n = ThreeCxProvider.parse_inbound_webhook({"channel": {}})
|
||||
assert n.account_id is None
|
||||
|
||||
|
||||
def test_provider_validate_account_id_matches_extension():
|
||||
assert ThreeCxProvider.validate_account_id({"extension": "12611"}, "12611") is True
|
||||
|
||||
|
||||
def test_provider_validate_account_id_rejects_wrong_extension():
|
||||
assert ThreeCxProvider.validate_account_id({"extension": "12611"}, "9999") is False
|
||||
|
||||
|
||||
def test_provider_validate_account_id_rejects_missing_config_extension():
|
||||
assert ThreeCxProvider.validate_account_id({}, "12611") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPEC registration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spec_registered_with_account_id_extension_and_preprocessor():
|
||||
spec = registry.get("three_cx")
|
||||
assert spec is SPEC
|
||||
assert spec.account_id_credential_field == "extension"
|
||||
assert spec.preprocess_credentials_on_save is not None
|
||||
assert spec.transport_sample_rate == 8000
|
||||
|
||||
|
||||
def test_spec_ui_metadata_marks_passwords_sensitive():
|
||||
by_name = {f.name: f for f in SPEC.ui_metadata.fields}
|
||||
assert by_name["app_password"].sensitive is True
|
||||
assert by_name["sip_password"].sensitive is True
|
||||
# Non-secret fields should NOT be marked sensitive.
|
||||
assert by_name["sip_domain"].sensitive is False
|
||||
assert by_name["extension"].sensitive is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provisioning (mocked asyncpg)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_mock_pool():
|
||||
"""Build a mock asyncpg pool whose ``acquire()`` yields a recording conn."""
|
||||
conn = MagicMock()
|
||||
conn.execute = AsyncMock(return_value="OK")
|
||||
|
||||
tx_ctx = MagicMock()
|
||||
tx_ctx.__aenter__ = AsyncMock(return_value=None)
|
||||
tx_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
conn.transaction = MagicMock(return_value=tx_ctx)
|
||||
|
||||
acquire_ctx = MagicMock()
|
||||
acquire_ctx.__aenter__ = AsyncMock(return_value=conn)
|
||||
acquire_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
pool = MagicMock()
|
||||
pool.acquire = MagicMock(return_value=acquire_ctx)
|
||||
return pool, conn
|
||||
|
||||
|
||||
@patch(
|
||||
"api.services.telephony.providers.three_cx.provisioning.get_pool",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_provision_writes_six_table_set_in_single_transaction(get_pool_mock):
|
||||
pool, conn = _make_mock_pool()
|
||||
get_pool_mock.return_value = pool
|
||||
|
||||
out = await _provision_3cx_trunk(dict(_FULL_CREDS))
|
||||
|
||||
# Returns the credentials unchanged — endpoint_id is rederived at runtime.
|
||||
assert out == _FULL_CREDS
|
||||
|
||||
statements = [call.args[0] for call in conn.execute.await_args_list]
|
||||
# Idempotency deletes come first (5 statements covering 4 ps_* + extensions).
|
||||
assert sum(1 for s in statements if s.lstrip().startswith("DELETE")) == 5
|
||||
# Then one INSERT per ps_* table + one INSERT per dialplan row (3 rows).
|
||||
inserts = [s for s in statements if "INSERT" in s]
|
||||
assert any("ps_auths" in s for s in inserts)
|
||||
assert any("ps_aors" in s for s in inserts)
|
||||
assert any("ps_endpoints" in s for s in inserts)
|
||||
assert any("ps_registrations" in s for s in inserts)
|
||||
assert sum(1 for s in inserts if "INTO extensions" in s) == 3
|
||||
# All inserts must happen inside one transaction context.
|
||||
assert conn.transaction.call_count == 1
|
||||
|
||||
|
||||
@patch(
|
||||
"api.services.telephony.providers.three_cx.provisioning.get_pool",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_provision_is_idempotent_on_resave(get_pool_mock):
|
||||
pool, conn = _make_mock_pool()
|
||||
get_pool_mock.return_value = pool
|
||||
|
||||
await _provision_3cx_trunk(dict(_FULL_CREDS))
|
||||
first_call_count = conn.execute.await_count
|
||||
|
||||
await _provision_3cx_trunk(dict(_FULL_CREDS))
|
||||
# Second call performs the same delete-then-insert work.
|
||||
assert conn.execute.await_count == 2 * first_call_count
|
||||
|
||||
|
||||
async def test_provision_raises_400_on_missing_required_field():
|
||||
bad = {**_FULL_CREDS}
|
||||
bad.pop("extension")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _provision_3cx_trunk(bad)
|
||||
assert exc.value.status_code == 400
|
||||
assert "extension" in exc.value.detail
|
||||
|
||||
|
||||
@patch(
|
||||
"api.services.telephony.providers.three_cx.provisioning.get_pool",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_provision_translates_ara_not_configured_to_400(get_pool_mock):
|
||||
from api.services.telephony.providers.three_cx.ara_db import (
|
||||
AraNotConfiguredError,
|
||||
)
|
||||
|
||||
get_pool_mock.side_effect = AraNotConfiguredError("ASTERISK_ARA_DSN not set")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _provision_3cx_trunk(dict(_FULL_CREDS))
|
||||
assert exc.value.status_code == 400
|
||||
assert "ASTERISK_ARA_DSN" in exc.value.detail
|
||||
|
||||
|
||||
@patch(
|
||||
"api.services.telephony.providers.three_cx.provisioning.get_pool",
|
||||
new_callable=AsyncMock,
|
||||
)
|
||||
async def test_provision_translates_db_error_to_502(get_pool_mock):
|
||||
pool, conn = _make_mock_pool()
|
||||
conn.execute = AsyncMock(side_effect=RuntimeError("relation \"ps_auths\" does not exist"))
|
||||
get_pool_mock.return_value = pool
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _provision_3cx_trunk(dict(_FULL_CREDS))
|
||||
assert exc.value.status_code == 502
|
||||
assert "ps_auths" in exc.value.detail
|
||||
203
docs/providers/three_cx.md
Normal file
203
docs/providers/three_cx.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# 3CX Telephony Provider
|
||||
|
||||
Connect a Dograh AI agent to a **3CX cloud PBX** through an intermediate
|
||||
**Asterisk** bridge. The Asterisk box terminates the SIP/RTP leg toward
|
||||
3CX and exposes a standard ARI + externalMedia surface to Dograh —
|
||||
identical to the [Asterisk ARI provider](../integrations/telephony/asterisk-ari)
|
||||
plus an automated trunk-provisioning step.
|
||||
|
||||
```
|
||||
+-------------------+ SIP/RTP +-------------+ ARI REST + +---------+
|
||||
| 3CX cloud SBC | <------------> | Asterisk | WS audio | -> | Dograh |
|
||||
| 1156.3cx.cloud | | (PJSIP ARA)| | | agent |
|
||||
+-------------------+ +-------------+ + +---------+
|
||||
^
|
||||
| ps_endpoints, ps_aors,
|
||||
| ps_auths, ps_registrations,
|
||||
| extensions (Postgres)
|
||||
|
|
||||
+----+----+
|
||||
| Dograh |
|
||||
| save UI |
|
||||
+---------+
|
||||
```
|
||||
|
||||
When an admin saves a 3CX TelephonyConfiguration in the Dograh UI, the
|
||||
provider's `preprocess_credentials_on_save` hook writes the matching
|
||||
PJSIP endpoint/aor/auth/registration rows and the `+39`-stripping
|
||||
dialplan into the Asterisk Realtime Architecture (ARA) Postgres. Asterisk
|
||||
picks them up dynamically — no `pjsip reload` needed.
|
||||
|
||||
## §1 — Asterisk side prerequisites
|
||||
|
||||
The bridging Asterisk must be ≥ Asterisk 18 with PJSIP and ARA enabled
|
||||
against the same Postgres Dograh writes to. One Asterisk instance can
|
||||
serve many Dograh 3CX configurations (multi-tenant) because each trunk
|
||||
gets a unique endpoint id of the form `dograh_<slug(sip_domain)>_<extension>`.
|
||||
|
||||
### 1.1 Postgres tables
|
||||
|
||||
Run the standard Asterisk realtime DDL on the ARA database — the
|
||||
relevant tables are `ps_auths`, `ps_aors`, `ps_endpoints`,
|
||||
`ps_registrations`, `ps_transports`, `ps_globals`, and `extensions`.
|
||||
The canonical schema ships with Asterisk under `contrib/realtime/postgresql/`.
|
||||
|
||||
### 1.2 `res_config_pgsql.conf`
|
||||
|
||||
```ini
|
||||
[asterisk]
|
||||
type = pgsql
|
||||
hostname = postgres.internal
|
||||
dbname = asterisk_ara
|
||||
user = asterisk_ro
|
||||
password = ********
|
||||
port = 5432
|
||||
requirements = warn
|
||||
```
|
||||
|
||||
### 1.3 `sorcery.conf`
|
||||
|
||||
```ini
|
||||
[res_pjsip]
|
||||
endpoint = realtime,ps_endpoints
|
||||
auth = realtime,ps_auths
|
||||
aor = realtime,ps_aors
|
||||
domain_alias = realtime,ps_domain_aliases
|
||||
contact = realtime,ps_contacts
|
||||
|
||||
[res_pjsip_endpoint_identifier_ip]
|
||||
identify = realtime,ps_endpoint_id_ips
|
||||
|
||||
[res_pjsip_outbound_registration]
|
||||
registration = realtime,ps_registrations
|
||||
```
|
||||
|
||||
### 1.4 `extconfig.conf`
|
||||
|
||||
```ini
|
||||
[settings]
|
||||
ps_endpoints = pgsql,asterisk
|
||||
ps_auths = pgsql,asterisk
|
||||
ps_aors = pgsql,asterisk
|
||||
ps_registrations = pgsql,asterisk
|
||||
extensions = pgsql,asterisk
|
||||
```
|
||||
|
||||
### 1.5 Static PJSIP transport
|
||||
|
||||
Dograh writes endpoints that reference a transport by name (default:
|
||||
`transport-udp`). Define it once in `pjsip.conf`:
|
||||
|
||||
```ini
|
||||
[transport-udp]
|
||||
type = transport
|
||||
protocol = udp
|
||||
bind = 0.0.0.0:5060
|
||||
```
|
||||
|
||||
### 1.6 Stasis app + externalMedia
|
||||
|
||||
```ini
|
||||
; ari.conf
|
||||
[general]
|
||||
enabled = yes
|
||||
[dograh]
|
||||
type = user
|
||||
read_only = no
|
||||
password = <ARI password to paste in the Dograh UI>
|
||||
|
||||
; websocket_client.conf
|
||||
[dograh_staging]
|
||||
type = websocket_client
|
||||
uri = ws://dograh-backend:8000/api/v1/telephony/ws/ari
|
||||
protocols = media
|
||||
connection_type = persistent
|
||||
```
|
||||
|
||||
Start the Stasis app and confirm registration is happening:
|
||||
|
||||
```bash
|
||||
asterisk -rx "module reload res_pjsip.so"
|
||||
asterisk -rx "pjsip show registrations"
|
||||
```
|
||||
|
||||
## §2 — Dograh side prerequisites
|
||||
|
||||
Set the connection string to the ARA Postgres in `api/.env`:
|
||||
|
||||
```bash
|
||||
ASTERISK_ARA_DSN=postgresql://dograh_rw:********@postgres.internal:5432/asterisk_ara
|
||||
```
|
||||
|
||||
The user needs `SELECT, INSERT, UPDATE, DELETE` on `ps_auths`,
|
||||
`ps_aors`, `ps_endpoints`, `ps_registrations`, and `extensions`. No DDL
|
||||
permissions required at runtime.
|
||||
|
||||
Restart the Dograh API process after setting the env var.
|
||||
|
||||
## §3 — Per-trunk flow in the Dograh UI
|
||||
|
||||
For each 3CX tenant + extension Dograh should serve:
|
||||
|
||||
1. Open *Telephony Configurations* → *Add* → select **3CX (Asterisk bridge)**.
|
||||
2. Fill in the form:
|
||||
|
||||
| Field | Value (example) |
|
||||
| --- | --- |
|
||||
| ARI Endpoint | `http://asterisk.internal:8088` |
|
||||
| Stasis App Name | `dograh` |
|
||||
| ARI Password | _(matches `ari.conf` `[dograh]` password)_ |
|
||||
| websocket_client.conf Name | `dograh_staging` |
|
||||
| 3CX SIP Domain | `1156.3cx.cloud` |
|
||||
| 3CX Extension | `12611` |
|
||||
| SIP Password | _(from `~/.claude-phone/.env` or 3CX admin console)_ |
|
||||
| Strip Prefix (regex) | `^\+39` |
|
||||
| From Numbers | `+393331112222` |
|
||||
|
||||
3. Save. On save the `preprocess_credentials_on_save` hook writes the
|
||||
six-table ARA set in a single transaction. A failure aborts the save
|
||||
with `HTTP 502` and a message describing which write failed; nothing
|
||||
persists.
|
||||
|
||||
## §4 — Verification
|
||||
|
||||
Confirm the trunk landed in ARA:
|
||||
|
||||
```bash
|
||||
psql $ASTERISK_ARA_DSN -c \
|
||||
"SELECT id FROM ps_endpoints WHERE id LIKE 'dograh\\_%'"
|
||||
psql $ASTERISK_ARA_DSN -c \
|
||||
"SELECT id, server_uri FROM ps_registrations WHERE id LIKE 'dograh\\_%'"
|
||||
```
|
||||
|
||||
Confirm Asterisk has registered upstream with 3CX:
|
||||
|
||||
```bash
|
||||
asterisk -rx "pjsip show registrations"
|
||||
# Expect: <id> <server> Registered
|
||||
```
|
||||
|
||||
Originate a test outbound call from Dograh and verify the `+39` prefix
|
||||
was stripped on the way out:
|
||||
|
||||
```bash
|
||||
asterisk -rx "core set verbose 4"
|
||||
# In another terminal: trigger an outbound from the Dograh API.
|
||||
# In the Asterisk console you should see:
|
||||
# Dial: PJSIP/3331112222@dograh_1156_3cx_cloud_12611
|
||||
# i.e. without '+39'.
|
||||
```
|
||||
|
||||
## Known limitations
|
||||
|
||||
* The hook **only** supports the literal `^\+<digits>` regex form for
|
||||
`strip_prefix`. PCRE alternation isn't translated to Asterisk's
|
||||
ad-hoc extension pattern syntax. Adding a `[02-9]` or branching
|
||||
regex needs an extension to `dialplan._prefix_to_pattern`.
|
||||
* Deprovisioning on TelephonyConfiguration deletion is not currently
|
||||
wired. `provisioning._deprovision_3cx_trunk` exists as a callable but
|
||||
no registry hook fires it; admin tooling can call it directly. Filed
|
||||
for follow-up rather than in scope for the initial provider.
|
||||
* `transport_name` is hard-coded to `transport-udp` (overridable per
|
||||
credentials dict). TLS or TCP trunks toward 3CX need the admin to
|
||||
define the transport and pass the name through.
|
||||
Loading…
Add table
Add a link
Reference in a new issue