mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
235 lines
7.7 KiB
Python
235 lines
7.7 KiB
Python
"""
|
|
Telephony helper utilities.
|
|
Common functions used across telephony operations.
|
|
"""
|
|
|
|
from fastapi import Request
|
|
from loguru import logger
|
|
from starlette.responses import HTMLResponse
|
|
|
|
from api.constants import COUNTRY_CODES
|
|
|
|
|
|
def numbers_match(
|
|
incoming_number: str,
|
|
configured_number: str,
|
|
to_country: str = None,
|
|
from_country: str = None,
|
|
) -> bool:
|
|
"""
|
|
Check if two phone numbers match, handling different formats with country context.
|
|
|
|
Args:
|
|
incoming_number: Phone number from webhook
|
|
configured_number: Phone number from organization config
|
|
to_country: ISO country code for the called number (e.g., "US", "IN")
|
|
from_country: ISO country code for the caller (e.g., "IN", "GB")
|
|
|
|
Examples:
|
|
- incoming: "+08043071383", configured: "918043071383", to_country="IN" -> True
|
|
- incoming: "+918043071383", configured: "918043071383" -> True
|
|
- incoming: "+19781899185", configured: "+19781899185" -> True
|
|
"""
|
|
if not incoming_number or not configured_number:
|
|
return False
|
|
|
|
# Remove spaces and normalize
|
|
incoming_clean = incoming_number.replace(" ", "").replace("-", "")
|
|
configured_clean = configured_number.replace(" ", "").replace("-", "")
|
|
|
|
# Direct match
|
|
if incoming_clean == configured_clean:
|
|
return True
|
|
|
|
# Remove + from both and compare
|
|
incoming_no_plus = incoming_clean.lstrip("+")
|
|
configured_no_plus = configured_clean.lstrip("+")
|
|
|
|
if incoming_no_plus == configured_no_plus:
|
|
return True
|
|
|
|
if to_country:
|
|
country_code = get_country_code(to_country)
|
|
if country_code:
|
|
if _test_number_formats_with_country_code(
|
|
incoming_no_plus, configured_no_plus, country_code
|
|
):
|
|
return True
|
|
|
|
# Fallback to caller country if available
|
|
if from_country and from_country != to_country:
|
|
country_code = get_country_code(from_country)
|
|
if country_code:
|
|
if _test_number_formats_with_country_code(
|
|
incoming_no_plus, configured_no_plus, country_code
|
|
):
|
|
return True
|
|
|
|
# Legacy fallback for common country codes (when no country info available)
|
|
if not to_country and not from_country:
|
|
common_codes = ["91", "1", "44"] # India, US/Canada, UK
|
|
for code in common_codes:
|
|
if _test_number_formats_with_country_code(
|
|
incoming_no_plus, configured_no_plus, code
|
|
):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _test_number_formats_with_country_code(
|
|
incoming_no_plus: str, configured_no_plus: str, country_code: str
|
|
) -> bool:
|
|
"""
|
|
Test different phone number format variations with the given country code to find matches.
|
|
|
|
This function handles various international phone number formatting scenarios:
|
|
- Numbers with/without country codes
|
|
- Numbers with leading zeros vs country codes
|
|
- Different representations of the same number across formats
|
|
|
|
Args:
|
|
incoming_no_plus: Incoming number without + prefix
|
|
configured_no_plus: Configured number without + prefix
|
|
country_code: International dialing code (e.g., "91", "1")
|
|
|
|
Returns:
|
|
True if any format variation produces a match
|
|
"""
|
|
# Case 1: Incoming has no country code, configured has it
|
|
if f"{country_code}{incoming_no_plus}" == configured_no_plus:
|
|
return True
|
|
|
|
# Case 2: Incoming has leading 0, need to replace with country code
|
|
if incoming_no_plus.startswith("0"):
|
|
local_part = incoming_no_plus[1:] # Remove leading 0
|
|
if f"{country_code}{local_part}" == configured_no_plus:
|
|
return True
|
|
|
|
# Case 3: Configured has no country code, incoming has it
|
|
if f"{country_code}{configured_no_plus}" == incoming_no_plus:
|
|
return True
|
|
|
|
# Case 4: Configured has leading 0, need to replace with country code
|
|
if configured_no_plus.startswith("0"):
|
|
local_part = configured_no_plus[1:] # Remove leading 0
|
|
if f"{country_code}{local_part}" == incoming_no_plus:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def normalize_phone_number(phone_number: str, country_code: str = None) -> str:
|
|
"""
|
|
Normalize a phone number to E.164 format using country context.
|
|
|
|
Args:
|
|
phone_number: Phone number to normalize
|
|
country_code: ISO country code (e.g., "US", "IN") for context
|
|
|
|
Returns:
|
|
Phone number in E.164 format (e.g., "+14155552671", "+919876543210")
|
|
"""
|
|
if not phone_number:
|
|
return ""
|
|
|
|
# Remove spaces, hyphens, and other formatting
|
|
clean_number = (
|
|
phone_number.replace(" ", "").replace("-", "").replace("(", "").replace(")", "")
|
|
)
|
|
|
|
# Already in E.164 format
|
|
if clean_number.startswith("+"):
|
|
return clean_number
|
|
|
|
# Get dialing code for the country
|
|
if country_code:
|
|
dialing_code = get_country_code(country_code)
|
|
if dialing_code:
|
|
# Remove leading 0 if present (common in many countries)
|
|
if clean_number.startswith("0"):
|
|
clean_number = clean_number[1:]
|
|
|
|
# Add country code if not already present
|
|
if not clean_number.startswith(dialing_code):
|
|
return f"+{dialing_code}{clean_number}"
|
|
else:
|
|
return f"+{clean_number}"
|
|
|
|
# Fallback: try to guess common formats
|
|
if clean_number.startswith("0") and len(clean_number) == 11:
|
|
# Without country context, prefer India for now
|
|
return f"+91{clean_number[1:]}"
|
|
elif len(clean_number) == 10:
|
|
# Without context, this is ambiguous - return as-is with + prefix
|
|
return f"+{clean_number}"
|
|
elif not clean_number.startswith("+"):
|
|
# Add + prefix if missing
|
|
return f"+{clean_number}"
|
|
|
|
return clean_number
|
|
|
|
|
|
def normalize_webhook_data(provider_class, webhook_data):
|
|
"""Normalize webhook data using the provider's parse method"""
|
|
return provider_class.parse_inbound_webhook(webhook_data)
|
|
|
|
|
|
def generic_hangup_response():
|
|
"""Return a generic hangup response for unknown/error cases"""
|
|
return HTMLResponse(
|
|
content="<Response><Hangup/></Response>", media_type="application/xml"
|
|
)
|
|
|
|
|
|
async def parse_webhook_request(request: Request) -> tuple[dict, str]:
|
|
"""Parse webhook request data from either JSON or form.
|
|
|
|
Returns ``(webhook_data, raw_body)`` where ``raw_body`` is the
|
|
request body decoded as UTF-8 — kept around for providers (e.g.
|
|
Vobiz) whose signature is computed over the raw bytes.
|
|
"""
|
|
raw_body = (await request.body()).decode("utf-8", errors="replace")
|
|
try:
|
|
webhook_data = await request.json()
|
|
except Exception:
|
|
try:
|
|
form_data = await request.form()
|
|
webhook_data = dict(form_data)
|
|
except Exception as e:
|
|
logger.error(f"Failed to parse webhook data: {e}")
|
|
raise ValueError("Unable to parse webhook data")
|
|
|
|
return webhook_data, raw_body
|
|
|
|
|
|
def get_country_code(country_iso: str) -> str:
|
|
"""
|
|
Get the international dialing code for a country.
|
|
|
|
Args:
|
|
country_iso: ISO 3166-1 alpha-2 country code (e.g., "US", "IN", "GB")
|
|
|
|
Returns:
|
|
International dialing code (e.g., "1", "91", "44") or empty string if not found
|
|
"""
|
|
if not country_iso:
|
|
return ""
|
|
|
|
return COUNTRY_CODES.get(country_iso.upper(), "")
|
|
|
|
|
|
def get_countries_for_code(dialing_code: str) -> list[str]:
|
|
"""
|
|
Get all countries that use a specific dialing code.
|
|
|
|
Args:
|
|
dialing_code: International dialing code (e.g., "1", "91")
|
|
|
|
Returns:
|
|
List of ISO country codes that use this dialing code
|
|
"""
|
|
if not dialing_code:
|
|
return []
|
|
|
|
return [country for country, code in COUNTRY_CODES.items() if code == dialing_code]
|