479 lines
14 KiB
Python
479 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from typing import Any
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from services.shared.models import BrowserSoftphoneConfigOut
|
|
|
|
|
|
def bool_env(name: str, default: bool) -> bool:
|
|
raw = os.getenv(name)
|
|
if raw is None:
|
|
return default
|
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def bridge_enabled() -> bool:
|
|
return bool_env("ASTERISK_BRIDGE_ENABLED", False)
|
|
|
|
|
|
def ami_host() -> str:
|
|
return os.getenv("ASTERISK_AMI_HOST", "").strip()
|
|
|
|
|
|
def ami_port() -> int:
|
|
return int(os.getenv("ASTERISK_AMI_PORT", "5038"))
|
|
|
|
|
|
def ami_username() -> str:
|
|
return os.getenv("ASTERISK_AMI_USERNAME", "").strip()
|
|
|
|
|
|
def ami_secret() -> str:
|
|
return os.getenv("ASTERISK_AMI_SECRET", "").strip()
|
|
|
|
|
|
def ami_prefix() -> str:
|
|
return os.getenv("ASTERISK_AMI_EVENT_PREFIX", "MVPCC").strip() or "MVPCC"
|
|
|
|
|
|
def poll_interval() -> float:
|
|
try:
|
|
value = float(os.getenv("ASTERISK_BRIDGE_POLL_INTERVAL_SECONDS", "1").strip())
|
|
except ValueError:
|
|
value = 1.0
|
|
return max(value, 0.25)
|
|
|
|
|
|
def recent_calls_window_seconds() -> int:
|
|
raw = os.getenv("ASTERISK_RECENT_CALLS_WINDOW_SECONDS", "120").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 120
|
|
return max(value, 30)
|
|
|
|
|
|
def failed_retry_enabled() -> bool:
|
|
return bool_env("ASTERISK_BRIDGE_FAILED_RETRY_ENABLED", True)
|
|
|
|
|
|
def failed_retry_interval_seconds() -> float:
|
|
raw = os.getenv("ASTERISK_BRIDGE_FAILED_RETRY_INTERVAL_SECONDS", "5").strip()
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
value = 5.0
|
|
return max(value, 1.0)
|
|
|
|
|
|
def failed_retry_batch_size() -> int:
|
|
raw = os.getenv("ASTERISK_BRIDGE_FAILED_RETRY_BATCH_SIZE", "20").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 20
|
|
return max(value, 1)
|
|
|
|
|
|
def reconcile_enabled() -> bool:
|
|
return bool_env("ASTERISK_BRIDGE_RECONCILE_ENABLED", True)
|
|
|
|
|
|
def reconcile_stale_seconds() -> int:
|
|
raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_STALE_SECONDS", "12").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 30
|
|
return max(value, 5)
|
|
|
|
|
|
def reconcile_settle_seconds() -> int:
|
|
raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_SETTLE_SECONDS", "6").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 8
|
|
return max(value, 2)
|
|
|
|
|
|
def reconcile_scan_limit() -> int:
|
|
raw = os.getenv("ASTERISK_BRIDGE_RECONCILE_SCAN_LIMIT", "20").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 20
|
|
return max(value, 1)
|
|
|
|
|
|
def forward_timeout_seconds(default: float = 45.0) -> float:
|
|
raw = os.getenv("ASTERISK_BRIDGE_FORWARD_TIMEOUT_SECONDS", str(default)).strip()
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
value = default
|
|
return max(value, 5.0)
|
|
|
|
|
|
def forward_max_attempts() -> int:
|
|
raw = os.getenv("ASTERISK_BRIDGE_FORWARD_MAX_ATTEMPTS", "3").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 3
|
|
return max(value, 1)
|
|
|
|
|
|
def forward_retry_backoff_seconds() -> float:
|
|
raw = os.getenv("ASTERISK_BRIDGE_FORWARD_RETRY_BACKOFF_SECONDS", "0.75").strip()
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
value = 0.75
|
|
return max(value, 0.05)
|
|
|
|
|
|
def queue_map() -> dict[str, str]:
|
|
raw = os.getenv("ASTERISK_QUEUE_MAP_JSON", "{}").strip()
|
|
try:
|
|
payload = json.loads(raw or "{}")
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError("Invalid ASTERISK_QUEUE_MAP_JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("ASTERISK_QUEUE_MAP_JSON must be a JSON object")
|
|
return {str(k): str(v) for k, v in payload.items() if str(k).strip() and str(v).strip()}
|
|
|
|
|
|
def sftp_enabled() -> bool:
|
|
return bool(
|
|
os.getenv("ASTERISK_SFTP_HOST", "").strip()
|
|
and os.getenv("ASTERISK_SFTP_USERNAME", "").strip()
|
|
)
|
|
|
|
|
|
def sftp_host() -> str:
|
|
return os.getenv("ASTERISK_SFTP_HOST", "").strip()
|
|
|
|
|
|
def sftp_port() -> int:
|
|
return int(os.getenv("ASTERISK_SFTP_PORT", "22"))
|
|
|
|
|
|
def sftp_username() -> str:
|
|
return os.getenv("ASTERISK_SFTP_USERNAME", "").strip()
|
|
|
|
|
|
def sftp_password() -> str:
|
|
return os.getenv("ASTERISK_SFTP_PASSWORD", "").strip()
|
|
|
|
|
|
def interaction_service_url() -> str:
|
|
return os.getenv("INTERACTION_SERVICE_URL", "http://localhost:8004").rstrip("/")
|
|
|
|
|
|
def voice_adapter_service_url() -> str:
|
|
return os.getenv("VOICE_ADAPTER_SERVICE_URL", "http://localhost:8006").rstrip("/")
|
|
|
|
|
|
def recording_service_url() -> str:
|
|
return os.getenv("RECORDING_SERVICE_URL", "http://localhost:8013").rstrip("/")
|
|
|
|
|
|
def ivr_service_url() -> str:
|
|
return os.getenv("IVR_SERVICE_URL", "http://localhost:8014").rstrip("/")
|
|
|
|
|
|
def ai_voice_runtime_service_url() -> str:
|
|
return os.getenv("AI_VOICE_RUNTIME_SERVICE_URL", "http://localhost:8018").rstrip("/")
|
|
|
|
|
|
def ai_voice_enabled() -> bool:
|
|
return bool_env("AI_VOICE_ENABLED", False)
|
|
|
|
|
|
def ai_voice_queue_config() -> dict[str, dict[str, Any]]:
|
|
raw = os.getenv("AI_VOICE_QUEUE_CONFIG_JSON", "{}").strip()
|
|
try:
|
|
payload = json.loads(raw or "{}")
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError("Invalid AI_VOICE_QUEUE_CONFIG_JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("AI_VOICE_QUEUE_CONFIG_JSON must be a JSON object")
|
|
result: dict[str, dict[str, Any]] = {}
|
|
for queue_code, item in payload.items():
|
|
key = str(queue_code or "").strip()
|
|
if not key or not isinstance(item, dict):
|
|
continue
|
|
result[key] = item
|
|
return result
|
|
|
|
|
|
def ai_voice_config_for_queue(queue_code: str | None) -> dict[str, Any] | None:
|
|
key = str(queue_code or "").strip()
|
|
if not key or not ai_voice_enabled():
|
|
return None
|
|
item = ai_voice_queue_config().get(key)
|
|
if not isinstance(item, dict):
|
|
return None
|
|
if str(item.get("mode") or "ai_first").strip().lower() != "ai_first":
|
|
return None
|
|
stage = str(item.get("stage") or "").strip() or None
|
|
next_queue_code = str(item.get("next_queue_code") or "").strip() or None
|
|
next_queue_id = str(item.get("next_queue_id") or "").strip() or None
|
|
handoff_queue_code = str(item.get("handoff_queue_code") or next_queue_code or key).strip() or key
|
|
handoff_queue_id = queue_map().get(handoff_queue_code)
|
|
if next_queue_code and not next_queue_id:
|
|
next_queue_id = queue_map().get(next_queue_code)
|
|
if stage == "voice_start" and not handoff_queue_id:
|
|
handoff_queue_id = next_queue_id
|
|
return {
|
|
"queue_code": key,
|
|
"mode": "ai_first",
|
|
"agent_profile": str(item.get("agent_profile") or "voice_support").strip() or "voice_support",
|
|
"language": str(item.get("language") or "").strip() or None,
|
|
"stage": stage,
|
|
"next_queue_code": next_queue_code,
|
|
"next_queue_id": next_queue_id,
|
|
"handoff_queue_code": handoff_queue_code,
|
|
"handoff_queue_id": handoff_queue_id,
|
|
}
|
|
|
|
|
|
def ivr_fastagi_enabled() -> bool:
|
|
return bool_env("ASTERISK_IVR_FASTAGI_ENABLED", False)
|
|
|
|
|
|
def ivr_fastagi_host() -> str:
|
|
return os.getenv("ASTERISK_IVR_FASTAGI_HOST", "127.0.0.1").strip() or "127.0.0.1"
|
|
|
|
|
|
def ivr_fastagi_port() -> int:
|
|
raw = os.getenv("ASTERISK_IVR_FASTAGI_PORT", "4573").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 4573
|
|
return max(value, 1)
|
|
|
|
|
|
def ivr_dtmf_timeout_seconds() -> int:
|
|
raw = os.getenv("ASTERISK_IVR_DTMF_TIMEOUT_SECONDS", "5").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 5
|
|
return max(value, 1)
|
|
|
|
|
|
def ivr_max_no_input_retries() -> int:
|
|
raw = os.getenv("ASTERISK_IVR_MAX_NO_INPUT_RETRIES", "2").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 2
|
|
return max(value, 0)
|
|
|
|
|
|
def ivr_max_invalid_retries() -> int:
|
|
raw = os.getenv("ASTERISK_IVR_MAX_INVALID_RETRIES", "2").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 2
|
|
return max(value, 0)
|
|
|
|
|
|
def ivr_call_link_wait_seconds() -> float:
|
|
raw = os.getenv("ASTERISK_IVR_CALL_LINK_WAIT_SECONDS", "5").strip()
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
value = 5.0
|
|
return max(value, 0.5)
|
|
|
|
|
|
def callcontrol_enabled() -> bool:
|
|
return bool_env("ASTERISK_CALLCONTROL_ENABLED", False)
|
|
|
|
|
|
def callcontrol_action_timeout_seconds() -> float:
|
|
raw = os.getenv("ASTERISK_CALLCONTROL_ACTION_TIMEOUT_SECONDS", "10").strip()
|
|
try:
|
|
value = float(raw)
|
|
except ValueError:
|
|
value = 10.0
|
|
return max(value, 2.0)
|
|
|
|
|
|
def webrtc_enabled() -> bool:
|
|
return bool_env("ASTERISK_WEBRTC_ENABLED", False)
|
|
|
|
|
|
def webrtc_ws_url() -> str:
|
|
return os.getenv("ASTERISK_WEBRTC_WS_URL", "").strip()
|
|
|
|
|
|
def webrtc_ice_servers() -> list[dict[str, Any]]:
|
|
raw = os.getenv("ASTERISK_WEBRTC_ICE_SERVERS_JSON", "[]").strip()
|
|
try:
|
|
payload = json.loads(raw or "[]")
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError("Invalid ASTERISK_WEBRTC_ICE_SERVERS_JSON") from exc
|
|
if not isinstance(payload, list):
|
|
raise RuntimeError("ASTERISK_WEBRTC_ICE_SERVERS_JSON must be a JSON array")
|
|
result: list[dict[str, Any]] = []
|
|
for item in payload:
|
|
if isinstance(item, dict):
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def operator_extension_map() -> dict[str, str]:
|
|
raw = os.getenv("ASTERISK_OPERATOR_EXTENSION_MAP_JSON", "{}").strip()
|
|
try:
|
|
payload = json.loads(raw or "{}")
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError("Invalid ASTERISK_OPERATOR_EXTENSION_MAP_JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("ASTERISK_OPERATOR_EXTENSION_MAP_JSON must be a JSON object")
|
|
return {str(k).strip(): str(v).strip() for k, v in payload.items() if str(k).strip() and str(v).strip()}
|
|
|
|
|
|
def user_for_operator_extension(extension: str | None) -> str | None:
|
|
target = str(extension or "").strip()
|
|
if not target:
|
|
return None
|
|
matches = [user for user, mapped in operator_extension_map().items() if mapped == target]
|
|
unique_matches = list(dict.fromkeys(matches))
|
|
if len(unique_matches) == 1:
|
|
return unique_matches[0]
|
|
return None
|
|
|
|
|
|
def browser_sip_map() -> dict[str, dict[str, Any]]:
|
|
raw = os.getenv("ASTERISK_BROWSER_SIP_MAP_JSON", "{}").strip()
|
|
try:
|
|
payload = json.loads(raw or "{}")
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError("Invalid ASTERISK_BROWSER_SIP_MAP_JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("ASTERISK_BROWSER_SIP_MAP_JSON must be a JSON object")
|
|
result: dict[str, dict[str, Any]] = {}
|
|
for user, item in payload.items():
|
|
if not str(user).strip() or not isinstance(item, dict):
|
|
continue
|
|
result[str(user).strip()] = item
|
|
return result
|
|
|
|
|
|
def default_browser_sip_domain() -> str:
|
|
ws_url = webrtc_ws_url()
|
|
if ws_url:
|
|
parsed = urlparse(ws_url)
|
|
if parsed.hostname:
|
|
return parsed.hostname
|
|
return ami_host()
|
|
|
|
|
|
def browser_softphone_config_for_actor(actor: dict) -> BrowserSoftphoneConfigOut:
|
|
user = str(actor.get("user") or "").strip()
|
|
entry = browser_sip_map().get(user)
|
|
if not entry:
|
|
raise HTTPException(status_code=403, detail="Browser softphone is not configured for this user")
|
|
|
|
operator_extension = (
|
|
str(entry.get("operator_extension") or "").strip()
|
|
or operator_extension_map().get(user, "")
|
|
)
|
|
authorization_username = (
|
|
str(entry.get("authorization_username") or "").strip()
|
|
or str(entry.get("username") or "").strip()
|
|
or operator_extension
|
|
)
|
|
password = str(entry.get("password") or "").strip()
|
|
display_name = str(entry.get("display_name") or actor.get("full_name") or user).strip() or user
|
|
sip_uri = str(entry.get("sip_uri") or "").strip()
|
|
if not sip_uri and authorization_username:
|
|
domain = default_browser_sip_domain()
|
|
if domain:
|
|
sip_uri = f"sip:{authorization_username}@{domain}"
|
|
|
|
return BrowserSoftphoneConfigOut(
|
|
enabled=webrtc_enabled(),
|
|
ws_url=webrtc_ws_url() or None,
|
|
sip_uri=sip_uri or None,
|
|
authorization_username=authorization_username or None,
|
|
password=password or None,
|
|
display_name=display_name or None,
|
|
ice_servers=webrtc_ice_servers(),
|
|
operator_extension=operator_extension or None,
|
|
)
|
|
|
|
|
|
def transfer_target_map() -> dict[str, str]:
|
|
raw = os.getenv("ASTERISK_TRANSFER_TARGET_MAP_JSON", "{}").strip()
|
|
try:
|
|
payload = json.loads(raw or "{}")
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError("Invalid ASTERISK_TRANSFER_TARGET_MAP_JSON") from exc
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("ASTERISK_TRANSFER_TARGET_MAP_JSON must be a JSON object")
|
|
return {str(k).strip(): str(v).strip() for k, v in payload.items() if str(k).strip() and str(v).strip()}
|
|
|
|
|
|
def claim_context() -> str:
|
|
return os.getenv("ASTERISK_CALLCONTROL_CLAIM_CONTEXT", "mvpcc-claim").strip() or "mvpcc-claim"
|
|
|
|
|
|
def transfer_context() -> str:
|
|
return os.getenv("ASTERISK_CALLCONTROL_TRANSFER_CONTEXT", "mvpcc-transfer").strip() or "mvpcc-transfer"
|
|
|
|
|
|
def bridge_auth_mode() -> str:
|
|
mode = os.getenv("ASTERISK_BRIDGE_AUTH_MODE", "legacy_headers").strip().lower()
|
|
if mode in {"legacy_headers", "bearer", "bearer_first"}:
|
|
return mode
|
|
return "legacy_headers"
|
|
|
|
|
|
def bridge_auth_fallback_legacy() -> bool:
|
|
return bool_env("ASTERISK_BRIDGE_AUTH_FALLBACK_LEGACY", True)
|
|
|
|
|
|
def bridge_auth_user() -> str:
|
|
return os.getenv("ASTERISK_BRIDGE_AUTH_USER", "asterisk-bridge").strip() or "asterisk-bridge"
|
|
|
|
|
|
def bridge_auth_role() -> str:
|
|
return os.getenv("ASTERISK_BRIDGE_AUTH_ROLE", "admin").strip().lower() or "admin"
|
|
|
|
|
|
def bridge_auth_subject() -> str:
|
|
return os.getenv("ASTERISK_BRIDGE_AUTH_SUBJECT", "svc:asterisk-bridge").strip() or "svc:asterisk-bridge"
|
|
|
|
|
|
def bridge_auth_token_ttl_seconds() -> int:
|
|
raw = os.getenv("ASTERISK_BRIDGE_AUTH_TOKEN_TTL_SECONDS", "300").strip()
|
|
try:
|
|
value = int(raw)
|
|
except ValueError:
|
|
value = 300
|
|
return max(value, 60)
|
|
|
|
|
|
def ai_voice_runtime_trusted_subjects() -> set[str]:
|
|
raw = os.getenv(
|
|
"AI_VOICE_RUNTIME_TRUSTED_SERVICE_SUBJECTS",
|
|
"svc:ai-voice-runtime",
|
|
)
|
|
return {
|
|
item.strip()
|
|
for item in raw.split(",")
|
|
if item.strip()
|
|
}
|