mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-16 08:25:18 +02:00
Feat/inbound telephony (#113)
* feat: inbound telephony (twilio & vobiz) * chore: add ruff and lint formatting * fix: add missing cloudonix interface compliance implementation
This commit is contained in:
parent
b79bc4221d
commit
97fbd9b37b
22 changed files with 1998 additions and 40 deletions
|
|
@ -26,6 +26,22 @@ class CallInitiationResult:
|
|||
) # Full provider response for debugging
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedInboundData:
|
||||
"""Standardized inbound call data across all providers."""
|
||||
|
||||
provider: str # Provider name (twilio, vobiz, etc.)
|
||||
call_id: str # Provider's call identifier
|
||||
from_number: str # Caller phone number (E.164 format)
|
||||
to_number: str # Called phone number (E.164 format)
|
||||
direction: str # Call direction (should be "inbound")
|
||||
call_status: str # Call status (ringing, answered, etc.)
|
||||
account_id: Optional[str] = None # Provider account ID
|
||||
from_country: Optional[str] = None # Country code of caller
|
||||
to_country: Optional[str] = None # Country code of called number
|
||||
raw_data: Dict[str, Any] = field(default_factory=dict) # Original webhook data
|
||||
|
||||
|
||||
class TelephonyProvider(ABC):
|
||||
"""
|
||||
Abstract base class for telephony providers.
|
||||
|
|
@ -181,3 +197,109 @@ class TelephonyProvider(ABC):
|
|||
workflow_run_id: The workflow run ID
|
||||
"""
|
||||
pass
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
|
||||
Args:
|
||||
webhook_data: The parsed webhook payload
|
||||
headers: HTTP headers from the webhook request
|
||||
|
||||
Returns:
|
||||
True if this provider should handle this webhook, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse provider-specific inbound webhook data into normalized format.
|
||||
|
||||
Args:
|
||||
webhook_data: Raw webhook data from the provider
|
||||
|
||||
Returns:
|
||||
NormalizedInboundData with standardized fields
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""
|
||||
Validate that the account_id from webhook matches the provider configuration.
|
||||
|
||||
Args:
|
||||
config_data: Provider configuration data from organization
|
||||
webhook_account_id: Account ID from the webhook
|
||||
|
||||
Returns:
|
||||
True if account_id matches, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def normalize_phone_number(self, phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for this provider.
|
||||
|
||||
Args:
|
||||
phone_number: Raw phone number from webhook
|
||||
|
||||
Returns:
|
||||
Phone number in E.164 format (+country_code_number)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound webhook for security.
|
||||
|
||||
Args:
|
||||
url: The full webhook URL
|
||||
webhook_data: The webhook payload
|
||||
signature: The signature header from the provider
|
||||
|
||||
Returns:
|
||||
True if signature is valid, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def generate_inbound_response(self, websocket_url: str) -> tuple:
|
||||
"""
|
||||
Generate the appropriate response for an inbound webhook.
|
||||
|
||||
Args:
|
||||
websocket_url: WebSocket URL for audio streaming
|
||||
|
||||
Returns:
|
||||
Tuple of (Response, media_type) - Response object and content type
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a provider-specific error response.
|
||||
|
||||
Args:
|
||||
error_type: Type of error (auth_failed, not_configured, etc.)
|
||||
message: Error message
|
||||
|
||||
Returns:
|
||||
Tuple of (Response, media_type) - Response object and content type
|
||||
"""
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ Handles configuration loading from environment (OSS) or database (SaaS).
|
|||
The providers themselves don't know or care where config comes from.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List, Type
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
|
@ -116,3 +116,13 @@ async def get_telephony_provider(organization_id: int) -> TelephonyProvider:
|
|||
|
||||
else:
|
||||
raise ValueError(f"Unknown telephony provider: {provider_type}")
|
||||
|
||||
|
||||
async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]:
|
||||
"""
|
||||
Get all available telephony provider classes for webhook detection.
|
||||
|
||||
Returns:
|
||||
List of provider classes that can be used for webhook detection
|
||||
"""
|
||||
return [TwilioProvider, VobizProvider, VonageProvider]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import aiohttp
|
|||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -416,3 +420,170 @@ class CloudonixProvider(TelephonyProvider):
|
|||
except Exception as e:
|
||||
logger.error(f"Error in Cloudonix WebSocket handler: {e}")
|
||||
raise
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
|
||||
Cloudonix uses TwiML-compatible format, so look for Twilio-like identifiers
|
||||
but also check for Cloudonix-specific headers or fields if they exist.
|
||||
"""
|
||||
# Check for Cloudonix-specific headers
|
||||
if headers.get("User-Agent", "").lower().startswith("cloudonix"):
|
||||
return True
|
||||
|
||||
# Check for session token (Cloudonix equivalent of CallSid)
|
||||
if "token" in webhook_data or "session_token" in webhook_data:
|
||||
return True
|
||||
|
||||
# If it looks like TwiML format but no other providers claimed it,
|
||||
# it could be Cloudonix (TwiML-compatible)
|
||||
if "CallSid" in webhook_data and "AccountSid" in webhook_data:
|
||||
# Let Twilio provider handle this first, only handle if unclaimed
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Cloudonix-specific inbound webhook data into normalized format.
|
||||
|
||||
Cloudonix is TwiML-compatible so the webhook format should be similar to Twilio.
|
||||
"""
|
||||
return NormalizedInboundData(
|
||||
provider=CloudonixProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("token") or webhook_data.get("CallSid", ""),
|
||||
from_number=webhook_data.get("From", ""),
|
||||
to_number=webhook_data.get("To", ""),
|
||||
direction="inbound", # This is an inbound webhook
|
||||
call_status=webhook_data.get("CallStatus", "ringing"),
|
||||
account_id=webhook_data.get("AccountSid") or webhook_data.get("domain_id"),
|
||||
from_country=webhook_data.get("FromCountry"),
|
||||
to_country=webhook_data.get("ToCountry"),
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""
|
||||
Validate that the account_id from webhook matches the Cloudonix configuration.
|
||||
"""
|
||||
if not webhook_account_id:
|
||||
return False
|
||||
|
||||
# Cloudonix uses domain_id as the account identifier
|
||||
stored_domain_id = config_data.get("domain_id")
|
||||
if not stored_domain_id:
|
||||
return False
|
||||
|
||||
return webhook_account_id == stored_domain_id
|
||||
|
||||
def normalize_phone_number(self, phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for Cloudonix.
|
||||
|
||||
Cloudonix typically provides numbers in E.164 format already,
|
||||
but we'll ensure proper formatting.
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
# Remove any spaces or formatting
|
||||
clean_number = (
|
||||
phone_number.replace(" ", "")
|
||||
.replace("-", "")
|
||||
.replace("(", "")
|
||||
.replace(")", "")
|
||||
)
|
||||
|
||||
# If already in E.164 format (+...), return as-is
|
||||
if clean_number.startswith("+"):
|
||||
return clean_number
|
||||
|
||||
# If starts with country code but no +, add it
|
||||
if len(clean_number) >= 10:
|
||||
return f"+{clean_number}"
|
||||
|
||||
return clean_number
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Cloudonix webhook for security.
|
||||
|
||||
Note: Cloudonix signature verification details need to be implemented
|
||||
based on their specific authentication method. For now, we'll log
|
||||
and return True (similar to current webhook verification).
|
||||
"""
|
||||
logger.info(
|
||||
f"Cloudonix inbound signature verification not fully implemented. "
|
||||
f"Webhook URL: {url}, Signature present: {bool(signature)}"
|
||||
)
|
||||
|
||||
# TODO: Implement actual Cloudonix signature verification
|
||||
# This would depend on Cloudonix's specific signing method
|
||||
return True
|
||||
|
||||
def generate_inbound_response(self, websocket_url: str) -> tuple:
|
||||
"""
|
||||
Generate the appropriate TwiML response for an inbound Cloudonix webhook.
|
||||
|
||||
Since Cloudonix is TwiML-compatible, we generate TwiML response.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
# Generate TwiML response to connect to WebSocket
|
||||
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="{websocket_url}"></Stream>
|
||||
</Connect>
|
||||
<Pause length="40"/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml, media_type="application/xml"), "application/xml"
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a Cloudonix-specific error response.
|
||||
|
||||
Since Cloudonix is TwiML-compatible, we use TwiML format.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
# Map error types to appropriate TwiML responses
|
||||
if error_type == "auth_failed":
|
||||
twiml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>Authentication failed. This call cannot be processed.</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
elif error_type == "not_configured":
|
||||
twiml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>Service not configured. Please contact support.</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
elif error_type == "invalid_number":
|
||||
twiml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>Invalid phone number. This call cannot be processed.</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
else:
|
||||
# Generic error
|
||||
twiml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say>An error occurred: {message}</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml, media_type="application/xml"), "application/xml"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ from loguru import logger
|
|||
from twilio.request_validator import RequestValidator
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -284,3 +288,139 @@ class TwilioProvider(TelephonyProvider):
|
|||
except Exception as e:
|
||||
logger.error(f"Error in Twilio WebSocket handler: {e}")
|
||||
raise
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
Twilio webhooks contain CallSid field.
|
||||
"""
|
||||
return "CallSid" in webhook_data
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Twilio-specific inbound webhook data into normalized format.
|
||||
"""
|
||||
return NormalizedInboundData(
|
||||
provider=TwilioProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("CallSid", ""),
|
||||
from_number=TwilioProvider.normalize_phone_number(
|
||||
webhook_data.get("From", "")
|
||||
),
|
||||
to_number=TwilioProvider.normalize_phone_number(webhook_data.get("To", "")),
|
||||
direction=webhook_data.get("Direction", ""),
|
||||
call_status=webhook_data.get("CallStatus", ""),
|
||||
account_id=webhook_data.get("AccountSid"),
|
||||
from_country=webhook_data.get("FromCountry")
|
||||
or webhook_data.get("CallerCountry"),
|
||||
to_country=webhook_data.get("ToCountry")
|
||||
or webhook_data.get("CalledCountry"),
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_phone_number(phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for Twilio.
|
||||
Twilio already provides numbers in E.164 format.
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
# Twilio numbers are already in E.164 format (+1234567890)
|
||||
if phone_number.startswith("+"):
|
||||
return phone_number
|
||||
|
||||
# If for some reason it doesn't have +, assume US and add +1
|
||||
if phone_number.startswith("1") and len(phone_number) == 11:
|
||||
return f"+{phone_number}"
|
||||
elif len(phone_number) == 10:
|
||||
return f"+1{phone_number}"
|
||||
|
||||
return phone_number
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""Validate Twilio account_sid from webhook matches configuration"""
|
||||
if not webhook_account_id:
|
||||
return False
|
||||
|
||||
stored_account_sid = config_data.get("account_sid")
|
||||
return stored_account_sid == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Twilio webhook for security.
|
||||
"""
|
||||
return await self.verify_webhook_signature(url, webhook_data, signature)
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
"""
|
||||
Generate TwiML response for an inbound Twilio webhook.
|
||||
|
||||
Uses the same StatusCallback URL pattern as outbound calls for consistency.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
# Generate StatusCallback URL using same pattern as outbound calls
|
||||
status_callback_attr = ""
|
||||
if workflow_run_id:
|
||||
backend_endpoint = await TunnelURLProvider.get_tunnel_url()
|
||||
status_callback_url = f"https://{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
|
||||
status_callback_attr = f' statusCallback="{status_callback_url}"'
|
||||
|
||||
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Connect>
|
||||
<Stream url="{websocket_url}"{status_callback_attr}></Stream>
|
||||
</Connect>
|
||||
<Pause length="40"/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml_content, media_type="application/xml")
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a Twilio-specific error response.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">Sorry, there was an error processing your call. {message}</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml_content, media_type="application/xml")
|
||||
|
||||
@staticmethod
|
||||
def generate_validation_error_response(error_type) -> tuple:
|
||||
"""
|
||||
Generate Twilio-specific error response for validation failures with organizational debugging info.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
from api.errors.telephony_errors import TELEPHONY_ERROR_MESSAGES, TelephonyError
|
||||
|
||||
message = TELEPHONY_ERROR_MESSAGES.get(
|
||||
error_type, TELEPHONY_ERROR_MESSAGES[TelephonyError.GENERAL_AUTH_FAILED]
|
||||
)
|
||||
|
||||
twiml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">{message}</Say>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=twiml_content, media_type="application/xml")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@ import aiohttp
|
|||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -179,20 +183,65 @@ class VobizProvider(TelephonyProvider):
|
|||
return bool(self.auth_id and self.auth_token and self.from_numbers)
|
||||
|
||||
async def verify_webhook_signature(
|
||||
self, url: str, params: Dict[str, Any], signature: str
|
||||
self,
|
||||
url: str,
|
||||
params: Dict[str, Any],
|
||||
signature: str,
|
||||
timestamp: str = None,
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify Vobiz webhook signature for security.
|
||||
|
||||
Vobiz uses Plivo-compatible signature verification (HMAC-SHA256).
|
||||
For now, returning True to allow testing.
|
||||
TODO: Implement proper signature verification based on Vobiz docs.
|
||||
Vobiz uses HMAC-SHA256 signature verification with timestamp validation:
|
||||
- Header: x-vobiz-signature (HMAC-SHA256 hash)
|
||||
- Header: x-vobiz-timestamp (timestamp for replay protection)
|
||||
- Signature = HMAC-SHA256(auth_token, timestamp + '.' + body)
|
||||
"""
|
||||
# Plivo/Vobiz signature verification would go here
|
||||
# For development, we can skip signature verification
|
||||
# In production, implement HMAC-SHA256 verification
|
||||
logger.warning("Vobiz webhook signature verification not yet implemented")
|
||||
return True
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timezone
|
||||
|
||||
if not signature or not timestamp:
|
||||
logger.warning("Missing signature or timestamp headers for Vobiz webhook")
|
||||
return False
|
||||
|
||||
if not self.auth_token:
|
||||
logger.error(
|
||||
"No auth_token available for Vobiz webhook signature verification"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
# 1. Timestamp validation (within 5 minutes)
|
||||
webhook_timestamp = int(timestamp)
|
||||
current_timestamp = int(datetime.now(timezone.utc).timestamp())
|
||||
time_diff = abs(current_timestamp - webhook_timestamp)
|
||||
|
||||
if time_diff > 300: # 5 minutes = 300 seconds
|
||||
logger.warning(f"Vobiz webhook timestamp too old: {time_diff}s > 300s")
|
||||
return False
|
||||
|
||||
# 2. Signature verification
|
||||
# Create expected signature: HMAC-SHA256(auth_token, timestamp + '.' + body)
|
||||
payload = f"{timestamp}.{body}"
|
||||
expected_signature = hmac.new(
|
||||
self.auth_token.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# 3. Compare signatures (timing-safe comparison)
|
||||
is_valid = hmac.compare_digest(expected_signature, signature)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"Vobiz webhook signature mismatch. Expected: {expected_signature[:8]}..., Got: {signature[:8]}..."
|
||||
)
|
||||
|
||||
return is_valid
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying Vobiz webhook signature: {e}")
|
||||
return False
|
||||
|
||||
async def get_webhook_response(
|
||||
self, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
|
|
@ -339,3 +388,140 @@ class VobizProvider(TelephonyProvider):
|
|||
f"[run {workflow_run_id}] Error in Vobiz WebSocket handler: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
Vobiz webhooks contain CallUUID field.
|
||||
"""
|
||||
return "CallUUID" in webhook_data
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Vobiz-specific inbound webhook data into normalized format.
|
||||
"""
|
||||
return NormalizedInboundData(
|
||||
provider=VobizProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("CallUUID", ""),
|
||||
from_number=VobizProvider.normalize_phone_number(
|
||||
webhook_data.get("From", "")
|
||||
),
|
||||
to_number=VobizProvider.normalize_phone_number(webhook_data.get("To", "")),
|
||||
direction=webhook_data.get("Direction", ""),
|
||||
call_status=webhook_data.get("CallStatus", ""),
|
||||
account_id=webhook_data.get("ParentAuthID"),
|
||||
from_country=None, # Vobiz doesn't provide country information
|
||||
to_country=None, # Vobiz doesn't provide country information
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_phone_number(phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for Vobiz.
|
||||
Vobiz sends numbers in various formats - normalize to E.164 with +.
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
# Remove any existing + prefix
|
||||
clean_number = phone_number.lstrip("+")
|
||||
|
||||
# If it starts with 1 and has 11 digits, it's a US number
|
||||
if clean_number.startswith("1") and len(clean_number) == 11:
|
||||
return f"+{clean_number}"
|
||||
elif len(clean_number) == 10:
|
||||
# Assume US number if 10 digits
|
||||
return f"+1{clean_number}"
|
||||
elif len(clean_number) > 10:
|
||||
# International number without country code detection
|
||||
return f"+{clean_number}"
|
||||
|
||||
return phone_number
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""Validate Vobiz auth_id from webhook matches configuration"""
|
||||
if not webhook_account_id:
|
||||
return False
|
||||
|
||||
stored_auth_id = config_data.get("auth_id")
|
||||
return stored_auth_id == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
signature: str,
|
||||
timestamp: str = None,
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Vobiz webhook for security.
|
||||
Uses the same HMAC-SHA256 verification as other Vobiz webhooks.
|
||||
"""
|
||||
return await self.verify_webhook_signature(
|
||||
url, webhook_data, signature, timestamp, body
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
"""
|
||||
Generate Vobiz XML response for an inbound webhook.
|
||||
|
||||
Note: For hangup callbacks, configure the hangup_url manually in Vobiz dashboard
|
||||
to point to: /api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
vobiz_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Stream bidirectional="true" keepCallAlive="true" contentType="audio/x-mulaw;rate=8000">{websocket_url}</Stream>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=vobiz_xml, media_type="application/xml")
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a Vobiz-specific error response.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
# Vobiz error responses should be valid XML like Plivo
|
||||
vobiz_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Speak voice="WOMAN">Sorry, there was an error processing your call. {message}</Speak>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=vobiz_xml, media_type="application/xml")
|
||||
|
||||
@staticmethod
|
||||
def generate_validation_error_response(error_type) -> tuple:
|
||||
"""
|
||||
Generate Vobiz-specific error response for validation failures with organizational debugging info.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
from api.errors.telephony_errors import TELEPHONY_ERROR_MESSAGES, TelephonyError
|
||||
|
||||
message = TELEPHONY_ERROR_MESSAGES.get(
|
||||
error_type, TELEPHONY_ERROR_MESSAGES[TelephonyError.GENERAL_AUTH_FAILED]
|
||||
)
|
||||
|
||||
vobiz_xml_content = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Speak voice="WOMAN">{message}</Speak>
|
||||
<Hangup/>
|
||||
</Response>"""
|
||||
|
||||
return Response(content=vobiz_xml_content, media_type="application/xml")
|
||||
|
|
|
|||
|
|
@ -9,10 +9,15 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|||
|
||||
import aiohttp
|
||||
import jwt
|
||||
from fastapi import Response
|
||||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony.base import CallInitiationResult, TelephonyProvider
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.tunnel import TunnelURLProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -378,3 +383,99 @@ class VonageProvider(TelephonyProvider):
|
|||
except Exception as e:
|
||||
logger.error(f"Error in Vonage WebSocket handler: {e}")
|
||||
raise
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
||||
@classmethod
|
||||
def can_handle_webhook(
|
||||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
"""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Vonage-specific inbound webhook data into normalized format.
|
||||
"""
|
||||
return NormalizedInboundData(
|
||||
provider=VonageProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("uuid", ""),
|
||||
from_number=webhook_data.get("from", ""),
|
||||
to_number=webhook_data.get("to", ""),
|
||||
direction=webhook_data.get("direction", ""),
|
||||
call_status=webhook_data.get("status", ""),
|
||||
account_id=webhook_data.get("account_id"),
|
||||
from_country=None,
|
||||
to_country=None,
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def normalize_phone_number(phone_number: str) -> str:
|
||||
"""
|
||||
Normalize a phone number to E.164 format for Vonage.
|
||||
"""
|
||||
if not phone_number:
|
||||
return ""
|
||||
|
||||
if phone_number.startswith("+"):
|
||||
return phone_number
|
||||
|
||||
return f"+{phone_number}"
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""Validate Vonage account_id from webhook matches configuration"""
|
||||
if not webhook_account_id:
|
||||
return False
|
||||
|
||||
stored_api_key = config_data.get("api_key")
|
||||
return stored_api_key == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
) -> bool:
|
||||
"""
|
||||
Vonage inbound signature verification - minimalist implementation.
|
||||
"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
"""
|
||||
Generate NCCO response for inbound Vonage webhook.
|
||||
"""
|
||||
# Minimalist NCCO response for interface compliance
|
||||
ncco_response = [
|
||||
{
|
||||
"action": "talk",
|
||||
"text": "Vonage inbound calls are not currently supported.",
|
||||
},
|
||||
{"action": "hangup"},
|
||||
]
|
||||
|
||||
return Response(
|
||||
content=json.dumps(ncco_response), media_type="application/json"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
"""
|
||||
Generate a Vonage-specific error response.
|
||||
"""
|
||||
from fastapi import Response
|
||||
|
||||
error_ncco = [
|
||||
{
|
||||
"action": "talk",
|
||||
"text": f"Sorry, there was an error processing your call. {message}",
|
||||
},
|
||||
{"action": "hangup"},
|
||||
]
|
||||
|
||||
return Response(content=json.dumps(error_ncco), media_type="application/json")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue