mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
feat(telephony): add FreeSWITCH provider to upstream-PBX seam
Generalize the upstream-PBX capture and control paths to dispatch by provider. FreeSWITCH bridges in with X-PBX-* headers and is driven over the Event Socket Library (uuid_kill / uuid_transfer) by the channel UUID, alongside the existing VICIdial ra_call_control path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5380489d28
commit
bd77a5a639
2 changed files with 134 additions and 17 deletions
|
|
@ -440,14 +440,42 @@ class ARIConnection:
|
|||
return (result or {}).get("value", "") or ""
|
||||
|
||||
async def _capture_upstream_pbx(self, channel_id: str) -> Optional[dict]:
|
||||
"""Capture upstream-PBX (VICIdial) identity from the inbound SIP headers.
|
||||
"""Capture upstream-PBX identity from the inbound SIP headers.
|
||||
|
||||
When the call is patched in from VICIdial, the trunk INVITE carries
|
||||
``X-VICIDIAL-*`` headers. The callerid + remote-agent user are the handle
|
||||
dograh uses to drive VICIdial's ``ra_call_control`` API for hangup and
|
||||
transfer (the customer's real leg lives on VICIdial, not on dograh).
|
||||
Returns None for non-upstream calls (no callerid header present).
|
||||
The customer's real call leg lives on the upstream PBX, not on dograh, so
|
||||
dograh drives hangup/transfer via the upstream's API using a captured
|
||||
handle. Two providers are supported:
|
||||
* FreeSWITCH — bridges in with ``X-PBX-*`` headers; the handle is the
|
||||
channel UUID (``X-PBX-UUID``), driven over ESL (uuid_kill/transfer).
|
||||
* VICIdial — patches in with ``X-VICIDIAL-*`` headers; the handle is the
|
||||
callerid + remote-agent user, driven over ``ra_call_control``.
|
||||
Returns None for non-upstream (direct) calls.
|
||||
"""
|
||||
# FreeSWITCH: X-PBX-Provider marks the call; X-PBX-UUID is the ESL handle.
|
||||
if (
|
||||
await self._get_channel_var(
|
||||
channel_id, "PJSIP_HEADER(read,X-PBX-Provider)"
|
||||
)
|
||||
) == "freeswitch":
|
||||
upstream = {
|
||||
"provider": "freeswitch",
|
||||
"uuid": await self._get_channel_var(
|
||||
channel_id, "PJSIP_HEADER(read,X-PBX-UUID)"
|
||||
),
|
||||
"lead_id": await self._get_channel_var(
|
||||
channel_id, "PJSIP_HEADER(read,X-PBX-Lead-ID)"
|
||||
),
|
||||
"campaign_id": await self._get_channel_var(
|
||||
channel_id, "PJSIP_HEADER(read,X-PBX-Campaign)"
|
||||
),
|
||||
}
|
||||
logger.info(
|
||||
f"[ARI org={self.organization_id}] Captured upstream_pbx for channel "
|
||||
f"{channel_id}: {upstream}"
|
||||
)
|
||||
return upstream
|
||||
|
||||
# VICIdial: X-VICIDIAL-callerid + user is the ra_call_control handle.
|
||||
callerid = await self._get_channel_var(
|
||||
channel_id, "PJSIP_HEADER(read,X-VICIDIAL-callerid)"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ version moves these into the ARI telephony-configuration credentials and selects
|
|||
the adapter by ``upstream["provider"]`` (see the upstream-PBX seam design doc).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
|
|
@ -29,6 +31,14 @@ _VICIDIAL_API_SOURCE = "dograh"
|
|||
|
||||
_HTTP_TIMEOUT = aiohttp.ClientTimeout(total=8)
|
||||
|
||||
# --- POC hardcoded FreeSWITCH ESL connection (refine: telephony config creds) ---
|
||||
# FreeSWITCH owns the customer leg; dograh drives hangup/transfer over the Event
|
||||
# Socket Library by the channel UUID captured from the X-PBX-UUID header.
|
||||
_FS_ESL_HOST = "10.10.10.17"
|
||||
_FS_ESL_PORT = 8021
|
||||
_FS_ESL_PASSWORD = "ClueCon"
|
||||
_FS_ESL_TIMEOUT = 8
|
||||
|
||||
|
||||
async def _ra_call_control(upstream: dict, stage: str, **extra) -> bool:
|
||||
"""Invoke VICIdial's agent API ``ra_call_control`` for the captured RA call.
|
||||
|
|
@ -63,25 +73,104 @@ async def _ra_call_control(upstream: dict, stage: str, **extra) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
async def _fs_esl_api(command: str) -> tuple[bool, str]:
|
||||
"""Run a FreeSWITCH ``api`` command over the Event Socket (inbound mode).
|
||||
|
||||
Connects, authenticates, issues ``api <command>`` and returns
|
||||
``(ok, response_body)`` where ok is True when FreeSWITCH replied ``+OK``.
|
||||
"""
|
||||
|
||||
async def _run() -> tuple[bool, str]:
|
||||
reader, writer = await asyncio.open_connection(_FS_ESL_HOST, _FS_ESL_PORT)
|
||||
try:
|
||||
await reader.readuntil(b"\n\n") # "Content-Type: auth/request"
|
||||
writer.write(f"auth {_FS_ESL_PASSWORD}\n\n".encode())
|
||||
await writer.drain()
|
||||
await reader.readuntil(b"\n\n") # auth command/reply
|
||||
writer.write(f"api {command}\n\n".encode())
|
||||
await writer.drain()
|
||||
headers = (await reader.readuntil(b"\n\n")).decode(errors="replace")
|
||||
length = 0
|
||||
for line in headers.splitlines():
|
||||
if line.lower().startswith("content-length:"):
|
||||
length = int(line.split(":", 1)[1].strip())
|
||||
body = (
|
||||
(await reader.readexactly(length)).decode(errors="replace")
|
||||
if length
|
||||
else ""
|
||||
)
|
||||
return body.startswith("+OK"), body.strip()
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(_run(), timeout=_FS_ESL_TIMEOUT)
|
||||
except Exception as e:
|
||||
logger.error(f"[upstream_pbx] FreeSWITCH ESL '{command}' failed: {e}")
|
||||
return False, ""
|
||||
|
||||
|
||||
async def _fs_uuid_kill(upstream: dict) -> bool:
|
||||
uuid = upstream.get("uuid", "")
|
||||
if not uuid:
|
||||
return False
|
||||
ok, resp = await _fs_esl_api(f"uuid_kill {uuid}")
|
||||
logger.info(f"[upstream_pbx] FreeSWITCH uuid_kill {uuid} -> {resp or ok}")
|
||||
return ok
|
||||
|
||||
|
||||
async def _fs_uuid_transfer(upstream: dict, destination: str) -> bool:
|
||||
uuid = upstream.get("uuid", "")
|
||||
if not uuid:
|
||||
return False
|
||||
number = destination.split("/")[-1]
|
||||
# The customer leg is transferred into the FS dialplan extension
|
||||
# dograh_xfer_<number>, which bridges to that registered user/agent.
|
||||
ok, resp = await _fs_esl_api(
|
||||
f"uuid_transfer {uuid} dograh_xfer_{number} XML dograh-customer"
|
||||
)
|
||||
logger.info(
|
||||
f"[upstream_pbx] FreeSWITCH uuid_transfer {uuid} -> {number}: {resp or ok}"
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
async def terminate_upstream_call(upstream: dict) -> bool:
|
||||
"""Hang up the upstream PBX's customer leg. Call this BEFORE dropping dograh's leg."""
|
||||
if not upstream or upstream.get("provider") != "vicidial":
|
||||
if not upstream:
|
||||
return False
|
||||
return await _ra_call_control(upstream, "HANGUP")
|
||||
provider = upstream.get("provider")
|
||||
if provider == "vicidial":
|
||||
return await _ra_call_control(upstream, "HANGUP")
|
||||
if provider == "freeswitch":
|
||||
return await _fs_uuid_kill(upstream)
|
||||
return False
|
||||
|
||||
|
||||
async def transfer_upstream_call(upstream: dict, destination: str) -> bool:
|
||||
"""Transfer the upstream PBX's customer leg (scaffolding for the transfer seam).
|
||||
"""Transfer the upstream PBX's customer leg, dispatched by provider.
|
||||
|
||||
``ingroup:<id>`` -> VICIdial INGROUPTRANSFER (to a queue/agent group);
|
||||
anything else -> EXTENSIONTRANSFER to that number/extension (tolerates a
|
||||
leading ``PJSIP/`` so dograh's existing transfer destination strings work).
|
||||
VICIdial: ``ingroup:<id>`` -> INGROUPTRANSFER (to a queue/agent group);
|
||||
anything else -> EXTENSIONTRANSFER to that number/extension.
|
||||
FreeSWITCH: uuid_transfer the customer leg to the FS dialplan extension that
|
||||
bridges to the target. (Both tolerate a leading ``PJSIP/`` in destination.)
|
||||
"""
|
||||
if not upstream or upstream.get("provider") != "vicidial":
|
||||
if not upstream:
|
||||
return False
|
||||
if destination.startswith("ingroup:"):
|
||||
provider = upstream.get("provider")
|
||||
if provider == "vicidial":
|
||||
if destination.startswith("ingroup:"):
|
||||
return await _ra_call_control(
|
||||
upstream, "INGROUPTRANSFER", ingroup_choices=destination.split(":", 1)[1]
|
||||
)
|
||||
number = destination.split("/")[-1]
|
||||
return await _ra_call_control(
|
||||
upstream, "INGROUPTRANSFER", ingroup_choices=destination.split(":", 1)[1]
|
||||
upstream, "EXTENSIONTRANSFER", phone_number=number
|
||||
)
|
||||
number = destination.split("/")[-1]
|
||||
return await _ra_call_control(upstream, "EXTENSIONTRANSFER", phone_number=number)
|
||||
if provider == "freeswitch":
|
||||
return await _fs_uuid_transfer(upstream, destination)
|
||||
return False
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue