dv-pipecat-ai 0.0.85.dev5__py3-none-any.whl → 0.0.85.dev698__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of dv-pipecat-ai might be problematic. Click here for more details.

Files changed (157) hide show
  1. {dv_pipecat_ai-0.0.85.dev5.dist-info → dv_pipecat_ai-0.0.85.dev698.dist-info}/METADATA +78 -117
  2. {dv_pipecat_ai-0.0.85.dev5.dist-info → dv_pipecat_ai-0.0.85.dev698.dist-info}/RECORD +157 -123
  3. pipecat/adapters/base_llm_adapter.py +38 -1
  4. pipecat/adapters/services/anthropic_adapter.py +9 -14
  5. pipecat/adapters/services/aws_nova_sonic_adapter.py +5 -0
  6. pipecat/adapters/services/bedrock_adapter.py +236 -13
  7. pipecat/adapters/services/gemini_adapter.py +12 -8
  8. pipecat/adapters/services/open_ai_adapter.py +19 -7
  9. pipecat/adapters/services/open_ai_realtime_adapter.py +5 -0
  10. pipecat/audio/filters/krisp_viva_filter.py +193 -0
  11. pipecat/audio/filters/noisereduce_filter.py +15 -0
  12. pipecat/audio/turn/base_turn_analyzer.py +9 -1
  13. pipecat/audio/turn/smart_turn/base_smart_turn.py +14 -8
  14. pipecat/audio/turn/smart_turn/data/__init__.py +0 -0
  15. pipecat/audio/turn/smart_turn/data/smart-turn-v3.0.onnx +0 -0
  16. pipecat/audio/turn/smart_turn/http_smart_turn.py +6 -2
  17. pipecat/audio/turn/smart_turn/local_smart_turn.py +1 -1
  18. pipecat/audio/turn/smart_turn/local_smart_turn_v2.py +1 -1
  19. pipecat/audio/turn/smart_turn/local_smart_turn_v3.py +124 -0
  20. pipecat/audio/vad/data/README.md +10 -0
  21. pipecat/audio/vad/vad_analyzer.py +13 -1
  22. pipecat/extensions/voicemail/voicemail_detector.py +5 -5
  23. pipecat/frames/frames.py +120 -87
  24. pipecat/observers/loggers/debug_log_observer.py +3 -3
  25. pipecat/observers/loggers/llm_log_observer.py +7 -3
  26. pipecat/observers/loggers/user_bot_latency_log_observer.py +22 -10
  27. pipecat/pipeline/runner.py +12 -4
  28. pipecat/pipeline/service_switcher.py +64 -36
  29. pipecat/pipeline/task.py +85 -24
  30. pipecat/processors/aggregators/dtmf_aggregator.py +28 -22
  31. pipecat/processors/aggregators/{gated_openai_llm_context.py → gated_llm_context.py} +9 -9
  32. pipecat/processors/aggregators/gated_open_ai_llm_context.py +12 -0
  33. pipecat/processors/aggregators/llm_response.py +6 -7
  34. pipecat/processors/aggregators/llm_response_universal.py +19 -15
  35. pipecat/processors/aggregators/user_response.py +6 -6
  36. pipecat/processors/aggregators/vision_image_frame.py +24 -2
  37. pipecat/processors/audio/audio_buffer_processor.py +43 -8
  38. pipecat/processors/filters/stt_mute_filter.py +2 -0
  39. pipecat/processors/frame_processor.py +103 -17
  40. pipecat/processors/frameworks/langchain.py +8 -2
  41. pipecat/processors/frameworks/rtvi.py +209 -68
  42. pipecat/processors/frameworks/strands_agents.py +170 -0
  43. pipecat/processors/logger.py +2 -2
  44. pipecat/processors/transcript_processor.py +4 -4
  45. pipecat/processors/user_idle_processor.py +3 -6
  46. pipecat/runner/run.py +270 -50
  47. pipecat/runner/types.py +2 -0
  48. pipecat/runner/utils.py +51 -10
  49. pipecat/serializers/exotel.py +5 -5
  50. pipecat/serializers/livekit.py +20 -0
  51. pipecat/serializers/plivo.py +6 -9
  52. pipecat/serializers/protobuf.py +6 -5
  53. pipecat/serializers/telnyx.py +2 -2
  54. pipecat/serializers/twilio.py +43 -23
  55. pipecat/services/ai_service.py +2 -6
  56. pipecat/services/anthropic/llm.py +2 -25
  57. pipecat/services/asyncai/tts.py +2 -3
  58. pipecat/services/aws/__init__.py +1 -0
  59. pipecat/services/aws/llm.py +122 -97
  60. pipecat/services/aws/nova_sonic/__init__.py +0 -0
  61. pipecat/services/aws/nova_sonic/context.py +367 -0
  62. pipecat/services/aws/nova_sonic/frames.py +25 -0
  63. pipecat/services/aws/nova_sonic/llm.py +1155 -0
  64. pipecat/services/aws/stt.py +1 -3
  65. pipecat/services/aws_nova_sonic/__init__.py +19 -1
  66. pipecat/services/aws_nova_sonic/aws.py +11 -1151
  67. pipecat/services/aws_nova_sonic/context.py +13 -355
  68. pipecat/services/aws_nova_sonic/frames.py +13 -17
  69. pipecat/services/azure/realtime/__init__.py +0 -0
  70. pipecat/services/azure/realtime/llm.py +65 -0
  71. pipecat/services/azure/stt.py +15 -0
  72. pipecat/services/cartesia/tts.py +2 -2
  73. pipecat/services/deepgram/__init__.py +1 -0
  74. pipecat/services/deepgram/flux/__init__.py +0 -0
  75. pipecat/services/deepgram/flux/stt.py +636 -0
  76. pipecat/services/elevenlabs/__init__.py +2 -1
  77. pipecat/services/elevenlabs/stt.py +254 -276
  78. pipecat/services/elevenlabs/tts.py +5 -5
  79. pipecat/services/fish/tts.py +2 -2
  80. pipecat/services/gemini_multimodal_live/events.py +38 -524
  81. pipecat/services/gemini_multimodal_live/file_api.py +23 -173
  82. pipecat/services/gemini_multimodal_live/gemini.py +41 -1403
  83. pipecat/services/gladia/stt.py +56 -72
  84. pipecat/services/google/__init__.py +1 -0
  85. pipecat/services/google/gemini_live/__init__.py +3 -0
  86. pipecat/services/google/gemini_live/file_api.py +189 -0
  87. pipecat/services/google/gemini_live/llm.py +1582 -0
  88. pipecat/services/google/gemini_live/llm_vertex.py +184 -0
  89. pipecat/services/google/llm.py +15 -11
  90. pipecat/services/google/llm_openai.py +3 -3
  91. pipecat/services/google/llm_vertex.py +86 -16
  92. pipecat/services/google/tts.py +7 -3
  93. pipecat/services/heygen/api.py +2 -0
  94. pipecat/services/heygen/client.py +8 -4
  95. pipecat/services/heygen/video.py +2 -0
  96. pipecat/services/hume/__init__.py +5 -0
  97. pipecat/services/hume/tts.py +220 -0
  98. pipecat/services/inworld/tts.py +6 -6
  99. pipecat/services/llm_service.py +15 -5
  100. pipecat/services/lmnt/tts.py +2 -2
  101. pipecat/services/mcp_service.py +4 -2
  102. pipecat/services/mem0/memory.py +6 -5
  103. pipecat/services/mistral/llm.py +29 -8
  104. pipecat/services/moondream/vision.py +42 -16
  105. pipecat/services/neuphonic/tts.py +2 -2
  106. pipecat/services/openai/__init__.py +1 -0
  107. pipecat/services/openai/base_llm.py +27 -20
  108. pipecat/services/openai/realtime/__init__.py +0 -0
  109. pipecat/services/openai/realtime/context.py +272 -0
  110. pipecat/services/openai/realtime/events.py +1106 -0
  111. pipecat/services/openai/realtime/frames.py +37 -0
  112. pipecat/services/openai/realtime/llm.py +829 -0
  113. pipecat/services/openai/tts.py +16 -8
  114. pipecat/services/openai_realtime/__init__.py +27 -0
  115. pipecat/services/openai_realtime/azure.py +21 -0
  116. pipecat/services/openai_realtime/context.py +21 -0
  117. pipecat/services/openai_realtime/events.py +21 -0
  118. pipecat/services/openai_realtime/frames.py +21 -0
  119. pipecat/services/openai_realtime_beta/azure.py +16 -0
  120. pipecat/services/openai_realtime_beta/openai.py +17 -5
  121. pipecat/services/playht/tts.py +31 -4
  122. pipecat/services/rime/tts.py +3 -4
  123. pipecat/services/sarvam/tts.py +2 -6
  124. pipecat/services/simli/video.py +2 -2
  125. pipecat/services/speechmatics/stt.py +1 -7
  126. pipecat/services/stt_service.py +34 -0
  127. pipecat/services/tavus/video.py +2 -2
  128. pipecat/services/tts_service.py +9 -9
  129. pipecat/services/vision_service.py +7 -6
  130. pipecat/services/vistaar/llm.py +4 -0
  131. pipecat/tests/utils.py +4 -4
  132. pipecat/transcriptions/language.py +41 -1
  133. pipecat/transports/base_input.py +17 -42
  134. pipecat/transports/base_output.py +42 -26
  135. pipecat/transports/daily/transport.py +199 -26
  136. pipecat/transports/heygen/__init__.py +0 -0
  137. pipecat/transports/heygen/transport.py +381 -0
  138. pipecat/transports/livekit/transport.py +228 -63
  139. pipecat/transports/local/audio.py +6 -1
  140. pipecat/transports/local/tk.py +11 -2
  141. pipecat/transports/network/fastapi_websocket.py +1 -1
  142. pipecat/transports/smallwebrtc/connection.py +98 -19
  143. pipecat/transports/smallwebrtc/request_handler.py +204 -0
  144. pipecat/transports/smallwebrtc/transport.py +65 -23
  145. pipecat/transports/tavus/transport.py +23 -12
  146. pipecat/transports/websocket/client.py +41 -5
  147. pipecat/transports/websocket/fastapi.py +21 -11
  148. pipecat/transports/websocket/server.py +14 -7
  149. pipecat/transports/whatsapp/api.py +8 -0
  150. pipecat/transports/whatsapp/client.py +47 -0
  151. pipecat/utils/base_object.py +54 -22
  152. pipecat/utils/string.py +12 -1
  153. pipecat/utils/tracing/service_decorators.py +21 -21
  154. {dv_pipecat_ai-0.0.85.dev5.dist-info → dv_pipecat_ai-0.0.85.dev698.dist-info}/WHEEL +0 -0
  155. {dv_pipecat_ai-0.0.85.dev5.dist-info → dv_pipecat_ai-0.0.85.dev698.dist-info}/licenses/LICENSE +0 -0
  156. {dv_pipecat_ai-0.0.85.dev5.dist-info → dv_pipecat_ai-0.0.85.dev698.dist-info}/top_level.txt +0 -0
  157. /pipecat/services/{aws_nova_sonic → aws/nova_sonic}/ready.wav +0 -0
