Merge remote-tracking branch 'origin/main' into feat/provisional-vad-v1

# Conflicts:
#	pipecat
This commit is contained in:
Abhishek Kumar 2026-06-29 17:28:42 +05:30
commit 5ea80506db
38 changed files with 1401 additions and 153 deletions

View file

@ -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]:

View file

@ -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,
)

View file

@ -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}")

View file

@ -11,6 +11,7 @@ from typing import Any
from loguru import logger
from api.services.pipecat.realtime.static_greeting import format_static_greeting_prompt
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
@ -49,6 +50,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
self._handled_initial_context: bool = False
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None
async def process_frame(self, frame: Frame, direction: FrameDirection):
if isinstance(frame, UserMuteStartedFrame):
@ -61,7 +63,11 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
return
if isinstance(frame, TTSSpeakFrame):
if not self._handled_initial_context:
await self._handle_context(self._context)
greeting_text = frame.text.strip() if frame.text else ""
if greeting_text:
await self._handle_initial_greeting(self._context, greeting_text)
else:
await self._handle_context(self._context)
else:
logger.warning(
f"{self}: TTSSpeakFrame after initial context already handled — "
@ -118,6 +124,57 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
self._context = context
await self._process_completed_function_calls(send_new_results=True)
async def _handle_initial_greeting(self, context: LLMContext, greeting_text: str):
if context is None:
logger.warning(
f"{self}: received initial greeting trigger before context was set"
)
return
self._handled_initial_context = True
self._context = context
await self._create_initial_greeting_response(greeting_text)
async def _create_initial_greeting_response(self, greeting_text: str):
if self._disconnecting:
return
if not self._api_session_ready:
self._pending_initial_greeting_text = greeting_text
self._run_llm_when_api_session_ready = True
return
self._pending_initial_greeting_text = None
await self._ensure_conversation_setup()
await self._send_manual_response_create(
instructions=format_static_greeting_prompt(greeting_text),
tool_choice="none",
)
async def _ensure_conversation_setup(self):
if not self._llm_needs_conversation_setup:
return
adapter = self.get_llm_adapter()
llm_invocation_params = adapter.get_llm_invocation_params(self._context)
for item in llm_invocation_params["messages"]:
evt = events.ConversationItemCreateEvent(item=item)
self._messages_added_manually[evt.item.id] = True
await self.send_client_event(evt)
await self._send_session_update()
self._llm_needs_conversation_setup = False
async def _handle_evt_session_updated(self, evt):
self._api_session_ready = True
if self._pending_initial_greeting_text is not None:
greeting_text = self._pending_initial_greeting_text
self._run_llm_when_api_session_ready = False
await self._create_initial_greeting_response(greeting_text)
elif self._run_llm_when_api_session_ready:
self._run_llm_when_api_session_ready = False
await self._create_response()
async def _send_user_audio(self, frame):
if self._user_is_muted:
return
@ -171,14 +228,21 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
return "\n".join(parts) if parts else None
return None
async def _send_manual_response_create(self):
async def _send_manual_response_create(
self,
*,
instructions: str | None = None,
tool_choice: str | None = None,
):
await self.push_frame(LLMFullResponseStartFrame())
await self.start_processing_metrics()
await self.start_ttfb_metrics()
await self.send_client_event(
events.ResponseCreateEvent(
response=events.ResponseProperties(
output_modalities=self._get_enabled_modalities()
output_modalities=self._get_enabled_modalities(),
instructions=instructions,
tool_choice=tool_choice,
)
)
)

View file

@ -20,11 +20,13 @@ Layers Dograh engine integration quirks onto upstream-pristine
from typing import Any
from google.genai.types import Content, Part
from loguru import logger
from api.services.pipecat.gemini_json_schema_adapter import (
DograhGeminiJSONSchemaAdapter,
)
from api.services.pipecat.realtime.static_greeting import format_static_greeting_prompt
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
Frame,
@ -63,6 +65,9 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# Function calls emitted by Gemini mid-bot-turn are deferred here and
# invoked when the turn ends, so they don't race the turn's audio.
self._pending_function_calls: list[FunctionCallFromLLM] = []
# Text greeting captured from the first TTSSpeakFrame while the Gemini
# session is still connecting.
self._pending_initial_greeting_text: str | None = None
# ------------------------------------------------------------------
# Hooks from upstream GeminiLiveLLMService
@ -142,10 +147,15 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
if isinstance(frame, TTSSpeakFrame):
# Greeting trigger: the engine queues a TTSSpeakFrame to start the
# bot's first turn after node setup. Gemini Live renders its own
# audio, so we don't pass the frame through — we re-enter
# _handle_context to kick off the initial response.
# audio, so we don't pass the frame through. For configured static
# text greetings, ask Gemini to say the exact greeting; otherwise
# re-enter _handle_context to kick off the normal initial response.
if not self._handled_initial_context:
await self._handle_context(self._context)
greeting_text = frame.text.strip() if frame.text else ""
if greeting_text:
await self._handle_initial_greeting(self._context, greeting_text)
else:
await self._handle_context(self._context)
else:
logger.warning(
f"{self}: TTSSpeakFrame after initial context already "
@ -183,6 +193,49 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
self._context = context
await self._process_completed_function_calls(send_new_results=True)
async def _handle_initial_greeting(self, context: LLMContext, greeting_text: str):
"""Trigger the first Gemini turn with an exact static text greeting."""
if context is None:
logger.warning(
f"{self}: received initial greeting trigger before context was set"
)
return
self._handled_initial_context = True
self._context = context
await self._create_initial_greeting_response(greeting_text)
async def _create_initial_greeting_response(self, greeting_text: str):
"""Ask Gemini Live to speak the configured greeting exactly once."""
if self._disconnecting:
return
if not self._session:
self._pending_initial_greeting_text = greeting_text
self._run_llm_when_session_ready = True
return
self._pending_initial_greeting_text = None
prompt = format_static_greeting_prompt(greeting_text)
turn = Content(role="user", parts=[Part(text=prompt)])
logger.debug("Creating Gemini Live initial response from static greeting")
await self.start_ttfb_metrics()
try:
await self._session.send_client_content(
turns=[turn],
turn_complete=True,
)
# Gemini 3.x also needs a realtime-input nudge to begin inference.
if self._is_gemini_3:
await self._session.send_realtime_input(text=" ")
except Exception as e:
await self._handle_send_error(e)
self._ready_for_realtime_input = True
# ------------------------------------------------------------------
# Session lifecycle: drop upstream's automatic reconnect-seed and
# initial-context-seed paths. The TTSSpeakFrame trigger and the
@ -201,7 +254,12 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# Context arrived before session was ready — fulfil the queued
# initial response now.
self._run_llm_when_session_ready = False
await self._create_initial_response()
if self._pending_initial_greeting_text is not None:
await self._create_initial_greeting_response(
self._pending_initial_greeting_text
)
else:
await self._create_initial_response()
await self._drain_pending_tool_results()
# Otherwise: no automatic seed. Reconnect after a session-resumption
# update relies on the server-side restored state; reconnects without

View file

@ -22,6 +22,7 @@ from typing import Any
from loguru import logger
from api.services.pipecat.realtime.static_greeting import format_static_greeting_prompt
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
@ -50,6 +51,7 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
self._handled_initial_context: bool = False
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None
async def process_frame(self, frame: Frame, direction: FrameDirection):
if isinstance(frame, UserMuteStartedFrame):
@ -62,7 +64,11 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
return
if isinstance(frame, TTSSpeakFrame):
if not self._handled_initial_context:
await self._handle_context(self._context)
greeting_text = frame.text.strip() if frame.text else ""
if greeting_text:
await self._handle_initial_greeting(self._context, greeting_text)
else:
await self._handle_context(self._context)
else:
logger.warning(
f"{self}: TTSSpeakFrame after initial context already "
@ -120,6 +126,67 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
self._context = context
await self._process_completed_function_calls(send_new_results=True)
async def _handle_initial_greeting(self, context: LLMContext, greeting_text: str):
if context is None:
logger.warning(
f"{self}: received initial greeting trigger before context was set"
)
return
self._handled_initial_context = True
self._context = context
await self._create_initial_greeting_response(greeting_text)
async def _create_initial_greeting_response(self, greeting_text: str):
if self._disconnecting:
return
if not self._api_session_ready:
self._pending_initial_greeting_text = greeting_text
self._run_llm_when_api_session_ready = True
return
self._pending_initial_greeting_text = None
await self._ensure_conversation_setup()
item = events.ConversationItem(
type="message",
role="user",
content=[
events.ItemContent(
type="input_text",
text=format_static_greeting_prompt(greeting_text),
)
],
)
evt = events.ConversationItemCreateEvent(item=item)
self._messages_added_manually[evt.item.id] = True
await self.send_client_event(evt)
await self._send_manual_response_create()
async def _ensure_conversation_setup(self):
if not self._llm_needs_conversation_setup:
return
adapter = self.get_llm_adapter()
llm_invocation_params = adapter.get_llm_invocation_params(self._context)
for item in llm_invocation_params["messages"]:
evt = events.ConversationItemCreateEvent(item=item)
self._messages_added_manually[evt.item.id] = True
await self.send_client_event(evt)
await self._send_session_update()
self._llm_needs_conversation_setup = False
async def _handle_evt_session_updated(self, evt):
self._api_session_ready = True
if self._pending_initial_greeting_text is not None:
greeting_text = self._pending_initial_greeting_text
self._run_llm_when_api_session_ready = False
await self._create_initial_greeting_response(greeting_text)
elif self._run_llm_when_api_session_ready:
self._run_llm_when_api_session_ready = False
await self._create_response()
async def _send_user_audio(self, frame):
if self._user_is_muted:
return

View file

@ -22,6 +22,7 @@ from typing import Any
from loguru import logger
from api.services.pipecat.realtime.static_greeting import format_static_greeting_prompt
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
@ -56,6 +57,7 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
# has finished speaking, matching Dograh's Gemini Live behavior.
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None
# ------------------------------------------------------------------
# Frame handling: mute, TTSSpeakFrame as greeting trigger
@ -73,11 +75,16 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
if isinstance(frame, TTSSpeakFrame):
# Greeting trigger: the engine queues a TTSSpeakFrame after node
# setup. OpenAI Realtime renders its own audio, so we don't pass
# the frame to TTS. Route through _handle_context so the initial
# response and later tool-result turns share the same context
# lifecycle even when Dograh has already pre-populated self._context.
# the frame to TTS. For configured static text greetings, ask the
# model to say the exact greeting; otherwise route through
# _handle_context so the initial response and later tool-result
# turns share the same context lifecycle.
if not self._handled_initial_context:
await self._handle_context(self._context)
greeting_text = frame.text.strip() if frame.text else ""
if greeting_text:
await self._handle_initial_greeting(self._context, greeting_text)
else:
await self._handle_context(self._context)
else:
logger.warning(
f"{self}: TTSSpeakFrame after initial context already "
@ -137,6 +144,57 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
self._context = context
await self._process_completed_function_calls(send_new_results=True)
async def _handle_initial_greeting(self, context: LLMContext, greeting_text: str):
if context is None:
logger.warning(
f"{self}: received initial greeting trigger before context was set"
)
return
self._handled_initial_context = True
self._context = context
await self._create_initial_greeting_response(greeting_text)
async def _create_initial_greeting_response(self, greeting_text: str):
if self._disconnecting:
return
if not self._api_session_ready:
self._pending_initial_greeting_text = greeting_text
self._run_llm_when_api_session_ready = True
return
self._pending_initial_greeting_text = None
await self._ensure_conversation_setup()
await self._send_manual_response_create(
instructions=format_static_greeting_prompt(greeting_text),
tool_choice="none",
)
async def _ensure_conversation_setup(self):
if not self._llm_needs_conversation_setup:
return
adapter = self.get_llm_adapter()
llm_invocation_params = adapter.get_llm_invocation_params(self._context)
for item in llm_invocation_params["messages"]:
evt = events.ConversationItemCreateEvent(item=item)
self._messages_added_manually[evt.item.id] = True
await self.send_client_event(evt)
await self._send_session_update()
self._llm_needs_conversation_setup = False
async def _handle_evt_session_updated(self, evt):
self._api_session_ready = True
if self._pending_initial_greeting_text is not None:
greeting_text = self._pending_initial_greeting_text
self._run_llm_when_api_session_ready = False
await self._create_initial_greeting_response(greeting_text)
elif self._run_llm_when_api_session_ready:
self._run_llm_when_api_session_ready = False
await self._create_response()
async def _send_user_audio(self, frame):
if self._user_is_muted:
return
@ -190,7 +248,12 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
return "\n".join(parts) if parts else None
return None
async def _send_manual_response_create(self):
async def _send_manual_response_create(
self,
*,
instructions: str | None = None,
tool_choice: str | None = None,
):
"""Trigger inference after manually appending conversation items."""
await self.push_frame(LLMFullResponseStartFrame())
await self.start_processing_metrics()
@ -198,7 +261,9 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
await self.send_client_event(
events.ResponseCreateEvent(
response=events.ResponseProperties(
output_modalities=self._get_enabled_modalities()
output_modalities=self._get_enabled_modalities(),
instructions=instructions,
tool_choice=tool_choice,
)
)
)

View file

@ -0,0 +1,8 @@
def format_static_greeting_prompt(greeting_text: str) -> str:
return (
"The phone call has just connected. Greet the caller now: "
"say the following opening line out loud, exactly as written, "
"in a natural spoken voice, and then stop and wait for the "
"caller to respond. Do not add anything before or after it.\n\n"
f'"{greeting_text}"'
)

View file

@ -72,7 +72,7 @@ class RealtimeFeedbackObserver(BaseObserver):
- TTFB metrics (LLM generation time only)
Logs buffer persistence (only final data for post-call analysis):
- Complete user transcripts per turn (via on_user_turn_stopped)
- Complete user transcripts per turn (via on_user_turn_message_added)
- Complete assistant transcripts per turn (via on_assistant_turn_stopped)
- Function calls and TTFB metrics
@ -300,13 +300,13 @@ def register_turn_log_handlers(
):
"""Register event handlers on aggregators to persist final turn transcripts.
Hooks into on_user_turn_stopped and on_assistant_turn_stopped to store
Hooks into on_user_turn_message_added and on_assistant_turn_stopped to store
complete turn text in the logs buffer. Works for both WebRTC and telephony
calls independent of WebSocket availability.
"""
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(aggregator, strategy, message):
@user_aggregator.event_handler("on_user_turn_message_added")
async def on_user_turn_message_added(aggregator, message):
logs_buffer.increment_turn()
try:
await logs_buffer.append(

View file

@ -113,49 +113,53 @@ def _resolve_user_turn_stop_timeout(
def _create_realtime_user_turn_config(provider: str):
"""Return user turn strategies and optional local VAD for realtime providers."""
def external_provider_turn_config():
return (
UserTurnStrategies(
start=[ExternalUserTurnStartStrategy()],
stop=[ExternalUserTurnStopStrategy(wait_for_transcript=False)],
),
None,
)
def local_vad_turn_config(*, enable_interruptions: bool):
return (
UserTurnStrategies(
start=[
VADUserTurnStartStrategy(enable_interruptions=enable_interruptions)
],
stop=[SpeechTimeoutUserTurnStopStrategy(wait_for_transcript=False)],
),
SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
)
if provider in {
ServiceProviders.GOOGLE_REALTIME.value,
ServiceProviders.GOOGLE_VERTEX_REALTIME.value,
}:
# Let Gemini Live own barge-in via its server-side VAD, but keep local
# Silero VAD for early user-turn start and speaking-state tracking.
return (
UserTurnStrategies(
start=[VADUserTurnStartStrategy(enable_interruptions=False)],
stop=[SpeechTimeoutUserTurnStopStrategy()],
),
SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
)
return local_vad_turn_config(enable_interruptions=False)
if provider == ServiceProviders.OPENAI_REALTIME.value:
# OpenAI Realtime already emits speaking-state frames and interruption
# events from the provider, so the aggregator should follow those
# external signals rather than run its own local VAD.
return (
UserTurnStrategies(
start=[ExternalUserTurnStartStrategy()],
stop=[ExternalUserTurnStopStrategy()],
),
None,
)
if provider in {
ServiceProviders.OPENAI_REALTIME.value,
ServiceProviders.AZURE_REALTIME.value,
}:
# OpenAI-compatible Realtime services already emit speaking-state frames
# and interruption events from the provider, so the aggregator should
# follow those external signals rather than run its own local VAD.
return external_provider_turn_config()
if provider == ServiceProviders.GROK_REALTIME.value:
# Grok Voice Agent emits server-side speech-start/stop and
# interruption signals, so local VAD should stay out of the way.
return (
UserTurnStrategies(
start=[ExternalUserTurnStartStrategy()],
stop=[ExternalUserTurnStopStrategy()],
),
None,
)
return external_provider_turn_config()
if provider == ServiceProviders.ULTRAVOX_REALTIME.value:
# Ultravox does not emit user-turn frames, so local VAD supplies
# lifecycle signals for Dograh observers/controllers.
return local_vad_turn_config(enable_interruptions=True)
return (
UserTurnStrategies(
start=[VADUserTurnStartStrategy()],
stop=[SpeechTimeoutUserTurnStopStrategy()],
),
SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
)
return local_vad_turn_config(enable_interruptions=True)
async def run_pipeline_telephony(
@ -773,7 +777,10 @@ async def _run_pipeline_impl(
vad_analyzer=user_vad_analyzer,
)
context_aggregator = LLMContextAggregatorPair(
context, assistant_params=assistant_params, user_params=user_params
context,
assistant_params=assistant_params,
user_params=user_params,
realtime_service_mode=is_realtime,
)
# Create usage metrics aggregator with engine's callback

View file

@ -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",

View file

@ -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]

View file

@ -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(

View file

@ -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"}

View file

@ -250,7 +250,6 @@ class _ToolDocumentRefsMixin(BaseModel):
"description": (
"Text spoken via TTS at the start of the call. Supports "
"{{template_variables}}. Leave empty to skip the greeting. "
"Not supported with realtime (speech-to-speech) models."
),
"display_options": DisplayOptions(show={"greeting_type": ["text"]}),
"placeholder": "Hi {{first_name}}, this is Sarah from Acme.",

View 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()

View file

@ -0,0 +1,88 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from pipecat.frames.frames import TTSSpeakFrame
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.openai.realtime import events
from api.services.pipecat.realtime.azure_realtime import (
DograhAzureRealtimeLLMService,
)
def _make_service() -> DograhAzureRealtimeLLMService:
service = DograhAzureRealtimeLLMService(
api_key="test-key",
base_url="wss://example.test/openai/realtime",
)
service._create_response = AsyncMock()
service._process_completed_function_calls = AsyncMock()
return service
@pytest.mark.asyncio
async def test_tts_greeting_sends_exact_static_greeting_prompt():
service = _make_service()
service._context = LLMContext([{"role": "user", "content": "Existing context"}])
service._api_session_ready = True
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service.process_frame(
TTSSpeakFrame("Hi Sam, this is Sarah from Acme.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert isinstance(sent_events[0], events.ConversationItemCreateEvent)
assert sent_events[0].item.role == "user"
assert sent_events[0].item.content[0].text == "Existing context"
assert isinstance(sent_events[1], events.SessionUpdateEvent)
response_event = sent_events[-1]
assert isinstance(response_event, events.ResponseCreateEvent)
assert response_event.response.tool_choice == "none"
prompt = response_event.response.instructions
assert "The phone call has just connected. Greet the caller now:" in prompt
assert prompt.endswith('"Hi Sam, this is Sarah from Acme."')
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()
@pytest.mark.asyncio
async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
service = _make_service()
service._context = LLMContext([{"role": "user", "content": "Existing context"}])
await service.process_frame(
TTSSpeakFrame("Hello from Dograh.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
assert service._handled_initial_context is True
assert service._run_llm_when_api_session_ready is True
assert service._pending_initial_greeting_text == "Hello from Dograh."
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service._handle_evt_session_updated(SimpleNamespace())
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert isinstance(sent_events[0], events.ConversationItemCreateEvent)
assert sent_events[0].item.content[0].text == "Existing context"
assert isinstance(sent_events[1], events.SessionUpdateEvent)
response_event = sent_events[-1]
assert isinstance(response_event, events.ResponseCreateEvent)
assert response_event.response.tool_choice == "none"
prompt = response_event.response.instructions
assert prompt.endswith('"Hello from Dograh."')
assert service._run_llm_when_api_session_ready is False
assert service._pending_initial_greeting_text is None
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()

View file

@ -3,7 +3,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from pipecat.frames.frames import TranscriptionFrame
from pipecat.frames.frames import TranscriptionFrame, TTSSpeakFrame
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
@ -21,6 +21,7 @@ class _TestDograhGeminiLiveLLMService(DograhGeminiLiveLLMService):
class _FakeSession:
def __init__(self):
self.send_client_content = AsyncMock()
self.send_tool_response = AsyncMock()
self.send_realtime_input = AsyncMock()
self.close = AsyncMock()
@ -108,3 +109,57 @@ async def test_user_transcription_matches_upstream_upstream_push_behavior():
assert frame.text == "Hi there"
assert frame.finalized is False
assert direction == FrameDirection.UPSTREAM
@pytest.mark.asyncio
async def test_tts_greeting_sends_exact_static_greeting_prompt_to_gemini():
service = _make_service()
service._context = LLMContext()
service._session = _FakeSession()
await service.process_frame(
TTSSpeakFrame("Hi Sam, this is Sarah from Acme.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
service._session.send_client_content.assert_awaited_once()
kwargs = service._session.send_client_content.await_args.kwargs
assert kwargs["turn_complete"] is True
turns = kwargs["turns"]
assert len(turns) == 1
assert turns[0].role == "user"
prompt = turns[0].parts[0].text
assert "The phone call has just connected. Greet the caller now:" in prompt
assert (
'Do not add anything before or after it.\n\n"Hi Sam, this is Sarah from Acme."'
in prompt
)
assert service._handled_initial_context is True
assert service._pending_initial_greeting_text is None
assert service._ready_for_realtime_input is True
@pytest.mark.asyncio
async def test_tts_greeting_waits_for_gemini_session_before_sending_prompt():
service = _make_service()
service._context = LLMContext()
await service.process_frame(
TTSSpeakFrame("Hello from Dograh.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
assert service._handled_initial_context is True
assert service._run_llm_when_session_ready is True
assert service._pending_initial_greeting_text == "Hello from Dograh."
session = _FakeSession()
await service._handle_session_ready(session)
session.send_client_content.assert_awaited_once()
prompt = session.send_client_content.await_args.kwargs["turns"][0].parts[0].text
assert prompt.endswith('"Hello from Dograh."')
assert service._run_llm_when_session_ready is False
assert service._pending_initial_greeting_text is None

View file

@ -37,17 +37,71 @@ async def test_initial_context_triggers_response_when_context_was_prepopulated()
@pytest.mark.asyncio
async def test_tts_greeting_uses_initial_context_handler():
async def test_tts_greeting_sends_exact_static_greeting_prompt():
service = _make_service()
service._context = LLMContext()
service._handle_context = AsyncMock()
service._context = LLMContext([{"role": "user", "content": "Existing context"}])
service._api_session_ready = True
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service.process_frame(
TTSSpeakFrame("hello", append_to_context=True),
TTSSpeakFrame("Hi Sam, this is Sarah from Acme.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
service._handle_context.assert_awaited_once_with(service._context)
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert isinstance(sent_events[0], events.ConversationItemCreateEvent)
assert sent_events[0].item.role == "user"
assert sent_events[0].item.content[0].text == "Existing context"
assert isinstance(sent_events[1], events.SessionUpdateEvent)
greeting_event = sent_events[2]
assert isinstance(greeting_event, events.ConversationItemCreateEvent)
assert greeting_event.item.role == "user"
assert greeting_event.item.type == "message"
prompt = greeting_event.item.content[0].text
assert "The phone call has just connected. Greet the caller now:" in prompt
assert prompt.endswith('"Hi Sam, this is Sarah from Acme."')
assert isinstance(sent_events[-1], events.ResponseCreateEvent)
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()
@pytest.mark.asyncio
async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
service = _make_service()
service._context = LLMContext([{"role": "user", "content": "Existing context"}])
await service.process_frame(
TTSSpeakFrame("Hello from Dograh.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
assert service._handled_initial_context is True
assert service._run_llm_when_api_session_ready is True
assert service._pending_initial_greeting_text == "Hello from Dograh."
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service._handle_evt_session_updated(SimpleNamespace())
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert isinstance(sent_events[0], events.ConversationItemCreateEvent)
assert sent_events[0].item.content[0].text == "Existing context"
assert isinstance(sent_events[1], events.SessionUpdateEvent)
greeting_event = sent_events[2]
assert isinstance(greeting_event, events.ConversationItemCreateEvent)
prompt = greeting_event.item.content[0].text
assert prompt.endswith('"Hello from Dograh."')
assert isinstance(sent_events[-1], events.ResponseCreateEvent)
assert service._run_llm_when_api_session_ready is False
assert service._pending_initial_greeting_text is None
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()
@pytest.mark.asyncio

View file

@ -5,6 +5,7 @@ import pytest
from pipecat.frames.frames import TTSSpeakFrame
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.openai.realtime import events
from api.services.pipecat.realtime.openai_realtime import (
DograhOpenAIRealtimeLLMService,
@ -48,17 +49,69 @@ async def test_updated_context_uses_tool_result_path_after_initial_context():
@pytest.mark.asyncio
async def test_tts_greeting_uses_initial_context_handler():
async def test_tts_greeting_sends_exact_static_greeting_prompt():
service = _make_service()
service._context = LLMContext()
service._handle_context = AsyncMock()
service._api_session_ready = True
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service.process_frame(
TTSSpeakFrame("hello", append_to_context=True),
TTSSpeakFrame("Hi Sam, this is Sarah from Acme.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
service._handle_context.assert_awaited_once_with(service._context)
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert not any(
isinstance(event, events.ConversationItemCreateEvent) for event in sent_events
)
assert isinstance(sent_events[0], events.SessionUpdateEvent)
response_event = sent_events[-1]
assert isinstance(response_event, events.ResponseCreateEvent)
assert response_event.response.tool_choice == "none"
prompt = response_event.response.instructions
assert "The phone call has just connected. Greet the caller now:" in prompt
assert prompt.endswith('"Hi Sam, this is Sarah from Acme."')
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()
@pytest.mark.asyncio
async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
service = _make_service()
service._context = LLMContext()
await service.process_frame(
TTSSpeakFrame("Hello from Dograh.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
assert service._handled_initial_context is True
assert service._run_llm_when_api_session_ready is True
assert service._pending_initial_greeting_text == "Hello from Dograh."
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service._handle_evt_session_updated(SimpleNamespace())
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert not any(
isinstance(event, events.ConversationItemCreateEvent) for event in sent_events
)
assert isinstance(sent_events[0], events.SessionUpdateEvent)
response_event = sent_events[-1]
assert isinstance(response_event, events.ResponseCreateEvent)
assert response_event.response.tool_choice == "none"
prompt = response_event.response.instructions
assert prompt.endswith('"Hello from Dograh."')
assert service._run_llm_when_api_session_ready is False
assert service._pending_initial_greeting_text is None
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()

View file

@ -7,7 +7,23 @@ from pipecat.processors.frame_processor import FrameDirection
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import TransportParams
from api.services.pipecat.realtime_feedback_observer import RealtimeFeedbackObserver
from api.services.pipecat.in_memory_buffers import InMemoryLogsBuffer
from api.services.pipecat.realtime_feedback_observer import (
RealtimeFeedbackObserver,
register_turn_log_handlers,
)
class _FakeAggregator:
def __init__(self):
self.handlers = {}
def event_handler(self, event_name):
def decorator(handler):
self.handlers[event_name] = handler
return handler
return decorator
def _frame_pushed(frame, direction, *, source=None):
@ -98,3 +114,33 @@ async def test_observer_waits_for_tts_text_from_output_transport():
"payload": {"text": "Hello"},
}
]
@pytest.mark.asyncio
async def test_turn_log_handlers_persist_user_message_added_events():
logs_buffer = InMemoryLogsBuffer(workflow_run_id=123)
user_aggregator = _FakeAggregator()
assistant_aggregator = _FakeAggregator()
register_turn_log_handlers(logs_buffer, user_aggregator, assistant_aggregator)
assert "on_user_turn_message_added" in user_aggregator.handlers
assert "on_user_turn_stopped" not in user_aggregator.handlers
await user_aggregator.handlers["on_user_turn_message_added"](
user_aggregator,
SimpleNamespace(
content="Hi there",
timestamp="2026-01-01T00:00:00+00:00",
),
)
events = logs_buffer.get_events()
assert len(events) == 1
assert events[0]["type"] == "rtf-user-transcription"
assert events[0]["payload"] == {
"text": "Hi there",
"final": True,
"timestamp": "2026-01-01T00:00:00+00:00",
}
assert events[0]["turn"] == 1

View file

@ -30,6 +30,7 @@ def test_gemini_realtime_uses_local_vad_without_local_interruptions():
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_gemini_vertex_realtime_uses_same_turn_config_as_gemini_live():
@ -41,6 +42,9 @@ def test_gemini_vertex_realtime_uses_same_turn_config_as_gemini_live():
assert len(strategies.start) == 1
assert isinstance(strategies.start[0], VADUserTurnStartStrategy)
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_openai_realtime_uses_provider_turn_frames_without_local_vad():
@ -54,6 +58,21 @@ def test_openai_realtime_uses_provider_turn_frames_without_local_vad():
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], ExternalUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_azure_realtime_uses_provider_turn_frames_without_local_vad():
strategies, vad_analyzer = _create_realtime_user_turn_config(
ServiceProviders.AZURE_REALTIME.value
)
assert vad_analyzer is None
assert len(strategies.start) == 1
assert isinstance(strategies.start[0], ExternalUserTurnStartStrategy)
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], ExternalUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_grok_realtime_uses_provider_turn_frames_without_local_vad():
@ -67,6 +86,21 @@ def test_grok_realtime_uses_provider_turn_frames_without_local_vad():
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], ExternalUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_ultravox_realtime_uses_local_vad_with_local_interruptions():
strategies, vad_analyzer = _create_realtime_user_turn_config(
ServiceProviders.ULTRAVOX_REALTIME.value
)
assert isinstance(vad_analyzer, SileroVADAnalyzer)
assert len(strategies.start) == 1
assert isinstance(strategies.start[0], VADUserTurnStartStrategy)
assert strategies.start[0]._enable_interruptions is True
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_unknown_realtime_providers_keep_local_vad():
@ -75,8 +109,10 @@ def test_unknown_realtime_providers_keep_local_vad():
assert isinstance(vad_analyzer, SileroVADAnalyzer)
assert len(strategies.start) == 1
assert isinstance(strategies.start[0], VADUserTurnStartStrategy)
assert strategies.start[0]._enable_interruptions is True
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_external_turn_stt_uses_longer_stop_timeout():

View file

@ -0,0 +1,38 @@
from api.utils.template_renderer import render_template
def test_initial_context_prefix_resolves_against_flat_context():
context = {
"first_name": "Abhishek",
"runtime_configuration": {
"realtime_model": "gpt-realtime-2",
},
}
assert (
render_template("Hi {{initial_context.first_name | there}}", context)
== "Hi Abhishek"
)
assert (
render_template(
"Model {{initial_context.runtime_configuration.realtime_model}}", context
)
== "Model gpt-realtime-2"
)
def test_initial_context_prefix_prefers_explicit_initial_context():
context = {
"first_name": "Flat",
"initial_context": {
"first_name": "Nested",
},
}
assert render_template("Hi {{initial_context.first_name}}", context) == "Hi Nested"
def test_initial_context_prefix_uses_fallback_when_missing_from_both_contexts():
assert (
render_template("Hi {{initial_context.first_name | there}}", {}) == "Hi there"
)

View file

@ -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():

View file

@ -12,6 +12,7 @@ from api.services.workflow.workflow_graph import TEMPLATE_VAR_PATTERN
_CURRENT_TIME_PREFIX = "current_time"
_CURRENT_WEEKDAY_PREFIX = "current_weekday"
_INITIAL_CONTEXT_PREFIX = "initial_context."
def get_nested_value(obj: Any, path: str) -> Any:
@ -184,8 +185,14 @@ def _render_string(template_str: str, context: Dict[str, Any]) -> str:
if builtin_value is not None:
return builtin_value
# Get value using nested path lookup
# Get value using nested path lookup. Prompts commonly reference
# initial_context.<key>, while some runtime callers pass the initial
# context itself as the render context.
value = get_nested_value(context, variable_path)
if value is None and variable_path.startswith(_INITIAL_CONTEXT_PREFIX):
value = get_nested_value(
context, variable_path[len(_INITIAL_CONTEXT_PREFIX) :]
)
# Apply fallback: new syntax {{var | default}} or legacy {{var | fallback:default}}
if filter_name is not None:

File diff suppressed because one or more lines are too long

View file

@ -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

@ -1 +1 @@
Subproject commit 5a4fa626cfba4f4509f7744b13bb83adae0992fe
Subproject commit 18fab582994b919a833379253d6cd1caab822ef6

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.MU08hRFPE8
# timestamp: 2026-06-26T11:33:34+00:00
# filename: dograh-openapi-XXXXXX.json.IQaxuC56sd
# timestamp: 2026-06-29T10:55:38+00:00
from __future__ import annotations

View file

@ -65,8 +65,7 @@ class StartCall(TypedNode):
greeting: Optional[str] = None
"""
Text spoken via TTS at the start of the call. Supports
{{template_variables}}. Leave empty to skip the greeting. Not supported
with realtime (speech-to-speech) models.
{{template_variables}}. Leave empty to skip the greeting.
"""
greeting_recording_id: Optional[str] = None

View file

@ -38,7 +38,7 @@ export interface StartCall {
*/
greeting_type?: "text" | "audio";
/**
* Text spoken via TTS at the start of the call. Supports {{template_variables}}. Leave empty to skip the greeting. Not supported with realtime (speech-to-speech) models.
* Text spoken via TTS at the start of the call. Supports {{template_variables}}. Leave empty to skip the greeting.
*/
greeting?: string;
/**

4
ui/package-lock.json generated
View file

@ -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",

View file

@ -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

View file

@ -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?: {

View file

@ -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)

View file

@ -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,
}}