Skip to content

Commit 9e3afe7

Browse files
committed
normalise wss urls from Ngrok
1 parent b2ebaee commit 9e3afe7

File tree

2 files changed

+63
-33
lines changed

2 files changed

+63
-33
lines changed

plugins/twilio/vision_agents/plugins/twilio/models.py

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
from twilio.request_validator import RequestValidator
1111
from twilio.twiml.voice_response import VoiceResponse, Connect
1212

13+
from .utils import normalize_websocket_url
14+
1315
logger = 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

4652
class 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:
120126
class 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-
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
def normalize_websocket_url(url: str) -> str:
2+
"""Normalize a URL to use the wss:// scheme for Twilio media streams."""
3+
url = url.strip()
4+
if url.startswith("wss://https://"):
5+
url = "wss://" + url[14:]
6+
elif url.startswith("wss://http://"):
7+
url = "wss://" + url[13:]
8+
elif url.startswith("https://"):
9+
url = "wss://" + url[8:]
10+
elif url.startswith("http://"):
11+
url = "wss://" + url[8:]
12+
return url

0 commit comments

Comments
 (0)