@@ -25,11 +25,11 @@ from pipecat.frames.frames import (
25
25
  EndFrame,
26
26
  Frame,
27
27
  InputAudioRawFrame,
28
+ InterruptionFrame,
28
29
  OutputAudioRawFrame,
30
+ OutputTransportMessageFrame,
31
+ OutputTransportMessageUrgentFrame,
29
32
  StartFrame,
30
- StartInterruptionFrame,
31
- TransportMessageFrame,
32
- TransportMessageUrgentFrame,
33
33
  )
34
34
  from pipecat.processors.frame_processor import FrameDirection, FrameProcessor, FrameProcessorSetup
35
35
  from pipecat.transports.base_input import BaseInputTransport
@@ -221,6 +221,7 @@ class TavusTransportClient:
221
221
  ),
222
222
  on_joined=self._on_joined,
223
223
  on_left=self._on_left,
224
+ on_before_leave=partial(self._on_handle_callback, "on_before_leave"),
224
225
  on_error=partial(self._on_handle_callback, "on_error"),
225
226
  on_app_message=partial(self._on_handle_callback, "on_app_message"),
226
227
  on_call_state_updated=partial(self._on_handle_callback, "on_call_state_updated"),
@@ -344,7 +345,9 @@ class TavusTransportClient:
344
345
  participant_id, callback, audio_source, sample_rate, callback_interval_ms
345
346
  )
346
347
 
347
- async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
348
+ async def send_message(
349
+ self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame
350
+ ):
348
351
  """Send a message to participants.
349
352
 
350
353
  Args:
@@ -372,7 +375,7 @@ class TavusTransportClient:
372
375
 
373
376
  async def send_interrupt_message(self) -> None:
374
377
  """Send an interrupt message to the conversation."""
375
- transport_frame = TransportMessageUrgentFrame(
378
+ transport_frame = OutputTransportMessageUrgentFrame(
376
379
  message={
377
380
  "message_type": "conversation",
378
381
  "event_type": "conversation.interrupt",
@@ -395,15 +398,18 @@ class TavusTransportClient:
395
398
  participant_settings=participant_settings, profile_settings=profile_settings
396
399
  )
397
400
 
398
- async def write_audio_frame(self, frame: OutputAudioRawFrame):
401
+ async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool:
399
402
  """Write an audio frame to the transport.
400
403
 
401
404
  Args:
402
405
  frame: The audio frame to write.
406
+
407
+ Returns:
408
+ True if the audio frame was written successfully, False otherwise.
403
409
  """
404
410
  if not self._client:
405
- return
406
- await self._client.write_audio_frame(frame)
411
+ return False
412
+ return await self._client.write_audio_frame(frame)
407
413
 
408
414
  async def register_audio_destination(self, destination: str):
409
415
  """Register an audio destination for output.
@@ -601,7 +607,9 @@ class TavusOutputTransport(BaseOutputTransport):
601
607
  await super().cancel(frame)
602
608
  await self._client.stop()
603
609
 
604
- async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
610
+ async def send_message(
611
+ self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame
612
+ ):
605
613
  """Send a message to participants.
606
614
 
607
615
  Args:
@@ -618,22 +626,25 @@ class TavusOutputTransport(BaseOutputTransport):
618
626
  direction: The direction of frame flow in the pipeline.
619
627
  """
620
628
  await super().process_frame(frame, direction)
621
- if isinstance(frame, StartInterruptionFrame):
629
+ if isinstance(frame, InterruptionFrame):
622
630
  await self._handle_interruptions()
623
631
 
624
632
  async def _handle_interruptions(self):
625
633
  """Handle interruption events by sending interrupt message."""
626
634
  await self._client.send_interrupt_message()
627
635
 
628
- async def write_audio_frame(self, frame: OutputAudioRawFrame):
636
+ async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool:
629
637
  """Write an audio frame to the Tavus transport.
630
638
 
631
639
  Args:
632
640
  frame: The audio frame to write.
641
+
642
+ Returns:
643
+ True if the audio frame was written successfully, False otherwise.
633
644
  """
634
645
  # This is the custom track destination expected by Tavus
635
646
  frame.transport_destination = self._transport_destination
636
- await self._client.write_audio_frame(frame)
647
+ return await self._client.write_audio_frame(frame)
637
648
 
638
649
  async def register_audio_destination(self, destination: str):
639
650
  """Register an audio destination.
@@ -28,9 +28,9 @@ from pipecat.frames.frames import (
28
28
  Frame,
29
29
  InputAudioRawFrame,
30
30
  OutputAudioRawFrame,
31
+ OutputTransportMessageFrame,
32
+ OutputTransportMessageUrgentFrame,
31
33
  StartFrame,
32
- TransportMessageFrame,
33
- TransportMessageUrgentFrame,
34
34
  )
35
35
  from pipecat.processors.frame_processor import FrameProcessorSetup
36
36
  from pipecat.serializers.base_serializer import FrameSerializer
@@ -150,17 +150,39 @@ class WebsocketClientSession:
150
150
  await self._websocket.close()
151
151
  self._websocket = None
152
152
 
153
- async def send(self, message: websockets.Data):
153
+ async def send(self, message: websockets.Data) -> bool:
154
154
  """Send a message through the WebSocket connection.
155
155
 
156
156
  Args:
157
157
  message: The message data to send.
158
158
  """
159
+ result = False
159
160
  try:
160
161
  if self._websocket:
161
162
  await self._websocket.send(message)
163
+ result = True
162
164
  except Exception as e:
163
165
  logger.error(f"{self} exception sending data: {e.__class__.__name__} ({e})")
166
+ finally:
167
+ return result
168
+
169
+ @property
170
+ def is_connected(self) -> bool:
171
+ """Check if the WebSocket is currently connected.
172
+
173
+ Returns:
174
+ True if the WebSocket is in connected state.
175
+ """
176
+ return self._websocket.state == websockets.State.OPEN if self._websocket else False
177
+
178
+ @property
179
+ def is_closing(self) -> bool:
180
+ """Check if the WebSocket is currently closing.
181
+
182
+ Returns:
183
+ True if the WebSocket is in the process of closing.
184
+ """
185
+ return self._websocket.state == websockets.State.CLOSING if self._websocket else False
164
186
 
165
187
  async def _client_task_handler(self):
166
188
  """Handle incoming messages from the WebSocket connection."""
@@ -363,7 +385,9 @@ class WebsocketClientOutputTransport(BaseOutputTransport):
363
385
  await super().cleanup()
364
386
  await self._transport.cleanup()
365
387
 
366
- async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
388
+ async def send_message(
389
+ self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame
390
+ ):
367
391
  """Send a transport message through the WebSocket.
368
392
 
369
393
  Args:
@@ -371,12 +395,18 @@ class WebsocketClientOutputTransport(BaseOutputTransport):
371
395
  """
372
396
  await self._write_frame(frame)
373
397
 
374
- async def write_audio_frame(self, frame: OutputAudioRawFrame):
398
+ async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool:
375
399
  """Write an audio frame to the WebSocket with optional WAV header.
376
400
 
377
401
  Args:
378
402
  frame: The output audio frame to write.
403
+
404
+ Returns:
405
+ True if the audio frame was written successfully, False otherwise.
379
406
  """
407
+ if self._session.is_closing or not self._session.is_connected:
408
+ return False
409
+
380
410
  frame = OutputAudioRawFrame(
381
411
  audio=frame.audio,
382
412
  sample_rate=self.sample_rate,
@@ -402,10 +432,16 @@ class WebsocketClientOutputTransport(BaseOutputTransport):
402
432
  # Simulate audio playback with a sleep.
403
433
  await self._write_audio_sleep()
404
434
 
435
+ return True
436
+
405
437
  async def _write_frame(self, frame: Frame):
406
438
  """Write a frame to the WebSocket after serialization."""
439
+ if self._session.is_closing or not self._session.is_connected:
440
+ return
441
+
407
442
  if not self._params.serializer:
408
443
  return
444
+
409
445
  payload = await self._params.serializer.serialize(frame)
410
446
  if payload:
411
447
  await self._session.send(payload)
@@ -26,11 +26,11 @@ from pipecat.frames.frames import (
26
26
  EndFrame,
27
27
  Frame,
28
28
  InputAudioRawFrame,
29
+ InterruptionFrame,
29
30
  OutputAudioRawFrame,
31
+ OutputTransportMessageFrame,
32
+ OutputTransportMessageUrgentFrame,
30
33
  StartFrame,
31
- StartInterruptionFrame,
32
- TransportMessageFrame,
33
- TransportMessageUrgentFrame,
34
34
  )
35
35
  from pipecat.processors.frame_processor import FrameDirection
36
36
  from pipecat.serializers.base_serializer import FrameSerializer, FrameSerializerType
@@ -150,7 +150,6 @@ class FastAPIWebsocketClient:
150
150
  "Closing already disconnected websocket!", call_id=self._conversation_id
151
151
  )
152
152
  self._closing = True
153
- await self.trigger_client_disconnected()
154
153
 
155
154
  async def disconnect(self):
156
155
  """Disconnect the WebSocket client."""
@@ -164,8 +163,6 @@ class FastAPIWebsocketClient:
164
163
  await self._websocket.close()
165
164
  except Exception as e:
166
165
  logger.error(f"{self} exception while closing the websocket: {e}")
167
- finally:
168
- await self.trigger_client_disconnected()
169
166
 
170
167
  async def trigger_client_disconnected(self):
171
168
  """Trigger the client disconnected callback."""
@@ -310,7 +307,10 @@ class FastAPIWebsocketInputTransport(BaseInputTransport):
310
307
  except Exception as e:
311
308
  logger.error(f"{self} exception receiving data: {e.__class__.__name__} ({e})")
312
309
 
313
- await self._client.trigger_client_disconnected()
310
+ # Trigger `on_client_disconnected` if the client actually disconnects,
311
+ # that is, we are not the ones disconnecting.
312
+ if not self._client.is_closing:
313
+ await self._client.trigger_client_disconnected()
314
314
 
315
315
  async def _monitor_websocket(self):
316
316
  """Wait for self._params.session_timeout seconds, if the websocket is still open, trigger timeout event."""
@@ -410,11 +410,13 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport):
410
410
  """
411
411
  await super().process_frame(frame, direction)
412
412
 
413
- if isinstance(frame, StartInterruptionFrame):
413
+ if isinstance(frame, InterruptionFrame):
414
414
  await self._write_frame(frame)
415
415
  self._next_send_time = 0
416
416
 
417
- async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
417
+ async def send_message(
418
+ self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame
419
+ ):
418
420
  """Send a transport message frame.
419
421
 
420
422
  Args:
@@ -422,14 +424,17 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport):
422
424
  """
423
425
  await self._write_frame(frame)
424
426
 
425
- async def write_audio_frame(self, frame: OutputAudioRawFrame):
427
+ async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool:
426
428
  """Write an audio frame to the WebSocket with timing simulation.
427
429
 
428
430
  Args:
429
431
  frame: The output audio frame to write.
432
+
433
+ Returns:
434
+ True if the audio frame was written successfully, False otherwise.
430
435
  """
431
436
  if self._client.is_closing or not self._client.is_connected:
432
- return
437
+ return False
433
438
 
