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() }