Revert Phase 1 STT/auth swaps: stay on Whisper + Twilio Auth Token
Deepgram and the Twilio Standard API Key were reverted per decision: - bot.py: restore HintedWhisperSTTService (faster-whisper hotwords), default model medium; remove DeepgramSTTService import + DEEPGRAM_API_KEY. - server.py: restore TWILIO_AUTH_TOKEN for X-Twilio-Signature validation and the serializer auto-hang-up. Twilio signs webhooks with the Auth Token, so an API Key Secret cannot validate signatures. - .env.example: back to TWILIO_AUTH_TOKEN + Whisper STT vars. - .gitignore: ignore runtime *.log (avc_run.log). OLLAMA_MODEL stays activeblue-avc:latest (the existing pulled tag). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
34
server.py
34
server.py
@@ -10,8 +10,8 @@ Two endpoints, both reached by Twilio over your public Traefik domain:
|
||||
|
||||
Security:
|
||||
- POST /voice is authenticated with Twilio's X-Twilio-Signature (HMAC-SHA1 over the
|
||||
public URL + sorted POST params, keyed by the API Key Secret). Enforced whenever
|
||||
TWILIO_API_KEY_SECRET is set; set TWILIO_VALIDATE=false to bypass for local testing.
|
||||
public URL + sorted POST params, keyed by the auth token). Enforced whenever
|
||||
TWILIO_AUTH_TOKEN is set; set TWILIO_VALIDATE=false to bypass for local testing.
|
||||
- WS /ws can't carry an X-Twilio-Signature usefully, so we gate it with a shared
|
||||
STREAM_TOKEN embedded in the wss URL we hand Twilio in the TwiML.
|
||||
|
||||
@@ -44,20 +44,16 @@ BIND_HOST = os.environ.get("BIND_HOST", "127.0.0.1")
|
||||
# Twilio REST creds — let the serializer auto-hang-up the carrier leg on EndFrame,
|
||||
# and validate inbound webhook signatures.
|
||||
TWILIO_ACCOUNT_SID = os.environ.get("TWILIO_ACCOUNT_SID")
|
||||
# Standard API Key (scoped to this app, revocable independently) instead of the account
|
||||
# master Auth Token. The Secret is used both for HMAC webhook-signature validation and as
|
||||
# the serializer credential for auto-hang-up.
|
||||
TWILIO_API_KEY_SID = os.environ.get("TWILIO_API_KEY_SID")
|
||||
TWILIO_API_KEY_SECRET = os.environ.get("TWILIO_API_KEY_SECRET")
|
||||
# Signature validation is ON by default when the API key secret exists; explicit opt-out.
|
||||
TWILIO_AUTH_TOKEN = os.environ.get("TWILIO_AUTH_TOKEN")
|
||||
# Signature validation is ON by default when an auth token exists; explicit opt-out.
|
||||
TWILIO_VALIDATE = os.environ.get("TWILIO_VALIDATE", "true").lower() not in ("false", "0", "no")
|
||||
|
||||
# Shared secret embedded in the Media Stream wss URL to gate /ws. Auto-generated if
|
||||
# unset (fine for a single process), but set it in .env for stability across restarts.
|
||||
STREAM_TOKEN = os.environ.get("STREAM_TOKEN") or secrets.token_urlsafe(24)
|
||||
|
||||
# Max simultaneous live calls. Each call holds an Ollama context on the 16GB GPU and
|
||||
# Ollama serializes generation, so cap this to protect call quality.
|
||||
# Max simultaneous live calls. Each call loads a Whisper model + an Ollama context on
|
||||
# the 16GB GPU and Ollama serializes generation, so cap this to protect call quality.
|
||||
# Over-cap callers hear BUSY_MESSAGE and are hung up — existing calls are never degraded.
|
||||
MAX_CONCURRENT_CALLS = int(os.environ.get("MAX_CONCURRENT_CALLS", "2"))
|
||||
BUSY_MESSAGE = os.environ.get(
|
||||
@@ -93,12 +89,12 @@ def _twilio_signature_ok(url: str, params: dict, header_sig: str) -> bool:
|
||||
"""Recompute Twilio's request signature and compare in constant time.
|
||||
|
||||
Algorithm (Twilio docs): take the full public URL, append each POST param as
|
||||
key+value sorted by key, HMAC-SHA1 with the API Key Secret, base64-encode.
|
||||
key+value sorted by key, HMAC-SHA1 with the auth token, base64-encode.
|
||||
"""
|
||||
if not (TWILIO_API_KEY_SECRET and header_sig):
|
||||
if not (TWILIO_AUTH_TOKEN and header_sig):
|
||||
return False
|
||||
payload = url + "".join(f"{k}{params[k]}" for k in sorted(params))
|
||||
digest = hmac.new(TWILIO_API_KEY_SECRET.encode(), payload.encode("utf-8"), hashlib.sha1).digest()
|
||||
digest = hmac.new(TWILIO_AUTH_TOKEN.encode(), payload.encode("utf-8"), hashlib.sha1).digest()
|
||||
expected = base64.b64encode(digest).decode()
|
||||
return hmac.compare_digest(expected, header_sig)
|
||||
|
||||
@@ -108,7 +104,7 @@ async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"public_host": PUBLIC_HOST,
|
||||
"validate": TWILIO_VALIDATE and bool(TWILIO_API_KEY_SECRET),
|
||||
"validate": TWILIO_VALIDATE and bool(TWILIO_AUTH_TOKEN),
|
||||
"active_calls": _active_calls,
|
||||
"max_calls": MAX_CONCURRENT_CALLS,
|
||||
}
|
||||
@@ -118,15 +114,15 @@ async def health():
|
||||
async def voice(request: Request):
|
||||
"""TwiML: connect the call to our Media Stream WebSocket (bidirectional)."""
|
||||
form = dict(await request.form())
|
||||
if TWILIO_VALIDATE and TWILIO_API_KEY_SECRET:
|
||||
if TWILIO_VALIDATE and TWILIO_AUTH_TOKEN:
|
||||
# Validate against the PUBLIC url Twilio actually signed, not the internal one.
|
||||
public_url = f"https://{PUBLIC_HOST}/voice"
|
||||
sig = request.headers.get("X-Twilio-Signature", "")
|
||||
if not _twilio_signature_ok(public_url, form, sig):
|
||||
logger.warning("Rejected /voice: bad or missing X-Twilio-Signature")
|
||||
return HTMLResponse(status_code=403, content="forbidden")
|
||||
elif not TWILIO_API_KEY_SECRET:
|
||||
logger.warning("/voice signature validation DISABLED (no TWILIO_API_KEY_SECRET set)")
|
||||
elif not TWILIO_AUTH_TOKEN:
|
||||
logger.warning("/voice signature validation DISABLED (no TWILIO_AUTH_TOKEN set)")
|
||||
|
||||
caller = form.get("From", "") # caller-ID; passed through for appointment callback
|
||||
|
||||
@@ -199,7 +195,7 @@ async def media_stream(websocket: WebSocket):
|
||||
stream_sid=stream_sid,
|
||||
call_sid=call_sid,
|
||||
account_sid=TWILIO_ACCOUNT_SID,
|
||||
auth_token=TWILIO_API_KEY_SECRET,
|
||||
auth_token=TWILIO_AUTH_TOKEN,
|
||||
)
|
||||
await run_call(websocket, serializer, caller_number=caller_number, call_sid=call_sid)
|
||||
except Exception:
|
||||
@@ -214,5 +210,5 @@ if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
logger.info(f"AVC phone agent on {BIND_HOST}:{PORT} | public={PUBLIC_HOST} | "
|
||||
f"sig_validation={'on' if (TWILIO_VALIDATE and TWILIO_API_KEY_SECRET) else 'OFF'}")
|
||||
f"sig_validation={'on' if (TWILIO_VALIDATE and TWILIO_AUTH_TOKEN) else 'OFF'}")
|
||||
uvicorn.run(app, host=BIND_HOST, port=PORT)
|
||||
|
||||
Reference in New Issue
Block a user