mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
feat: support inbound vonage calls (#480)
* feat: support inbound vonage calls * fix: drift check * feat: add warning with missing signature secret * docs: vonage inbound steps * chore: upgrade pipecat submodule
This commit is contained in:
parent
f190a0dd9a
commit
d9800fddd6
18 changed files with 687 additions and 83 deletions
|
|
@ -103,6 +103,30 @@ class TelephonyConfigurationClient(BaseDBClient):
|
|||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
async def count_vonage_configs_missing_signature_secret(
|
||||
self, organization_id: int
|
||||
) -> int:
|
||||
"""Count Vonage configs in this org with no signature_secret."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(func.count(TelephonyConfigurationModel.id)).where(
|
||||
TelephonyConfigurationModel.organization_id == organization_id,
|
||||
TelephonyConfigurationModel.provider == "vonage",
|
||||
(
|
||||
TelephonyConfigurationModel.credentials.op("->>")(
|
||||
"signature_secret"
|
||||
).is_(None)
|
||||
)
|
||||
| (
|
||||
TelephonyConfigurationModel.credentials.op("->>")(
|
||||
"signature_secret"
|
||||
)
|
||||
== ""
|
||||
),
|
||||
)
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
async def list_all_telephony_configurations_by_provider(
|
||||
self, provider: str
|
||||
) -> List[TelephonyConfigurationModel]:
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ class TelephonyConfigWarningsResponse(BaseModel):
|
|||
"""
|
||||
|
||||
telnyx_missing_webhook_public_key_count: int
|
||||
vonage_missing_signature_secret_count: int
|
||||
|
||||
|
||||
@router.get("/context", response_model=OrganizationContextResponse)
|
||||
|
|
@ -200,8 +201,7 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
|
|||
async def get_telephony_config_warnings(user: UserModel = Depends(get_user)):
|
||||
"""Return aggregated warning counts for the current org's telephony configs.
|
||||
|
||||
Today this surfaces only Telnyx configs missing ``webhook_public_key``;
|
||||
additional warning types should be added as new fields on the response.
|
||||
Surfaces provider configs missing webhook-verification credentials.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
|
@ -209,8 +209,12 @@ async def get_telephony_config_warnings(user: UserModel = Depends(get_user)):
|
|||
telnyx_missing = await db_client.count_telnyx_configs_missing_webhook_public_key(
|
||||
user.selected_organization_id
|
||||
)
|
||||
vonage_missing = await db_client.count_vonage_configs_missing_signature_secret(
|
||||
user.selected_organization_id
|
||||
)
|
||||
return TelephonyConfigWarningsResponse(
|
||||
telnyx_missing_webhook_public_key_count=telnyx_missing,
|
||||
vonage_missing_signature_secret_count=vonage_missing,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -684,7 +684,7 @@ async def handle_inbound_run(request: Request):
|
|||
logger.error("Unable to detect provider for /inbound/run webhook")
|
||||
return generic_hangup_response()
|
||||
|
||||
normalized_data = normalize_webhook_data(provider_class, webhook_data)
|
||||
normalized_data = normalize_webhook_data(provider_class, webhook_data, headers)
|
||||
logger.info(
|
||||
f"/inbound/run normalized data — provider={normalized_data.provider} "
|
||||
f"to={normalized_data.to_number} from={normalized_data.from_number}"
|
||||
|
|
@ -871,7 +871,7 @@ async def handle_inbound_telephony(
|
|||
logger.error("Unable to detect provider for webhook")
|
||||
return generic_hangup_response()
|
||||
|
||||
normalized_data = normalize_webhook_data(provider_class, webhook_data)
|
||||
normalized_data = normalize_webhook_data(provider_class, webhook_data, headers)
|
||||
|
||||
logger.info(f"Inbound call - Provider: {normalized_data.provider}")
|
||||
logger.info(f"Normalized data: {normalized_data}")
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"private_key": value.get("private_key"),
|
||||
"api_key": value.get("api_key"),
|
||||
"api_secret": value.get("api_secret"),
|
||||
"signature_secret": value.get("signature_secret"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +50,13 @@ _UI_METADATA = ProviderUIMetadata(
|
|||
type="password",
|
||||
sensitive=True,
|
||||
),
|
||||
ProviderUIField(
|
||||
name="signature_secret",
|
||||
label="Signature Secret",
|
||||
type="password",
|
||||
sensitive=True,
|
||||
description="Vonage signature secret for signed webhook verification",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="Phone Numbers",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Vonage telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal
|
||||
from typing import List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -13,6 +13,10 @@ class VonageConfigurationRequest(BaseModel):
|
|||
api_secret: str = Field(..., description="Vonage API Secret")
|
||||
application_id: str = Field(..., description="Vonage Application ID")
|
||||
private_key: str = Field(..., description="Private key for JWT generation")
|
||||
signature_secret: Optional[str] = Field(
|
||||
None,
|
||||
description="Vonage signature secret used to verify signed webhooks",
|
||||
)
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of Vonage phone numbers (without + prefix)",
|
||||
|
|
@ -27,4 +31,5 @@ class VonageConfigurationResponse(BaseModel):
|
|||
api_key: str # Masked
|
||||
api_secret: str # Masked
|
||||
private_key: str # Masked
|
||||
signature_secret: Optional[str] = None # Masked
|
||||
from_numbers: List[str]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
Vonage (Nexmo) implementation of the TelephonyProvider interface.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
|
|
@ -44,12 +45,14 @@ class VonageProvider(TelephonyProvider):
|
|||
- api_secret: Vonage API Secret
|
||||
- application_id: Vonage Application ID
|
||||
- private_key: Private key for JWT generation
|
||||
- signature_secret: Signature secret for signed webhooks
|
||||
- from_numbers: List of phone numbers to use
|
||||
"""
|
||||
self.api_key = config.get("api_key")
|
||||
self.api_secret = config.get("api_secret")
|
||||
self.application_id = config.get("application_id")
|
||||
self.private_key = config.get("private_key")
|
||||
self.signature_secret = config.get("signature_secret")
|
||||
self.from_numbers = config.get("from_numbers", [])
|
||||
|
||||
# Handle both single number (string) and multiple numbers (list)
|
||||
|
|
@ -186,17 +189,18 @@ class VonageProvider(TelephonyProvider):
|
|||
Verify Vonage webhook signature for security.
|
||||
Vonage uses JWT for webhook signatures.
|
||||
"""
|
||||
if not self.api_secret:
|
||||
logger.error("No API secret available for webhook signature verification")
|
||||
if not self.signature_secret:
|
||||
logger.error(
|
||||
"No signature secret available for Vonage webhook verification"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Vonage sends JWT in Authorization header. Verify the JWT signature
|
||||
decoded = jwt.decode(
|
||||
jwt.decode(
|
||||
signature,
|
||||
self.api_secret,
|
||||
self.signature_secret,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_signature": True},
|
||||
options={"verify_signature": True, "verify_aud": False},
|
||||
)
|
||||
return True
|
||||
except jwt.InvalidTokenError:
|
||||
|
|
@ -295,9 +299,13 @@ class VonageProvider(TelephonyProvider):
|
|||
"ringing": TelephonyCallStatus.RINGING,
|
||||
"answered": TelephonyCallStatus.ANSWERED,
|
||||
"complete": TelephonyCallStatus.COMPLETED,
|
||||
"completed": TelephonyCallStatus.COMPLETED,
|
||||
"disconnected": TelephonyCallStatus.COMPLETED,
|
||||
"failed": TelephonyCallStatus.FAILED,
|
||||
"busy": TelephonyCallStatus.BUSY,
|
||||
"timeout": TelephonyCallStatus.NO_ANSWER,
|
||||
"unanswered": TelephonyCallStatus.NO_ANSWER,
|
||||
"cancelled": TelephonyCallStatus.NO_ANSWER,
|
||||
"rejected": TelephonyCallStatus.BUSY,
|
||||
}
|
||||
|
||||
|
|
@ -349,6 +357,8 @@ class VonageProvider(TelephonyProvider):
|
|||
if workflow_run.gathered_context
|
||||
else None
|
||||
)
|
||||
if not call_uuid and workflow_run.gathered_context:
|
||||
call_uuid = workflow_run.gathered_context.get("call_id")
|
||||
|
||||
if not call_uuid:
|
||||
logger.error(
|
||||
|
|
@ -400,26 +410,126 @@ class VonageProvider(TelephonyProvider):
|
|||
"""
|
||||
Determine if this provider can handle the incoming webhook.
|
||||
"""
|
||||
return False
|
||||
claims = cls._decode_unverified_signed_claims(headers)
|
||||
if claims.get("api_key") or claims.get("application_id"):
|
||||
return True
|
||||
|
||||
return bool(
|
||||
webhook_data.get("uuid")
|
||||
and webhook_data.get("conversation_uuid")
|
||||
and webhook_data.get("from")
|
||||
and webhook_data.get("to")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
def parse_inbound_webhook(
|
||||
webhook_data: Dict[str, Any], headers: Optional[Dict[str, str]] = None
|
||||
) -> NormalizedInboundData:
|
||||
"""
|
||||
Parse Vonage-specific inbound webhook data into normalized format.
|
||||
"""
|
||||
claims = VonageProvider._decode_unverified_signed_claims(headers or {})
|
||||
direction = webhook_data.get("direction") or "inbound"
|
||||
status = webhook_data.get("status") or "started"
|
||||
|
||||
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"),
|
||||
direction=direction,
|
||||
call_status=status,
|
||||
account_id=claims.get("api_key") or webhook_data.get("account_id"),
|
||||
from_country=None,
|
||||
to_country=None,
|
||||
raw_data=webhook_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _header(headers: Dict[str, str], name: str) -> Optional[str]:
|
||||
for key, value in headers.items():
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _bearer_token(cls, headers: Dict[str, str]) -> Optional[str]:
|
||||
auth_header = cls._header(headers, "authorization")
|
||||
if not auth_header:
|
||||
return None
|
||||
parts = auth_header.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
return parts[1].strip()
|
||||
|
||||
@classmethod
|
||||
def _decode_unverified_signed_claims(
|
||||
cls, headers: Dict[str, str]
|
||||
) -> Dict[str, Any]:
|
||||
token = cls._bearer_token(headers)
|
||||
if not token:
|
||||
return {}
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
options={
|
||||
"verify_signature": False,
|
||||
"verify_aud": False,
|
||||
"verify_exp": False,
|
||||
},
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
return {}
|
||||
return claims if isinstance(claims, dict) else {}
|
||||
|
||||
def _verify_signed_claims(
|
||||
self, headers: Dict[str, str], body: str = ""
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
token = self._bearer_token(headers)
|
||||
if not token:
|
||||
logger.warning("Missing Vonage Authorization bearer token")
|
||||
return None
|
||||
if not self.signature_secret:
|
||||
logger.error("Missing Vonage signature_secret for signed webhook")
|
||||
return None
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
self.signature_secret,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_signature": True, "verify_aud": False},
|
||||
)
|
||||
except jwt.InvalidTokenError as exc:
|
||||
logger.warning(f"Invalid Vonage signed webhook JWT: {exc}")
|
||||
return None
|
||||
|
||||
if claims.get("iss") != "Vonage":
|
||||
logger.warning("Vonage signed webhook JWT has unexpected issuer")
|
||||
return None
|
||||
|
||||
if self.api_key and claims.get("api_key") != self.api_key:
|
||||
logger.warning("Vonage signed webhook api_key does not match config")
|
||||
return None
|
||||
|
||||
claim_application_id = claims.get("application_id")
|
||||
if (
|
||||
self.application_id
|
||||
and claim_application_id
|
||||
and claim_application_id != self.application_id
|
||||
):
|
||||
logger.warning("Vonage signed webhook application_id does not match config")
|
||||
return None
|
||||
|
||||
payload_hash = claims.get("payload_hash")
|
||||
if payload_hash:
|
||||
actual_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||
if actual_hash != payload_hash:
|
||||
logger.warning("Vonage signed webhook payload hash mismatch")
|
||||
return None
|
||||
|
||||
return claims
|
||||
|
||||
@staticmethod
|
||||
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
|
||||
"""Validate Vonage account_id from webhook matches configuration"""
|
||||
|
|
@ -437,9 +547,10 @@ class VonageProvider(TelephonyProvider):
|
|||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Vonage inbound signature verification - minimalist implementation.
|
||||
Verify Vonage signed webhook JWT and optional payload hash.
|
||||
"""
|
||||
return True
|
||||
claims = self._verify_signed_claims(headers, body)
|
||||
return claims is not None
|
||||
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
|
|
@ -486,6 +597,15 @@ class VonageProvider(TelephonyProvider):
|
|||
),
|
||||
)
|
||||
|
||||
if not self.signature_secret:
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"Vonage signature_secret is required because inbound calls "
|
||||
"use signed webhook verification"
|
||||
),
|
||||
)
|
||||
|
||||
app_endpoint = f"{self.base_url}/v2/applications/{self.application_id}"
|
||||
auth = aiohttp.BasicAuth(self.api_key, self.api_secret)
|
||||
|
||||
|
|
@ -510,12 +630,18 @@ class VonageProvider(TelephonyProvider):
|
|||
capabilities = app_data.get("capabilities") or {}
|
||||
voice = capabilities.get("voice") or {}
|
||||
webhooks = voice.get("webhooks") or {}
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
|
||||
webhooks["answer_url"] = {
|
||||
"address": webhook_url,
|
||||
"http_method": "POST",
|
||||
}
|
||||
webhooks["event_url"] = {
|
||||
"address": f"{backend_endpoint}/api/v1/telephony/vonage/events",
|
||||
"http_method": "POST",
|
||||
}
|
||||
voice["webhooks"] = webhooks
|
||||
voice["signed_callbacks"] = True
|
||||
capabilities["voice"] = voice
|
||||
|
||||
update_body = {
|
||||
|
|
@ -561,13 +687,24 @@ class VonageProvider(TelephonyProvider):
|
|||
"""
|
||||
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"},
|
||||
"action": "connect",
|
||||
"eventUrl": [
|
||||
f"{backend_endpoint}/api/v1/telephony/vonage/events/{workflow_run_id}"
|
||||
],
|
||||
"endpoint": [
|
||||
{
|
||||
"type": "websocket",
|
||||
"uri": websocket_url,
|
||||
"content-type": "audio/l16;rate=16000",
|
||||
"headers": {
|
||||
"workflow_run_id": str(workflow_run_id),
|
||||
"call_uuid": normalized_data.call_id,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
return Response(
|
||||
|
|
|
|||
|
|
@ -7,16 +7,12 @@ provider registry — see ProviderSpec.router.
|
|||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from loguru import logger
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider_for_run
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -45,28 +41,33 @@ async def handle_ncco_webhook(
|
|||
return json.loads(response_content)
|
||||
|
||||
|
||||
@router.post("/vonage/events/{workflow_run_id}")
|
||||
async def handle_vonage_events(
|
||||
request: Request,
|
||||
workflow_run_id: int,
|
||||
):
|
||||
"""Handle Vonage-specific event webhooks.
|
||||
async def _read_json_body(request: Request) -> tuple[dict, str]:
|
||||
body_bytes = await request.body()
|
||||
try:
|
||||
raw_body = body_bytes.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Webhook body is not valid UTF-8"
|
||||
) from exc
|
||||
try:
|
||||
return json.loads(raw_body or "{}"), raw_body
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail="Webhook body is not JSON") from exc
|
||||
|
||||
Vonage sends all call events to a single endpoint.
|
||||
Events include: started, ringing, answered, complete, failed, etc.
|
||||
"""
|
||||
|
||||
async def _handle_vonage_event_request(request: Request, workflow_run_id: int):
|
||||
set_current_run_id(workflow_run_id)
|
||||
# Parse the event data
|
||||
event_data = await request.json()
|
||||
logger.info(f"[run {workflow_run_id}] Received Vonage event: {event_data}")
|
||||
event_data, raw_body = await _read_json_body(request)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vonage event "
|
||||
f"uuid={event_data.get('uuid')} status={event_data.get('status')}"
|
||||
)
|
||||
|
||||
# Get workflow run for processing
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.error(f"[run {workflow_run_id}] Workflow run not found")
|
||||
return {"status": "error", "message": "Workflow run not found"}
|
||||
|
||||
# Get workflow and provider
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.error(f"[run {workflow_run_id}] Workflow not found")
|
||||
|
|
@ -75,11 +76,18 @@ async def handle_vonage_events(
|
|||
provider = await get_telephony_provider_for_run(
|
||||
workflow_run, workflow.organization_id
|
||||
)
|
||||
signature_valid = await provider.verify_inbound_signature(
|
||||
str(request.url), event_data, dict(request.headers), raw_body
|
||||
)
|
||||
if not signature_valid:
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
|
||||
# Parse the event data into generic format
|
||||
parsed_data = provider.parse_status_callback(event_data)
|
||||
|
||||
# Create StatusCallbackRequest from parsed data
|
||||
status_update = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
|
|
@ -90,8 +98,35 @@ async def handle_vonage_events(
|
|||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
||||
# Return 204 No Content as expected by Vonage
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/vonage/events/{workflow_run_id}")
|
||||
async def handle_vonage_events(
|
||||
request: Request,
|
||||
workflow_run_id: int,
|
||||
):
|
||||
"""Handle Vonage-specific event webhooks.
|
||||
|
||||
Vonage sends all call events to a single endpoint.
|
||||
Events include: started, ringing, answered, complete, failed, etc.
|
||||
"""
|
||||
return await _handle_vonage_event_request(request, workflow_run_id)
|
||||
|
||||
|
||||
@router.post("/vonage/events")
|
||||
async def handle_vonage_events_without_run(request: Request):
|
||||
"""Handle application-level events by resolving the run from call UUID."""
|
||||
event_data, _ = await _read_json_body(request)
|
||||
call_id = event_data.get("uuid")
|
||||
if call_id:
|
||||
workflow_run = await db_client.get_workflow_run_by_call_id(call_id)
|
||||
if workflow_run:
|
||||
return await _handle_vonage_event_request(request, workflow_run.id)
|
||||
|
||||
logger.info(
|
||||
"Received unmatched Vonage application event "
|
||||
f"uuid={event_data.get('uuid')} status={event_data.get('status')}"
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
|
|
|||
279
api/tests/telephony/vonage/test_provider.py
Normal file
279
api/tests/telephony/vonage/test_provider.py
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from api.services.telephony.providers.vonage.provider import VonageProvider
|
||||
from api.services.telephony.providers.vonage.routes import handle_vonage_events
|
||||
|
||||
SIGNATURE_SECRET = "vonage-signature-secret"
|
||||
|
||||
|
||||
def _body() -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"from": "15551230001",
|
||||
"to": "15551230002",
|
||||
"uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
|
||||
"conversation_uuid": "CON-aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
|
||||
"status": "answered",
|
||||
"direction": "inbound",
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def _provider(**overrides) -> VonageProvider:
|
||||
config = {
|
||||
"api_key": "vonage-api-key",
|
||||
"api_secret": "vonage-api-secret",
|
||||
"application_id": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
|
||||
"private_key": "placeholder-private-key",
|
||||
"signature_secret": SIGNATURE_SECRET,
|
||||
"from_numbers": ["15551230002"],
|
||||
}
|
||||
config.update(overrides)
|
||||
return VonageProvider(config)
|
||||
|
||||
|
||||
def _signed_headers(
|
||||
body: str,
|
||||
*,
|
||||
signature_secret: str = SIGNATURE_SECRET,
|
||||
api_key: str = "vonage-api-key",
|
||||
application_id: str = "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
|
||||
) -> dict[str, str]:
|
||||
token = jwt.encode(
|
||||
{
|
||||
"iat": int(time.time()),
|
||||
"jti": "test-jti",
|
||||
"iss": "Vonage",
|
||||
"payload_hash": hashlib.sha256(body.encode("utf-8")).hexdigest(),
|
||||
"api_key": api_key,
|
||||
"application_id": application_id,
|
||||
},
|
||||
signature_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
return {"authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _request(body: str, headers: dict[str, str]) -> Request:
|
||||
async def receive():
|
||||
return {
|
||||
"type": "http.request",
|
||||
"body": body.encode("utf-8"),
|
||||
"more_body": False,
|
||||
}
|
||||
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/telephony/vonage/events/123",
|
||||
"headers": [
|
||||
(name.lower().encode("ascii"), value.encode("ascii"))
|
||||
for name, value in headers.items()
|
||||
],
|
||||
},
|
||||
receive,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_inbound_signature_accepts_valid_vonage_signed_webhook():
|
||||
body = _body()
|
||||
provider = _provider()
|
||||
|
||||
result = await provider.verify_inbound_signature(
|
||||
"https://example.test/api/v1/telephony/inbound/run",
|
||||
json.loads(body),
|
||||
_signed_headers(body),
|
||||
body,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_inbound_signature_rejects_tampered_payload():
|
||||
body = _body()
|
||||
provider = _provider()
|
||||
|
||||
result = await provider.verify_inbound_signature(
|
||||
"https://example.test/api/v1/telephony/inbound/run",
|
||||
json.loads(body),
|
||||
_signed_headers(body),
|
||||
body.replace("answered", "completed"),
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_inbound_signature_rejects_missing_signature_secret():
|
||||
body = _body()
|
||||
provider = _provider(signature_secret=None)
|
||||
|
||||
result = await provider.verify_inbound_signature(
|
||||
"https://example.test/api/v1/telephony/inbound/run",
|
||||
json.loads(body),
|
||||
_signed_headers(body),
|
||||
body,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_inbound_signature_rejects_wrong_api_key_claim():
|
||||
body = _body()
|
||||
provider = _provider()
|
||||
|
||||
result = await provider.verify_inbound_signature(
|
||||
"https://example.test/api/v1/telephony/inbound/run",
|
||||
json.loads(body),
|
||||
_signed_headers(body, api_key="other-api-key"),
|
||||
body,
|
||||
)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_parse_inbound_webhook_uses_signed_api_key_claim_for_account_id():
|
||||
body = _body()
|
||||
normalized = VonageProvider.parse_inbound_webhook(
|
||||
json.loads(body), headers=_signed_headers(body)
|
||||
)
|
||||
|
||||
assert normalized.provider == "vonage"
|
||||
assert normalized.call_id == "aaaaaaaa-bbbb-cccc-dddd-0123456789ab"
|
||||
assert normalized.account_id == "vonage-api-key"
|
||||
assert normalized.direction == "inbound"
|
||||
|
||||
|
||||
def test_can_handle_webhook_detects_signed_vonage_answer_payload():
|
||||
body = _body()
|
||||
|
||||
assert VonageProvider.can_handle_webhook(json.loads(body), _signed_headers(body))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_inbound_stream_returns_websocket_ncco():
|
||||
body = _body()
|
||||
provider = _provider()
|
||||
normalized = VonageProvider.parse_inbound_webhook(
|
||||
json.loads(body), headers=_signed_headers(body)
|
||||
)
|
||||
|
||||
response = await provider.start_inbound_stream(
|
||||
websocket_url="wss://example.test/api/v1/telephony/ws/1/2/3",
|
||||
workflow_run_id=123,
|
||||
normalized_data=normalized,
|
||||
backend_endpoint="https://example.test",
|
||||
)
|
||||
|
||||
ncco = json.loads(response.body)
|
||||
assert ncco == [
|
||||
{
|
||||
"action": "connect",
|
||||
"eventUrl": ["https://example.test/api/v1/telephony/vonage/events/123"],
|
||||
"endpoint": [
|
||||
{
|
||||
"type": "websocket",
|
||||
"uri": "wss://example.test/api/v1/telephony/ws/1/2/3",
|
||||
"content-type": "audio/l16;rate=16000",
|
||||
"headers": {
|
||||
"workflow_run_id": "123",
|
||||
"call_uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vonage_events_route_verifies_signature_before_status_update():
|
||||
body = _body()
|
||||
provider = _provider()
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.providers.vonage.routes.db_client") as db_client,
|
||||
patch(
|
||||
"api.services.telephony.providers.vonage.routes.get_telephony_provider_for_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=provider,
|
||||
),
|
||||
):
|
||||
process_status = AsyncMock()
|
||||
status_processor = SimpleNamespace(
|
||||
StatusCallbackRequest=SimpleNamespace,
|
||||
_process_status_update=process_status,
|
||||
)
|
||||
db_client.get_workflow_run_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(workflow_id=7)
|
||||
)
|
||||
db_client.get_workflow_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(organization_id=11)
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
sys.modules,
|
||||
{"api.services.telephony.status_processor": status_processor},
|
||||
):
|
||||
result = await handle_vonage_events(
|
||||
_request(body, _signed_headers(body)), workflow_run_id=123
|
||||
)
|
||||
|
||||
assert result == {"status": "ok"}
|
||||
process_status.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vonage_events_route_rejects_invalid_signature_with_401():
|
||||
body = _body()
|
||||
provider = _provider()
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.providers.vonage.routes.db_client") as db_client,
|
||||
patch(
|
||||
"api.services.telephony.providers.vonage.routes.get_telephony_provider_for_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=provider,
|
||||
),
|
||||
):
|
||||
process_status = AsyncMock()
|
||||
status_processor = SimpleNamespace(
|
||||
StatusCallbackRequest=SimpleNamespace,
|
||||
_process_status_update=process_status,
|
||||
)
|
||||
db_client.get_workflow_run_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(workflow_id=7)
|
||||
)
|
||||
db_client.get_workflow_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(organization_id=11)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{"api.services.telephony.status_processor": status_processor},
|
||||
),
|
||||
pytest.raises(HTTPException) as exc_info,
|
||||
):
|
||||
await handle_vonage_events(
|
||||
_request(body, _signed_headers(body, signature_secret="wrong")),
|
||||
workflow_run_id=123,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 401
|
||||
assert exc_info.value.detail == "Invalid webhook signature"
|
||||
process_status.assert_not_awaited()
|
||||
|
|
@ -3,6 +3,8 @@ Telephony helper utilities.
|
|||
Common functions used across telephony operations.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
|
||||
from fastapi import Request
|
||||
from loguru import logger
|
||||
from starlette.responses import HTMLResponse
|
||||
|
|
@ -119,9 +121,12 @@ def _test_number_formats_with_country_code(
|
|||
return False
|
||||
|
||||
|
||||
def normalize_webhook_data(provider_class, webhook_data):
|
||||
def normalize_webhook_data(provider_class, webhook_data, headers=None):
|
||||
"""Normalize webhook data using the provider's parse method"""
|
||||
return provider_class.parse_inbound_webhook(webhook_data)
|
||||
parse_method = provider_class.parse_inbound_webhook
|
||||
if headers is not None and "headers" in inspect.signature(parse_method).parameters:
|
||||
return parse_method(webhook_data, headers=headers)
|
||||
return parse_method(webhook_data)
|
||||
|
||||
|
||||
def generic_hangup_response():
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Before setting up Vonage integration, you'll need:
|
|||
- Vonage Application with Voice capability enabled
|
||||
- Application ID and Private Key from your Vonage Dashboard
|
||||
- API Key and API Secret from your Vonage Dashboard
|
||||
- Signature Secret from your Vonage Dashboard
|
||||
- At least one Vonage phone number linked to the application
|
||||
- Dograh AI instance running and accessible
|
||||
|
||||
|
|
@ -31,9 +32,11 @@ Before setting up Vonage integration, you'll need:
|
|||
### Step 2: Get API Credentials
|
||||
|
||||
1. Find your **API Key** and **API Secret** in the dashboard under **API Settings**
|
||||
2. Navigate to **Numbers** → **Your Numbers**
|
||||
3. Copy your phone number(s)
|
||||
4. Link your numbers to your application
|
||||
2. Copy your **Signature Secret** from the same Vonage account. Dograh uses it
|
||||
to verify [Vonage signed webhooks](https://developer.vonage.com/en/getting-started/concepts/webhooks).
|
||||
3. Navigate to **Numbers** → **Your Numbers**
|
||||
4. Copy your phone number(s)
|
||||
5. Link each inbound number to your Voice application
|
||||
|
||||
### Step 3: Configure in Dograh AI
|
||||
|
||||
|
|
@ -44,6 +47,7 @@ Before setting up Vonage integration, you'll need:
|
|||
- Private Key (entire key including BEGIN/END lines)
|
||||
- API Key
|
||||
- API Secret
|
||||
- Signature Secret
|
||||
4. Click **Save Configuration**
|
||||
5. Open the configuration you just created and add at least one **phone number** (without `+` prefix, e.g. `14155551234`). The default caller ID is used for outbound calls.
|
||||
|
||||
|
|
@ -55,12 +59,24 @@ Before setting up Vonage integration, you'll need:
|
|||
|
||||
## Inbound Calling Setup
|
||||
|
||||
Vonage configures inbound webhooks at the **application level**, not per phone number. A single **Answer URL** on the Vonage application applies to every number linked to it. Dograh routes the call to the right agent based on the called number's inbound workflow assignment inside Dograh. **When you save an inbound workflow on a phone number, Dograh automatically pushes the webhook URL to your Vonage Application's Answer URL** (provided the credentials are correct).
|
||||
Vonage routes inbound Voice API calls through a Voice application. The application owns the Answer URL and Event URL, and the phone number must be linked to that application. Dograh routes the call to the right agent based on the called number's inbound workflow assignment inside Dograh.
|
||||
|
||||
When you save an inbound workflow on a phone number, Dograh updates the configured Vonage application's Voice webhooks and enables signed callbacks, provided the API Key, API Secret, and Application ID are correct and a Signature Secret is configured.
|
||||
|
||||
<Warning>
|
||||
Linking the phone number to the Voice application is required. If the number
|
||||
is not linked, Vonage will not call Dograh's Answer URL, and you may hear a
|
||||
busy or disconnected tone without seeing any Dograh application logs.
|
||||
</Warning>
|
||||
|
||||
### Step 1: Link Phone Numbers to Your Vonage Application
|
||||
|
||||
1. Open the [Vonage Dashboard](https://dashboard.nexmo.com/)
|
||||
2. Under **Numbers** → **Your Numbers**, link each number you want to use for inbound to the same Vonage Application whose ID you configured in Dograh
|
||||
2. Go to **Numbers** → **Your Numbers**
|
||||
3. Open each number you want to use for inbound calls
|
||||
4. Set the number's Voice application to the same Vonage Application whose ID you configured in Dograh
|
||||
|
||||
Vonage's Numbers API describes this as the number's `app_id`: the application that handles inbound traffic to that number. See the [Numbers API reference](https://developer.vonage.com/en/api/numbers).
|
||||
|
||||
### Step 2: Assign an Inbound Workflow to the Phone Number in Dograh
|
||||
|
||||
|
|
@ -73,23 +89,26 @@ Vonage configures inbound webhooks at the **application level**, not per phone n
|
|||
|
||||
1. Open your Vonage Application in the [Vonage Dashboard](https://dashboard.nexmo.com/)
|
||||
2. Under **Capabilities** → **Voice**, confirm:
|
||||
- **Answer URL** is set to: `https://api.dograh.com/api/v1/telephony/inbound/run`
|
||||
- **Answer URL** is set to: `https://<your-backend-domain>/api/v1/telephony/inbound/run`
|
||||
- **HTTP Method** is `POST`
|
||||
- **Event URL** is set to: `https://<your-backend-domain>/api/v1/telephony/vonage/events`
|
||||
- **Event Method** is `POST`
|
||||
- **Signed callbacks** are enabled
|
||||
|
||||
<Note>
|
||||
Dograh pushed this URL automatically when you saved the inbound workflow
|
||||
in Step 2. If the field is empty, shows a different URL, or Dograh
|
||||
surfaced a sync warning on save, the auto-push failed — most often
|
||||
because the API Key/Secret or Application ID in Dograh is incorrect.
|
||||
Paste the URL into the field yourself, set the method to `POST`, and
|
||||
save the application. On self-hosted Dograh, replace `api.dograh.com`
|
||||
with your backend domain.
|
||||
Dograh pushes these settings automatically when you save the inbound
|
||||
workflow in Step 2. If the fields are empty, show a different URL, or
|
||||
Dograh surfaced a sync warning on save, check the API Key, API Secret,
|
||||
Application ID, and Signature Secret in Dograh, then save the inbound
|
||||
workflow again. On self-hosted Dograh, the backend domain must be publicly
|
||||
reachable by Vonage.
|
||||
</Note>
|
||||
|
||||
### Step 4: Verify Setup
|
||||
|
||||
- Ensure your Dograh AI instance is publicly accessible
|
||||
- Verify any firewalls allow Vonage's IP ranges
|
||||
- Verify your public backend URL is reachable from the internet
|
||||
- Use the [Vonage logs](https://dashboard.nexmo.com/logs) or Voice Inspector to confirm Vonage is sending the Answer webhook to Dograh
|
||||
|
||||
### Test Inbound Calling
|
||||
|
||||
|
|
@ -119,6 +138,13 @@ Vonage uses higher quality audio (16kHz) which provides:
|
|||
- Check the Application ID is correct
|
||||
- Ensure the private key hasn't been regenerated in Vonage Dashboard
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Signed webhook validation failed">
|
||||
- Verify the Signature Secret in Dograh matches the Vonage account's signature secret
|
||||
- Ensure signed callbacks are enabled on the Vonage Voice application
|
||||
- Check that the webhook request includes an `Authorization` header
|
||||
- Confirm the application belongs to the same API Key saved in Dograh
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Invalid phone number error">
|
||||
- Remove the '+' prefix for Vonage (use `14155551234` not `+14155551234`)
|
||||
|
|
@ -141,11 +167,18 @@ Vonage uses higher quality audio (16kHz) which provides:
|
|||
</Accordion>
|
||||
|
||||
<Accordion title="Inbound calls not reaching voice agent">
|
||||
- Verify the Vonage application's Answer URL is set to `https://api.dograh.com/api/v1/telephony/inbound/run`
|
||||
- Verify the Vonage application's Answer URL is set to `https://<your-backend-domain>/api/v1/telephony/inbound/run`
|
||||
- Ensure the Answer URL is publicly accessible
|
||||
- Confirm the called number is linked to the correct Vonage application
|
||||
- Confirm the called number exists in your Dograh telephony configuration and has an **Inbound workflow** assigned
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Inbound call gives busy tone and Dograh shows no logs">
|
||||
- Confirm the Vonage number is linked to the Voice application configured in Dograh
|
||||
- Confirm the Voice application has the Dograh Answer URL set with method `POST`
|
||||
- Confirm your backend domain is public; Vonage cannot call `localhost`
|
||||
- Check Vonage logs for Answer URL delivery errors before debugging Dograh
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Voice agent doesn't respond to inbound calls">
|
||||
- Confirm the phone number has an **Inbound workflow** assigned in /telephony-configurations
|
||||
|
|
@ -158,6 +191,7 @@ Vonage uses higher quality audio (16kHz) which provides:
|
|||
## Best Practices
|
||||
|
||||
- **Security**: Private keys are stored securely in the database
|
||||
- **Signed callbacks**: Keep Vonage signed callbacks enabled and keep the Signature Secret in Dograh up to date
|
||||
- **Testing**: Use Vonage Voice Inspector for debugging call issues
|
||||
- **Numbers**: Configure multiple numbers for redundancy
|
||||
- **Monitoring**: Set up alerts in Vonage Dashboard for failures
|
||||
|
|
@ -177,4 +211,4 @@ Check [Vonage pricing](https://www.vonage.com/communications-apis/voice/pricing/
|
|||
- Test your Vonage integration with a simple workflow
|
||||
- Configure VAD settings for optimal voice detection
|
||||
- Set up monitoring and alerts
|
||||
- Explore advanced features like call recording
|
||||
- Explore advanced features like call recording
|
||||
|
|
|
|||
2
pipecat
2
pipecat
|
|
@ -1 +1 @@
|
|||
Subproject commit 57363002069be1d66336341923493705f6bcb141
|
||||
Subproject commit 12af7a65c576ce52225c735917e44075d202ab1a
|
||||
4
ui/package-lock.json
generated
4
ui/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "1.35.0",
|
||||
"version": "1.39.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
"version": "1.35.0",
|
||||
"version": "1.39.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export default function TelephonyConfigurationsPage() {
|
|||
const { user, getAccessToken, loading: authLoading } = useAuth();
|
||||
const {
|
||||
telnyxMissingWebhookPublicKeyCount,
|
||||
vonageMissingSignatureSecretCount,
|
||||
refresh: refreshWarnings,
|
||||
} = useTelephonyConfigWarnings();
|
||||
const [items, setItems] = useState<TelephonyConfigurationListItem[]>([]);
|
||||
|
|
@ -82,9 +83,9 @@ export default function TelephonyConfigurationsPage() {
|
|||
}
|
||||
}, [authLoading, user, getAccessToken]);
|
||||
|
||||
// After a save (create/update), the backing config may have flipped between
|
||||
// missing/present webhook_public_key — refresh the cached warning state so
|
||||
// the page banner and nav badge update without a manual reload.
|
||||
// After a save (create/update), webhook-verification warning state may have
|
||||
// changed — refresh the cached warning state so the page banner and nav badge
|
||||
// update without a manual reload.
|
||||
const onSaved = useCallback(async () => {
|
||||
await fetchItems();
|
||||
await refreshWarnings();
|
||||
|
|
@ -194,6 +195,26 @@ export default function TelephonyConfigurationsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{vonageMissingSignatureSecretCount > 0 && (
|
||||
<div className="mb-6 rounded-md border border-amber-300 bg-amber-50 p-4 text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium">Signature secret not configured</p>
|
||||
<p>
|
||||
{vonageMissingSignatureSecretCount === 1
|
||||
? "1 Vonage configuration is"
|
||||
: `${vonageMissingSignatureSecretCount} Vonage configurations are`}{" "}
|
||||
missing a signature secret. Without it, Vonage signed webhooks
|
||||
are rejected, so inbound calls and call status updates will not
|
||||
work. Copy the signature secret from your Vonage account and
|
||||
paste it into the affected Vonage configuration below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -5323,6 +5323,10 @@ export type TelephonyConfigWarningsResponse = {
|
|||
* Telnyx Missing Webhook Public Key Count
|
||||
*/
|
||||
telnyx_missing_webhook_public_key_count: number;
|
||||
/**
|
||||
* Vonage Missing Signature Secret Count
|
||||
*/
|
||||
vonage_missing_signature_secret_count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -6427,6 +6431,12 @@ export type VonageConfigurationRequest = {
|
|||
* Private key for JWT generation
|
||||
*/
|
||||
private_key: string;
|
||||
/**
|
||||
* Signature Secret
|
||||
*
|
||||
* Vonage signature secret used to verify signed webhooks
|
||||
*/
|
||||
signature_secret?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*
|
||||
|
|
@ -6461,6 +6471,10 @@ export type VonageConfigurationResponse = {
|
|||
* Private Key
|
||||
*/
|
||||
private_key: string;
|
||||
/**
|
||||
* Signature Secret
|
||||
*/
|
||||
signature_secret?: string | null;
|
||||
/**
|
||||
* From Numbers
|
||||
*/
|
||||
|
|
@ -7553,6 +7567,27 @@ export type HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostRespons
|
|||
200: unknown;
|
||||
};
|
||||
|
||||
export type HandleVonageEventsWithoutRunApiV1TelephonyVonageEventsPostData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/telephony/vonage/events';
|
||||
};
|
||||
|
||||
export type HandleVonageEventsWithoutRunApiV1TelephonyVonageEventsPostErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
};
|
||||
|
||||
export type HandleVonageEventsWithoutRunApiV1TelephonyVonageEventsPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type ImpersonateApiV1SuperuserImpersonatePostData = {
|
||||
body: ImpersonateRequest;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -167,8 +167,13 @@ export function AppSidebar() {
|
|||
const { provider, getSelectedTeam, logout, user } = useAuth();
|
||||
const { config } = useAppConfig();
|
||||
const { openHireExpert } = useLeadForms();
|
||||
const { telnyxMissingWebhookPublicKeyCount } = useTelephonyConfigWarnings();
|
||||
const hasTelephonyWarning = telnyxMissingWebhookPublicKeyCount > 0;
|
||||
const {
|
||||
telnyxMissingWebhookPublicKeyCount,
|
||||
vonageMissingSignatureSecretCount,
|
||||
} = useTelephonyConfigWarnings();
|
||||
const hasTelephonyWarning =
|
||||
telnyxMissingWebhookPublicKeyCount > 0 ||
|
||||
vonageMissingSignatureSecretCount > 0;
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
|
||||
// Get selected team for Stack auth (cast to Team type from Stack)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import { useAuth } from '@/lib/auth';
|
|||
|
||||
interface TelephonyConfigWarningsContextType {
|
||||
telnyxMissingWebhookPublicKeyCount: number;
|
||||
vonageMissingSignatureSecretCount: number;
|
||||
refresh: () => Promise<void>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const TelephonyConfigWarningsContext = createContext<TelephonyConfigWarningsContextType>({
|
||||
telnyxMissingWebhookPublicKeyCount: 0,
|
||||
vonageMissingSignatureSecretCount: 0,
|
||||
refresh: async () => { },
|
||||
loading: false,
|
||||
});
|
||||
|
|
@ -23,7 +25,8 @@ const TelephonyConfigWarningsContext = createContext<TelephonyConfigWarningsCont
|
|||
// change. Page-level callers invalidate via refresh() after a save.
|
||||
export function TelephonyConfigWarningsProvider({ children }: { children: ReactNode }) {
|
||||
const auth = useAuth();
|
||||
const [count, setCount] = useState(0);
|
||||
const [telnyxCount, setTelnyxCount] = useState(0);
|
||||
const [vonageCount, setVonageCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const hasFetched = useRef(false);
|
||||
|
||||
|
|
@ -31,9 +34,11 @@ export function TelephonyConfigWarningsProvider({ children }: { children: ReactN
|
|||
setLoading(true);
|
||||
try {
|
||||
const res = await getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet();
|
||||
setCount(res.data?.telnyx_missing_webhook_public_key_count ?? 0);
|
||||
setTelnyxCount(res.data?.telnyx_missing_webhook_public_key_count ?? 0);
|
||||
setVonageCount(res.data?.vonage_missing_signature_secret_count ?? 0);
|
||||
} catch {
|
||||
setCount(0);
|
||||
setTelnyxCount(0);
|
||||
setVonageCount(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -53,7 +58,8 @@ export function TelephonyConfigWarningsProvider({ children }: { children: ReactN
|
|||
return (
|
||||
<TelephonyConfigWarningsContext.Provider
|
||||
value={{
|
||||
telnyxMissingWebhookPublicKeyCount: count,
|
||||
telnyxMissingWebhookPublicKeyCount: telnyxCount,
|
||||
vonageMissingSignatureSecretCount: vonageCount,
|
||||
refresh,
|
||||
loading,
|
||||
}}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue