import ipaddress import socket from urllib.parse import urlparse, urlunparse import frappe import requests from frappe import _ from frappe.query_builder import Order from pypika.functions import Replace from werkzeug.wrappers import Response from crm.utils import are_same_phone_number, parse_phone_number def _get_recording_credentials(telephony_medium: str) -> tuple | None: """Return (api_key, secret) for the given telephony medium, or None when the recording needs no auth. A manual/unrecognized medium (a recording added by hand) is fetched as-is, and a provider whose credentials aren't configured yet falls back to no auth rather than raising — so the proxy attempts the fetch and lets the provider decide, instead of 500-ing before the request is even made. """ if telephony_medium == "Twilio": s = frappe.get_single("CRM Twilio Settings") secret = s.get_password("api_secret", raise_exception=False) return (s.api_key, secret) if s.api_key and secret else None elif telephony_medium == "Exotel": s = frappe.get_single("CRM Exotel Settings") token = s.get_password("api_token", raise_exception=False) return (s.api_key, token) if s.api_key and token else None # manual or unrecognized medium: no provider auth to apply return None @frappe.whitelist() def is_call_integration_enabled(): return { "integrations": { "twilio": bool(frappe.db.get_single_value("CRM Twilio Settings", "enabled")), "exotel": bool(frappe.db.get_single_value("CRM Exotel Settings", "enabled")), }, "default_calling_medium": get_user_default_calling_medium(), } def get_user_default_calling_medium(): if not frappe.db.exists("CRM Telephony Agent", frappe.session.user): return None default_medium = frappe.db.get_value("CRM Telephony Agent", frappe.session.user, "default_medium") if not default_medium: return None return default_medium @frappe.whitelist() def set_default_calling_medium(medium: str): if not frappe.db.exists("CRM Telephony Agent", frappe.session.user): frappe.get_doc( { "doctype": "CRM Telephony Agent", "user": frappe.session.user, "default_medium": medium, } ).insert(ignore_permissions=True) else: frappe.db.set_value("CRM Telephony Agent", frappe.session.user, "default_medium", medium) return get_user_default_calling_medium() @frappe.whitelist() def add_note_to_call_log(call_sid: str, note: dict): """Add/Update note to call log based on call sid.""" if not frappe.has_permission("CRM Call Log", "write", call_sid): frappe.throw(_("Not permitted"), frappe.PermissionError) _note = None if not note.get("name"): _note = frappe.get_doc( { "doctype": "FCRM Note", "title": note.get("title", "Call Note"), "content": note.get("content"), } ).insert(ignore_permissions=True) else: _note = frappe.set_value("FCRM Note", note.get("name"), "content", note.get("content")) call_log = frappe.get_cached_doc("CRM Call Log", call_sid) call_log.link_with_reference_doc("FCRM Note", _note.name) call_log.save(ignore_permissions=True) return _note @frappe.whitelist() def add_task_to_call_log(call_sid: str, task: dict): """Add/Update task to call log based on call sid.""" if not frappe.has_permission("CRM Call Log", "write", call_sid): frappe.throw(_("Not permitted"), frappe.PermissionError) _task = None if not task.get("name"): _task = frappe.get_doc( { "doctype": "CRM Task", "title": task.get("title"), "description": task.get("description"), "assigned_to": task.get("assigned_to"), "due_date": task.get("due_date"), "status": task.get("status"), "priority": task.get("priority"), } ).insert(ignore_permissions=True) else: _task = frappe.get_doc("CRM Task", task.get("name")) _task.update( { "title": task.get("title"), "description": task.get("description"), "assigned_to": task.get("assigned_to"), "due_date": task.get("due_date"), "status": task.get("status"), "priority": task.get("priority"), } ) _task.save(ignore_permissions=True) call_log = frappe.get_doc("CRM Call Log", call_sid) call_log.link_with_reference_doc("CRM Task", _task.name) call_log.save(ignore_permissions=True) return _task @frappe.whitelist() def get_contact_lead_or_deal_from_number(number: str): """Get contact, lead or deal from the given number.""" contact = get_contact_by_phone_number(number) if contact.get("name"): doctype = "Contact" docname = contact.get("name") if contact.get("lead"): doctype = "CRM Lead" docname = contact.get("lead") elif contact.get("deal"): doctype = "CRM Deal" docname = contact.get("deal") return docname, doctype return None, None @frappe.whitelist() def get_contact_by_phone_number(phone_number: str): """Get contact by phone number.""" number = parse_phone_number(phone_number) if number.get("is_valid"): return get_contact(number.get("national_number"), number.get("country")) else: return get_contact(phone_number, number.get("country"), exact_match=True) def _resolve_validated_ip(hostname: str, port: int) -> str: # Refuse any host that resolves to a non-public address (cloud metadata, localhost, # private/link-local ranges) and return a single validated IP to connect to. Returning # the exact resolved IP — rather than re-resolving at connect time — is what closes the # DNS-rebinding TOCTOU window. try: addrinfos = socket.getaddrinfo(hostname, port, proto=socket.IPPROTO_TCP) except socket.gaierror: frappe.throw(_("Invalid recording URL"), frappe.ValidationError) ips = [info[4][0] for info in addrinfos] if not ips: frappe.throw(_("Invalid recording URL"), frappe.ValidationError) for ip in ips: if not ipaddress.ip_address(ip).is_global: frappe.throw(_("Recording URL is not allowed"), frappe.ValidationError) return ips[0] class _PinnedIPAdapter(requests.adapters.HTTPAdapter): # Connect to a pre-validated IP while keeping the original hostname for the Host header, # TLS SNI and certificate verification. The socket therefore reaches the exact IP that was # checked, so DNS can't be rebound to an internal address between check and connect. def __init__(self, pinned_ip: str, hostname: str, **kwargs): self._pinned_ip = pinned_ip self._hostname = hostname super().__init__(**kwargs) def send(self, request, **kwargs): parsed = urlparse(request.url) literal_ip = ( f"[{self._pinned_ip}]" if ipaddress.ip_address(self._pinned_ip).version == 6 else self._pinned_ip ) netloc = f"{literal_ip}:{parsed.port}" if parsed.port else literal_ip request.url = urlunparse(parsed._replace(netloc=netloc)) host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname request.headers["Host"] = f"{host}:{parsed.port}" if parsed.port else host if parsed.scheme == "https": self.poolmanager.connection_pool_kw["server_hostname"] = self._hostname self.poolmanager.connection_pool_kw["assert_hostname"] = self._hostname return super().send(request, **kwargs) def _safe_get(url: str, auth, headers: dict): # TODO: this SSRF-safe fetch (host validation + IP pinning) will likely need to be shared # with domain enrichment; until that feature ships, this local helper is the interim workaround. parsed = urlparse(url) if parsed.scheme not in ("http", "https") or not parsed.hostname: frappe.throw(_("Invalid recording URL"), frappe.ValidationError) port = parsed.port or (443 if parsed.scheme == "https" else 80) pinned_ip = _resolve_validated_ip(parsed.hostname, port) session = requests.Session() session.mount(f"{parsed.scheme}://", _PinnedIPAdapter(pinned_ip, parsed.hostname)) resp = session.get(url, auth=auth, headers=headers, stream=True, timeout=30, allow_redirects=False) return resp, session def _fetch_recording(url: str, auth, headers: dict): # Follow redirects manually so every hop is validated and IP-pinned: a provider URL can # 302 to a signed CDN URL (legitimate), but without per-hop checks a redirect to an # internal address would bypass validation. Provider credentials are dropped after the # first hop so they aren't leaked to the redirect target. current_url = url current_auth = auth for _hop in range(5): resp, session = _safe_get(current_url, current_auth, headers) if resp.is_redirect and resp.headers.get("Location"): current_url = requests.compat.urljoin(current_url, resp.headers["Location"]) current_auth = None resp.close() session.close() continue resp._pinned_session = session return resp frappe.throw(_("Too many redirects while fetching recording"), frappe.ValidationError) @frappe.whitelist() def get_recording_url(call_log_name: str): """Proxy a call recording (authenticating with the provider) so it plays in the browser. Forwards the browser's Range request to the provider and passes the response back with Accept-Ranges/Content-Length set. Without range support the HTML