434
439
  frame = OutputAudioRawFrame(
435
440
  audio=frame.audio,
@@ -456,8 +461,13 @@ class FastAPIWebsocketOutputTransport(BaseOutputTransport):
456
461
  # Simulate audio playback with a sleep.
457
462
  await self._write_audio_sleep()
458
463
 
464
+ return True
465
+
459
466
  async def _write_frame(self, frame: Frame):
460
467
  """Serialize and send a frame through the WebSocket."""
468
+ if self._client.is_closing or not self._client.is_connected:
469
+ return
470
+
461
471
  if not self._params.serializer:
462
472
  return
463
473
 
@@ -25,11 +25,11 @@ from pipecat.frames.frames import (
25
25
  EndFrame,
26
26
  Frame,
27
27
  InputAudioRawFrame,
28
+ InterruptionFrame,
28
29
  OutputAudioRawFrame,
30
+ OutputTransportMessageFrame,
31
+ OutputTransportMessageUrgentFrame,
29
32
  StartFrame,
30
- StartInterruptionFrame,
31
- TransportMessageFrame,
32
- TransportMessageUrgentFrame,
33
33
  )
34
34
  from pipecat.processors.frame_processor import FrameDirection
35
35
  from pipecat.serializers.base_serializer import FrameSerializer
@@ -334,11 +334,13 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
334
334
  """
335
335
  await super().process_frame(frame, direction)
336
336
 
337
- if isinstance(frame, StartInterruptionFrame):
337
+ if isinstance(frame, InterruptionFrame):
338
338
  await self._write_frame(frame)
339
339
  self._next_send_time = 0
340
340
 
341
- async def send_message(self, frame: TransportMessageFrame | TransportMessageUrgentFrame):
341
+ async def send_message(
342
+ self, frame: OutputTransportMessageFrame | OutputTransportMessageUrgentFrame
343
+ ):
342
344
  """Send a transport message frame to the client.
343
345
 
344
346
  Args:
@@ -346,14 +348,17 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
346
348
  """
347
349
  await self._write_frame(frame)
348
350
 
349
- async def write_audio_frame(self, frame: OutputAudioRawFrame):
351
+ async def write_audio_frame(self, frame: OutputAudioRawFrame) -> bool:
350
352
  """Write an audio frame to the WebSocket client with timing control.
351
353
 
352
354
  Args:
353
355
  frame: The output audio frame to write.
356
+
357
+ Returns:
358
+ True if the audio frame was written successfully, False otherwise.
354
359
  """
355
360
  if not self._websocket:
356
- return
361
+ return False
357
362
 
358
363
  frame = OutputAudioRawFrame(
359
364
  audio=frame.audio,
@@ -380,6 +385,8 @@ class WebsocketServerOutputTransport(BaseOutputTransport):
380
385
  # Simulate audio playback with a sleep.
381
386
  await self._write_audio_sleep()
382
387
 
388
+ return True
389
+
383
390
  async def _write_frame(self, frame: Frame):
384
391
  """Serialize and send a frame to the WebSocket client."""
385
392
  if not self._params.serializer:
@@ -241,6 +241,14 @@ class WhatsAppApi:
241
241
  self._whatsapp_url = f"{self.BASE_URL}{phone_number_id}/calls"
242
242
  self._whatsapp_token = whatsapp_token
243
243
 
244
+ def update_whatsapp_token(self, whatsapp_token: str):
245
+ """Update the WhatsApp access token for authentication."""
246
+ self._whatsapp_token = whatsapp_token
247
+
248
+ def update_whatsapp_phone_number_id(self, phone_number_id: str):
249
+ """Update the WhatsApp phone number ID for authentication."""
250
+ self._phone_number_id = phone_number_id
251
+
244
252
  async def answer_call_to_whatsapp(self, call_id: str, action: str, sdp: str, from_: str):
245
253
  """Answer an incoming WhatsApp call.
246
254
 
@@ -12,6 +12,8 @@ WhatsApp call events.
12
12
  """
13
13
 
14
14
  import asyncio
15
+ import hashlib
16
+ import hmac
15
17
  from typing import Awaitable, Callable, Dict, List, Optional
16
18
 
17
19
  import aiohttp
@@ -47,6 +49,7 @@ class WhatsAppClient:
47
49
  phone_number_id: str,
48
50
  session: aiohttp.ClientSession,
49
51
  ice_servers: Optional[List[IceServer]] = None,
52
+ whatsapp_secret: Optional[str] = None,
50
53
  ) -> None:
51
54
  """Initialize the WhatsApp client.
52
55
 
@@ -56,10 +59,12 @@ class WhatsAppClient:
56
59
  session: aiohttp session for making HTTP requests
57
60
  ice_servers: List of ICE servers for WebRTC connections. If None,
58
61
  defaults to Google's public STUN server
62
+ whatsapp_secret: WhatsApp APP secret for validating that the webhook request came from WhatsApp.
59
63
  """
60
64
  self._whatsapp_api = WhatsAppApi(
61
65
  whatsapp_token=whatsapp_token, phone_number_id=phone_number_id, session=session
62
66
  )
67
+ self._whatsapp_secret = whatsapp_secret
63
68
  self._ongoing_calls_map: Dict[str, SmallWebRTCConnection] = {}
64
69
 
65
70
  # Set default ICE servers if none provided
@@ -68,6 +73,22 @@ class WhatsAppClient:
68
73
  else:
69
74
  self._ice_servers = ice_servers
70
75
 
76
+ def update_ice_servers(self, ice_servers: Optional[List[IceServer]] = None):
77
+ """Update the list of ICE servers used for WebRTC connections."""
78
+ self._ice_servers = ice_servers
79
+
80
+ def update_whatsapp_secret(self, whatsapp_secret: Optional[str] = None):
81
+ """Update the WhatsApp APP secret for validating that the webhook request came from WhatsApp."""
82
+ self._whatsapp_secret = whatsapp_secret
83
+
84
+ def update_whatsapp_token(self, whatsapp_token: str):
85
+ """Update the WhatsApp API access token."""
86
+ self._whatsapp_api.update_whatsapp_token(whatsapp_token)
87
+
88
+ def update_whatsapp_phone_number_id(self, phone_number_id: str):
89
+ """Update the WhatsApp phone number ID for authentication."""
90
+ self._whatsapp_api.update_whatsapp_phone_number_id(phone_number_id)
91
+
71
92
  async def terminate_all_calls(self) -> None:
72
93
  """Terminate all ongoing WhatsApp calls.
73
94
 
@@ -133,10 +154,32 @@ class WhatsAppClient:
133
154
 
134
155
  return int(challenge)
135
156
 
157
+ async def _validate_whatsapp_webhook_request(self, raw_body: bytes, sha256_signature: str):
158
+ """Common handler for both /start and /connect endpoints."""
159
+ # Compute HMAC SHA256 using your App Secret
160
+ expected_signature = hmac.new(
161
+ key=self._whatsapp_secret.encode("utf-8"),
162
+ msg=raw_body,
163
+ digestmod=hashlib.sha256,
164
+ ).hexdigest()
165
+
166
+ # Extract signature from header (strip 'sha256=' prefix)
167
+ if not sha256_signature:
168
+ raise Exception("Missing X-Hub-Signature-256 header")
169
+ received_signature = sha256_signature.split("sha256=")[-1]
170
+
171
+ # Compare signatures securely
172
+ if not hmac.compare_digest(expected_signature, received_signature):
173
+ raise Exception("Invalid webhook signature")
174
+
175
+ logger.debug(f"Webhook signature verified!")
176
+
136
177
  async def handle_webhook_request(
137
178
  self,
138
179
  request: WhatsAppWebhookRequest,
139
180
  connection_callback: Optional[Callable[[SmallWebRTCConnection], Awaitable[None]]] = None,
181
+ raw_body: Optional[bytes] = None,
182
+ sha256_signature: Optional[str] = None,
140
183
  ) -> bool:
141
184
  """Handle a webhook request from WhatsApp.
142
185
 
@@ -150,6 +193,8 @@ class WhatsAppClient:
150
193
  connection_callback: Optional callback function to invoke when a new
151
194
  WebRTC connection is established. The callback
152
195
  receives the SmallWebRTCConnection instance.
196
+ raw_body: Optional bytes containing the raw request body.
197
+ sha256_signature: Optional X-Hub-Signature-256 header value from the request.
153
198
 
154
199
  Returns:
155
200
  bool: True if the webhook request was handled successfully, False otherwise
@@ -159,6 +204,8 @@ class WhatsAppClient:
159
204
  Exception: If connection establishment or API calls fail
160
205
  """
161
206
  try:
207
+ if self._whatsapp_secret:
208
+ await self._validate_whatsapp_webhook_request(raw_body, sha256_signature)
162
209
  for entry in request.entry:
163
210
  for change in entry.changes:
164
211
  # Handle connect events
@@ -14,13 +14,33 @@ and async cleanup for all Pipecat components.
14
14
  import asyncio
15
15
  import inspect
16
16
  from abc import ABC
17
- from typing import Optional
17
+ from dataclasses import dataclass
18
+ from typing import Any, Dict, List, Optional
18
19
 
19
20
  from loguru import logger
20
21
 
21
22
  from pipecat.utils.utils import obj_count, obj_id
22
23
 
23
24
 
25
+ @dataclass
26
+ class EventHandler:
27
+ """Data class to store event handlers information.
28
+
29
+ This data class stores the event name, a list of handlers to run for this
30
+ event, and whether these handlers will be executed in a task.
31
+
32
+ Parameters:
33
+ name (str): The name of the event handler.
34
+ handlers (List[Any]): A list of functions to be called when this event is triggered.
35
+ is_sync (bool): Indicates whether the functions are executed in a task.
36
+
37
+ """
38
+
39
+ name: str
40
+ handlers: List[Any]
41
+ is_sync: bool
42
+
43
+
24
44
  class BaseObject(ABC):
25
45
  """Abstract base class providing common functionality for Pipecat objects.
26
46
 
@@ -41,7 +61,7 @@ class BaseObject(ABC):
41
61
  self._name = name or f"{self.__class__.__name__}#{obj_count(self)}"
42
62
 
43
63
  # Registered event handlers.
44
- self._event_handlers: dict = {}
64
+ self._event_handlers: Dict[str, EventHandler] = {}
45
65
 
46
66
  # Set of tasks being executed. When a task finishes running it gets
47
67
  # automatically removed from the set. When we cleanup we wait for all
@@ -103,20 +123,23 @@ class BaseObject(ABC):
103
123
  Can be sync or async.
104
124
  """
105
125
  if event_name in self._event_handlers:
106
- self._event_handlers[event_name].append(handler)
126
+ self._event_handlers[event_name].handlers.append(handler)
107
127
  else:
108
128
  logger.warning(f"Event handler {event_name} not registered")
109
129
 
110
- def _register_event_handler(self, event_name: str):
130
+ def _register_event_handler(self, event_name: str, sync: bool = False):
111
131
  """Register an event handler type.
112
132
 
113
133
  Args:
114
134
  event_name: The name of the event type to register.
135
+ sync: Whether this event handler will be executed in a task.
115
136
  """
116
137
  if event_name not in self._event_handlers:
117
- self._event_handlers[event_name] = []
138
+ self._event_handlers[event_name] = EventHandler(
139
+ name=event_name, handlers=[], is_sync=sync
140
+ )
118
141
  else:
119
- logger.warning(f"Event handler {event_name} not registered")
142
+ logger.warning(f"Event handler {event_name} already registered")
120
143
 
121
144
  async def _call_event_handler(self, event_name: str, *args, **kwargs):
122
145
  """Call all registered handlers for the specified event.
@@ -126,34 +149,43 @@ class BaseObject(ABC):
126
149
  *args: Positional arguments to pass to event handlers.
127
150
  **kwargs: Keyword arguments to pass to event handlers.
128
151
  """
129
- # If we haven't registered an event handler, we don't need to do
130
- # anything.
131
- if not self._event_handlers.get(event_name):
152
+ if event_name not in self._event_handlers:
132
153
  return
133
154
 
134
- # Create the task.
135
- task = asyncio.create_task(self._run_task(event_name, *args, **kwargs))
155
+ event_handler = self._event_handlers[event_name]
156
+
157
+ for handler in event_handler.handlers:
158
+ if event_handler.is_sync:
159
+ # Just run the handler.
160
+ await self._run_handler(event_handler.name, handler, *args, **kwargs)
161
+ else:
162
+ # Create the task. Note that this is a task per each function
163
+ # handler. Users can register to an event handler multiple
164
+ # times.
165
+ task = asyncio.create_task(
166
+ self._run_handler(event_handler.name, handler, *args, **kwargs)
167
+ )
136
168
 
137
- # Add it to our list of event tasks.
138
- self._event_tasks.add((event_name, task))
169
+ # Add it to our list of event tasks.
170
+ self._event_tasks.add((event_name, task))
139
171
 
140
- # Remove the task from the event tasks list when the task completes.
141
- task.add_done_callback(self._event_task_finished)
172
+ # Remove the task from the event tasks list when the task completes.
173
+ task.add_done_callback(self._event_task_finished)
142
174
 
143
- async def _run_task(self, event_name: str, *args, **kwargs):
175
+ async def _run_handler(self, event_name: str, handler, *args, **kwargs):
144
176
  """Execute all handlers for an event.
145
177
 
146
178
  Args:
147
- event_name: The name of the event being handled.
179
+ event_name: The event name for this handler.
180
+ handler: The handler function to run.
148
181
  *args: Positional arguments to pass to handlers.
149
182
  **kwargs: Keyword arguments to pass to handlers.
150
183
  """
151
184
  try:
152
- for handler in self._event_handlers[event_name]:
153
- if inspect.iscoroutinefunction(handler):
154
- await handler(self, *args, **kwargs)
155
- else:
156
- handler(self, *args, **kwargs)
185
+ if inspect.iscoroutinefunction(handler):
186
+ await handler(self, *args, **kwargs)
187
+ else:
188
+ handler(self, *args, **kwargs)
157
189
  except Exception as e:
158
190
  logger.exception(f"Exception in event handler {event_name}: {e}")
159
191
 
pipecat/utils/string.py CHANGED
@@ -21,13 +21,24 @@ import re
21
21
  from typing import FrozenSet, Optional, Sequence, Tuple
22
22
 
23
23
  import nltk
24
+ from loguru import logger
24
25
  from nltk.tokenize import sent_tokenize
25
26
 
26
27
  # Ensure punkt_tab tokenizer data is available
27
28
  try:
28
29
  nltk.data.find("tokenizers/punkt_tab")
29
30
  except LookupError:
30
- nltk.download("punkt_tab", quiet=True)
31
+ try:
32
+ nltk.download("punkt_tab", quiet=True)
33
+ except (OSError, PermissionError) as e:
34
+ logger.error(
35
+ f"Failed to download NLTK 'punkt_tab' tokenizer data: {e}. "
36
+ "This data is required for sentence tokenization features. "
37
+ "The download failed due to filesystem permissions. "
38
+ "To resolve: pre-install the data in a location with appropriate read permissions, "
39
+ "or set the NLTK_DATA environment variable to point to a writable directory. "
40
+ "See https://www.nltk.org/data.html for more information."
41
+ )
31
42
 
32
43
  SENTENCE_ENDING_PUNCTUATION: FrozenSet[str] = frozenset(
33
44
  {