1010from twilio .request_validator import RequestValidator
1111from twilio .twiml .voice_response import VoiceResponse , Connect
1212
13+ from .utils import normalize_websocket_url
14+
1315logger = logging .getLogger (__name__ )
1416
1517
@@ -18,11 +20,13 @@ def create_media_stream_twiml(websocket_url: str) -> str:
1820 Create TwiML that starts a media stream to the given websocket URL.
1921
2022 Args:
21- websocket_url: The websocket URL to stream audio to (e.g. wss://example.com/media/123)
23+ websocket_url: The websocket URL to stream audio to (e.g. wss://example.com/media/123).
24+ https:// URLs are automatically converted to wss://.
2225
2326 Returns:
2427 TwiML string.
2528 """
29+ websocket_url = normalize_websocket_url (websocket_url )
2630 response = VoiceResponse ()
2731 connect = Connect ()
2832 connect .stream (url = websocket_url )
@@ -40,19 +44,21 @@ def create_media_stream_response(websocket_url: str) -> Response:
4044 Returns:
4145 FastAPI Response with TwiML content.
4246 """
43- return Response (content = create_media_stream_twiml (websocket_url ), media_type = "text/xml" )
47+ return Response (
48+ content = create_media_stream_twiml (websocket_url ), media_type = "text/xml"
49+ )
4450
4551
4652class TwilioSignatureVerifier :
4753 """
4854 Verifies Twilio webhook signatures to ensure requests are authentic.
49-
55+
5056 Uses the TWILIO_AUTH_TOKEN environment variable to validate
5157 the X-Twilio-Signature header against the request URL and body.
52-
58+
5359 Example:
5460 verifier = TwilioSignatureVerifier()
55-
61+
5662 @app.post("/twilio/voice")
5763 async def webhook(
5864 request: Request,
@@ -62,17 +68,17 @@ async def webhook(
6268 # Request is verified, safe to process
6369 ...
6470 """
65-
71+
6672 def __init__ (self , auth_token : Optional [str ] = None ):
6773 """
6874 Initialize the verifier.
69-
75+
7076 Args:
7177 auth_token: Twilio Auth Token. If not provided, reads from
7278 TWILIO_AUTH_TOKEN environment variable.
7379 """
7480 self ._auth_token = auth_token
75-
81+
7682 @property
7783 def auth_token (self ) -> str :
7884 """Get the auth token, falling back to environment variable."""
@@ -82,34 +88,34 @@ def auth_token(self) -> str:
8288 "TWILIO_AUTH_TOKEN environment variable is required for signature verification"
8389 )
8490 return token
85-
91+
8692 async def __call__ (self , request : Request ) -> None :
8793 """
8894 FastAPI dependency that verifies Twilio signature.
89-
95+
9096 Raises:
9197 HTTPException: If signature is missing or invalid.
9298 """
9399 from fastapi import HTTPException
94-
100+
95101 signature = request .headers .get ("X-Twilio-Signature" )
96102 if not signature :
97103 logger .warning ("Missing X-Twilio-Signature header" )
98104 raise HTTPException (status_code = 403 , detail = "Missing Twilio signature" )
99-
105+
100106 # Get the full URL (Twilio uses the URL for signature validation)
101107 url = str (request .url )
102-
108+
103109 # Get form data as dict
104110 form = await request .form ()
105111 params = {key : value for key , value in form .items ()}
106-
112+
107113 # Validate the signature
108114 validator = RequestValidator (self .auth_token )
109115 if not validator .validate (url , params , signature ):
110116 logger .warning (f"Invalid Twilio signature for { url } " )
111117 raise HTTPException (status_code = 403 , detail = "Invalid Twilio signature" )
112-
118+
113119 logger .debug (f"Twilio signature verified for { url } " )
114120
115121
@@ -120,28 +126,39 @@ async def __call__(self, request: Request) -> None:
120126class CallWebhookInput (BaseModel ):
121127 """
122128 Twilio voice webhook form data.
123-
129+
124130 This model represents the form data sent by Twilio when a call is received.
125131 Use with FastAPI's Form() for automatic parsing.
126-
132+
127133 Example:
128134 @app.post("/twilio/voice")
129135 async def webhook(data: CallWebhookInput = Depends(CallWebhookInput.as_form)):
130136 print(f"Call from {data.caller} to {data.to}")
131137 """
132-
138+
133139 # Call identification
134- call_sid : str = Field (alias = "CallSid" , description = "Unique identifier for this call" )
140+ call_sid : str = Field (
141+ alias = "CallSid" , description = "Unique identifier for this call"
142+ )
135143 account_sid : str = Field (alias = "AccountSid" , description = "Twilio account SID" )
136144 api_version : str = Field (alias = "ApiVersion" , default = "2010-04-01" )
137-
145+
138146 # Call status
139- call_status : str = Field (alias = "CallStatus" , description = "Current call status (ringing, in-progress, etc.)" )
140- direction : str = Field (alias = "Direction" , description = "Call direction (inbound or outbound)" )
141-
147+ call_status : str = Field (
148+ alias = "CallStatus" ,
149+ description = "Current call status (ringing, in-progress, etc.)" ,
150+ )
151+ direction : str = Field (
152+ alias = "Direction" , description = "Call direction (inbound or outbound)"
153+ )
154+
142155 # From (caller) information
143- from_number : str = Field (alias = "From" , description = "Caller's phone number (E.164 format)" )
144- caller : str = Field (alias = "Caller" , description = "Caller's phone number (same as From)" )
156+ from_number : str = Field (
157+ alias = "From" , description = "Caller's phone number (E.164 format)"
158+ )
159+ caller : str = Field (
160+ alias = "Caller" , description = "Caller's phone number (same as From)"
161+ )
145162 caller_city : Optional [str ] = Field (alias = "CallerCity" , default = None )
146163 caller_state : Optional [str ] = Field (alias = "CallerState" , default = None )
147164 caller_zip : Optional [str ] = Field (alias = "CallerZip" , default = None )
@@ -150,7 +167,7 @@ async def webhook(data: CallWebhookInput = Depends(CallWebhookInput.as_form)):
150167 from_state : Optional [str ] = Field (alias = "FromState" , default = None )
151168 from_zip : Optional [str ] = Field (alias = "FromZip" , default = None )
152169 from_country : Optional [str ] = Field (alias = "FromCountry" , default = None )
153-
170+
154171 # To (called) information
155172 to : str = Field (alias = "To" , description = "Called phone number (E.164 format)" )
156173 called : str = Field (alias = "Called" , description = "Called phone number (same as To)" )
@@ -162,15 +179,17 @@ async def webhook(data: CallWebhookInput = Depends(CallWebhookInput.as_form)):
162179 to_state : Optional [str ] = Field (alias = "ToState" , default = None )
163180 to_zip : Optional [str ] = Field (alias = "ToZip" , default = None )
164181 to_country : Optional [str ] = Field (alias = "ToCountry" , default = None )
165-
182+
166183 # STIR/SHAKEN verification
167- stir_verstat : Optional [str ] = Field (alias = "StirVerstat" , default = None , description = "STIR/SHAKEN verification status" )
168-
184+ stir_verstat : Optional [str ] = Field (
185+ alias = "StirVerstat" , default = None , description = "STIR/SHAKEN verification status"
186+ )
187+
169188 # Call token for additional security
170189 call_token : Optional [str ] = Field (alias = "CallToken" , default = None )
171-
190+
172191 model_config = {"populate_by_name" : True }
173-
192+
174193 @classmethod
175194 def as_form (
176195 cls ,
@@ -204,7 +223,7 @@ def as_form(
204223 ) -> "CallWebhookInput" :
205224 """
206225 Create CallWebhookInput from FastAPI Form fields.
207-
226+
208227 Usage:
209228 @app.post("/twilio/voice")
210229 async def webhook(data: CallWebhookInput = Depends(CallWebhookInput.as_form)):
@@ -239,4 +258,3 @@ async def webhook(data: CallWebhookInput = Depends(CallWebhookInput.as_form)):
239258 StirVerstat = StirVerstat ,
240259 CallToken = CallToken ,
241260 )
242-
0 commit comments