intellema-vdk 0.2.1__py3-none-any.whl → 0.2.2__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.
Files changed (45) hide show
  1. intellema_vdk/__init__.py +67 -10
  2. intellema_vdk/config.py +14 -0
  3. intellema_vdk/providers/__init__.py +35 -0
  4. intellema_vdk/providers/livekit/__init__.py +19 -0
  5. intellema_vdk/providers/livekit/client.py +612 -0
  6. intellema_vdk/providers/livekit/exceptions.py +23 -0
  7. intellema_vdk/providers/protocols.py +33 -0
  8. intellema_vdk/providers/retell/__init__.py +17 -0
  9. intellema_vdk/providers/retell/client.py +468 -0
  10. intellema_vdk/providers/retell/exceptions.py +19 -0
  11. intellema_vdk/{retell_lib → providers/retell}/import_phone_number.py +1 -1
  12. intellema_vdk/stt/__init__.py +17 -0
  13. intellema_vdk/stt/client.py +482 -0
  14. intellema_vdk/stt/exceptions.py +19 -0
  15. intellema_vdk/tts/__init__.py +15 -0
  16. intellema_vdk/tts/__pycache__/__init__.cpython-312.pyc +0 -0
  17. intellema_vdk/tts/__pycache__/client.cpython-312.pyc +0 -0
  18. intellema_vdk/tts/__pycache__/exceptions.cpython-312.pyc +0 -0
  19. intellema_vdk/tts/__pycache__/providers.cpython-312.pyc +0 -0
  20. intellema_vdk/tts/client.py +541 -0
  21. intellema_vdk/tts/exceptions.py +15 -0
  22. intellema_vdk/tts/providers.py +293 -0
  23. intellema_vdk/utils/logger_config.py +41 -0
  24. intellema_vdk-0.2.2.dist-info/METADATA +311 -0
  25. intellema_vdk-0.2.2.dist-info/RECORD +29 -0
  26. {intellema_vdk-0.2.1.dist-info → intellema_vdk-0.2.2.dist-info}/WHEEL +1 -1
  27. intellema_vdk/__pycache__/__init__.cpython-312.pyc +0 -0
  28. intellema_vdk/livekit_lib/__init__.py +0 -3
  29. intellema_vdk/livekit_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  30. intellema_vdk/livekit_lib/__pycache__/client.cpython-312.pyc +0 -0
  31. intellema_vdk/livekit_lib/client.py +0 -280
  32. intellema_vdk/retell_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  33. intellema_vdk/retell_lib/__pycache__/retell_client.cpython-312.pyc +0 -0
  34. intellema_vdk/retell_lib/retell_client.py +0 -248
  35. intellema_vdk/speech_lib/__init__.py +0 -2
  36. intellema_vdk/speech_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  37. intellema_vdk/speech_lib/__pycache__/stt_client.cpython-312.pyc +0 -0
  38. intellema_vdk/speech_lib/__pycache__/tts_streamer.cpython-312.pyc +0 -0
  39. intellema_vdk/speech_lib/stt_client.py +0 -110
  40. intellema_vdk/speech_lib/tts_streamer.py +0 -188
  41. intellema_vdk-0.2.1.dist-info/METADATA +0 -221
  42. intellema_vdk-0.2.1.dist-info/RECORD +0 -22
  43. /intellema_vdk/{retell_lib/__init__.py → stt/providers.py} +0 -0
  44. {intellema_vdk-0.2.1.dist-info → intellema_vdk-0.2.2.dist-info}/licenses/LICENSE +0 -0
  45. {intellema_vdk-0.2.1.dist-info → intellema_vdk-0.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,612 @@
1
+ import os # Used for operating system dependent functionality, like file paths.
2
+ import json # Used for encoding and decoding JSON data.
3
+ import uuid # Used for generating unique identifiers.
4
+ import asyncio # Used for writing single-threaded concurrent code using coroutines.
5
+ import time # Used for time-related tasks.
6
+ import logging # Used for logging events.
7
+ import subprocess # Used for installing packages
8
+ import sys # Used for sys operations
9
+ from typing import List, Optional, Dict, Any, TYPE_CHECKING # Used for type hinting.
10
+
11
+ # Lazy imports - only load when LiveKitManager is instantiated
12
+ if TYPE_CHECKING:
13
+ from livekit import api
14
+ import boto3
15
+ else:
16
+ api = None
17
+ boto3 = None
18
+
19
+ from ...config import get_env # Used to get environment variables.
20
+ from ..protocols import VoiceProvider # Protocol for voice providers.
21
+ from .exceptions import (
22
+ LiveKitConfigurationError,
23
+ LiveKitRoomError,
24
+ LiveKitSIPError,
25
+ LiveKitDispatchError,
26
+ LiveKitEgressError,
27
+ LiveKitError,
28
+ )
29
+
30
+ # Setup logger for this module.
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class LiveKitManager(VoiceProvider):
35
+ """Manages LiveKit rooms, participants, and SIP calls.
36
+
37
+ This class provides a high-level interface to the LiveKit API for managing
38
+ voice calls, including starting outbound calls, creating tokens, managing rooms,
39
+ and handling media streams and recordings.
40
+
41
+ Attributes:
42
+ url (str): The URL of the LiveKit server.
43
+ api_key (str): The LiveKit API key.
44
+ api_secret (str): The LiveKit API secret.
45
+ sip_trunk_id (str): The ID of the SIP outbound trunk.
46
+ lk_api (api.LiveKitAPI): The LiveKit API client.
47
+
48
+ Example:
49
+ >>> async def main():
50
+ ... livekit_manager = VoiceClient("livekit")
51
+ ... call_id = await livekit_manager.start_outbound_call(
52
+ ... phone_number="+15551234567",
53
+ ... prompt_content="Hello, this is a test call."
54
+ ... )
55
+ ... print(f"Call started with ID: {call_id}")
56
+ ... await livekit_manager.delete_room(call_id)
57
+ ... await livekit_manager.close()
58
+ ...
59
+ >>> if __name__ == "__main__":
60
+ ... asyncio.run(main())
61
+ """
62
+
63
+ def __init__(self) -> None:
64
+ """Initializes the LiveKitManager.
65
+
66
+ Retrieves LiveKit configuration from environment variables and initializes
67
+ the LiveKit API client.
68
+
69
+ Raises:
70
+ LiveKitConfigurationError: If required LiveKit environment variables
71
+ are not set.
72
+ """
73
+ # Lazy import LiveKit API and boto3 - only install when actually used
74
+ global api, boto3
75
+ if api is None:
76
+ try:
77
+ from livekit import api as _api
78
+ api = _api
79
+ except ImportError:
80
+ print("LiveKit API is not installed. Installing now...")
81
+ print("Run: pip install intellema-vdk[livekit]")
82
+ try:
83
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "livekit-api>=1.1.0"])
84
+ from livekit import api as _api
85
+ api = _api
86
+ print("✓ LiveKit API installed successfully!")
87
+ except Exception as e:
88
+ raise LiveKitConfigurationError(
89
+ "Failed to install livekit-api. Please install manually:\n"
90
+ " pip install intellema-vdk[livekit]\n"
91
+ "or:\n"
92
+ " pip install livekit-api>=1.1.0"
93
+ ) from e
94
+
95
+ if boto3 is None:
96
+ try:
97
+ import boto3 as _boto3
98
+ boto3 = _boto3
99
+ except ImportError:
100
+ print("boto3 (AWS SDK) is not installed. Installing now...")
101
+ print("This is required for AWS Polly TTS and other AWS features.")
102
+ try:
103
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "boto3>=1.28.0"])
104
+ import boto3 as _boto3
105
+ boto3 = _boto3
106
+ print("✓ boto3 installed successfully!")
107
+ except Exception as e:
108
+ raise LiveKitConfigurationError(
109
+ "Failed to install boto3. Please install manually:\n"
110
+ " pip install boto3>=1.28.0"
111
+ ) from e
112
+
113
+ self.url = get_env("LIVEKIT_URL")
114
+ self.api_key = get_env("LIVEKIT_API_KEY")
115
+ self.api_secret = get_env("LIVEKIT_API_SECRET")
116
+ self.sip_trunk_id = get_env("SIP_OUTBOUND_TRUNK_ID")
117
+
118
+ if not self.url or not self.api_key or not self.api_secret:
119
+ raise LiveKitConfigurationError(
120
+ "LIVEKIT_URL, LIVEKIT_API_KEY, and LIVEKIT_API_SECRET must be set."
121
+ )
122
+
123
+ self.lk_api = api.LiveKitAPI(
124
+ url=self.url,
125
+ api_key=self.api_key,
126
+ api_secret=self.api_secret,
127
+ )
128
+
129
+ async def close(self) -> None:
130
+ """Closes the underlying LiveKitAPI client connection."""
131
+ await self.lk_api.aclose()
132
+
133
+ async def start_outbound_call(
134
+ self,
135
+ phone_number: str,
136
+ prompt_content: str,
137
+ call_id: Optional[str] = None,
138
+ timeout: int = 600,
139
+ ) -> str:
140
+ """Initiates an outbound call via a LiveKit SIP trunk.
141
+
142
+ This method creates a LiveKit room, dispatches an agent to it, and then
143
+ initiates a SIP call to the specified phone number.
144
+
145
+ Args:
146
+ phone_number (str): The destination phone number in E.164 format.
147
+ prompt_content (str): The initial prompt or context for the AI agent.
148
+ call_id (Optional[str]): A unique identifier for the call. If not
149
+ provided, a random one is generated. This is used as the room name.
150
+ timeout (int): The timeout for the room in seconds. Defaults to 600.
151
+
152
+ Returns:
153
+ str: The unique `call_id` (room name) for the call.
154
+
155
+ Raises:
156
+ LiveKitConfigurationError: If the SIP outbound trunk ID is not configured.
157
+ LiveKitSIPError: If the SIP call fails (e.g., user is busy).
158
+ """
159
+ if not call_id:
160
+ call_id = f"outbound_call_{uuid.uuid4().hex[:12]}"
161
+
162
+ logger.info(f"Starting outbound call to {phone_number}...")
163
+ logger.info(f" Call ID: {call_id}")
164
+
165
+ metadata = json.dumps(
166
+ {"phone_number": phone_number, "prompt_content": prompt_content}
167
+ )
168
+
169
+ # 1. Create a room with metadata.
170
+ room = await self.lk_api.room.create_room(
171
+ api.CreateRoomRequest(
172
+ name=call_id, empty_timeout=timeout, metadata=metadata
173
+ )
174
+ )
175
+ logger.info(f"✓ Room created successfully!")
176
+ logger.info(f" Room Name: {room.name}")
177
+
178
+ # 2. Dispatch an agent to the room.
179
+ await self.lk_api.agent_dispatch.create_dispatch(
180
+ api.CreateAgentDispatchRequest(
181
+ room=call_id, agent_name="outbound-caller", metadata=metadata
182
+ )
183
+ )
184
+ logger.info(f"✓ Agent dispatched to room")
185
+
186
+ # 3. Initiate the outbound SIP/PSTN call.
187
+ if not self.sip_trunk_id:
188
+ logger.error(
189
+ "SIP_OUTBOUND_TRUNK_ID is not configured in environment.", exc_info=True
190
+ )
191
+ raise LiveKitConfigurationError(
192
+ "SIP_OUTBOUND_TRUNK_ID is not configured in environment."
193
+ )
194
+
195
+ sip_participant_identity = f"phone-{phone_number}"
196
+
197
+ try:
198
+ await self.lk_api.sip.create_sip_participant(
199
+ api.CreateSIPParticipantRequest(
200
+ room_name=call_id,
201
+ sip_trunk_id=self.sip_trunk_id,
202
+ sip_call_to=phone_number,
203
+ participant_identity=sip_participant_identity,
204
+ wait_until_answered=True,
205
+ )
206
+ )
207
+ logger.info(f"✓ SIP participant created successfully!")
208
+ logger.info(f" Participant Identity: {sip_participant_identity}")
209
+
210
+ except api.TwirpError as e:
211
+ # Extract SIP-specific error information from metadata
212
+ sip_status_code = e.metadata.get("sip_status_code")
213
+ sip_status = e.metadata.get("sip_status", "Unknown")
214
+
215
+ logger.error(
216
+ f"SIP call failed: {e.message}, "
217
+ f"SIP status: {sip_status_code} {sip_status}"
218
+ )
219
+
220
+ # Clean up the room (centralized cleanup for all error paths)
221
+ await self.delete_room(call_id)
222
+
223
+ # Handle case where SIP status code is not available
224
+ if sip_status_code is None:
225
+ logger.warning("No SIP status code available in error metadata")
226
+ raise LiveKitSIPError(f"SIP call failed: {e.message}") from e
227
+
228
+ # Handle specific SIP error codes
229
+ # 486 = Busy Here, 600 = Busy Everywhere
230
+ if sip_status_code in ("486", "600"):
231
+ raise LiveKitSIPError(f"User is busy (SIP {sip_status_code})") from e
232
+
233
+ # Handle other common SIP errors
234
+ # 400 = Bad Request
235
+ # 404 = Not Found
236
+ # 408 = Request Timeout
237
+ # 480 = Temporarily Unavailable
238
+ # 487 = Request Terminated
239
+ elif sip_status_code in ("400", "404", "408", "480", "487"):
240
+ raise LiveKitSIPError(
241
+ f"Call failed: {sip_status} (SIP {sip_status_code})"
242
+ ) from e
243
+
244
+ # Handle all other SIP error codes
245
+ else:
246
+ raise LiveKitSIPError(
247
+ f"SIP call failed with status {sip_status_code}: {e.message}"
248
+ ) from e
249
+
250
+ except Exception as e:
251
+ # Handle non-Twirp exceptions
252
+ logger.error(
253
+ f"Unexpected error creating SIP participant: {e}", exc_info=True
254
+ )
255
+ await self.delete_room(call_id)
256
+ raise LiveKitSIPError(f"Failed to create SIP participant: {e}") from e
257
+
258
+ logger.info(f"✓ Call started successfully!")
259
+ logger.info(f" Call ID: {room.name}")
260
+ logger.info(f" Phone Number: {phone_number}")
261
+
262
+ return room.name
263
+
264
+ async def create_token(self, call_id: str, participant_name: str) -> str:
265
+ """Creates a JWT token for a participant to join a room.
266
+
267
+ Args:
268
+ call_id (str): The room name (call_id) to join.
269
+ participant_name (str): The name of the participant.
270
+
271
+ Returns:
272
+ str: The generated JWT token.
273
+ """
274
+ logger.info(
275
+ f"Creating token for participant '{participant_name}' in room '{call_id}'"
276
+ )
277
+ token = api.AccessToken(self.api_key, self.api_secret)
278
+ token.with_identity(participant_name)
279
+ token.with_name(participant_name)
280
+ token.with_grants(
281
+ api.VideoGrants(
282
+ room_join=True,
283
+ room=call_id,
284
+ )
285
+ )
286
+ logger.info(f"✓ Token created successfully for {participant_name}")
287
+ return token.to_jwt()
288
+
289
+ async def delete_room(self, call_id: str) -> None:
290
+ """Ends the call by deleting the LiveKit room.
291
+
292
+ Args:
293
+ call_id (str): The room name (call_id) to delete.
294
+ """
295
+ logger.info(f"Ending call by deleting room: {call_id}")
296
+ try:
297
+ await self.lk_api.room.delete_room(api.DeleteRoomRequest(room=call_id))
298
+ logger.info(f"✓ Room '{call_id}' deleted successfully")
299
+ except Exception as e:
300
+ logger.error(f"Error deleting room {call_id}: {e}", exc_info=True)
301
+ raise LiveKitRoomError(f"Error deleting room {call_id}: {e}") from e
302
+
303
+ async def start_stream(self, call_id: str, rtmp_urls: List[str]) -> None:
304
+ """Starts streaming the call to provided RTMP URLs using LiveKit Egress.
305
+
306
+ Args:
307
+ call_id (str): The room name to stream.
308
+ rtmp_urls (List[str]): A list of RTMP URLs to stream to.
309
+ """
310
+ if not rtmp_urls:
311
+ raise ValueError("No stream URLs provided")
312
+
313
+ logger.info(f"Starting stream for room: {call_id}")
314
+ logger.info(f" Stream URLs: {rtmp_urls}")
315
+
316
+ try:
317
+ await self.lk_api.egress.start_room_composite_egress(
318
+ api.RoomCompositeEgressRequest(
319
+ room_name=call_id,
320
+ layout="speaker",
321
+ stream_outputs=[
322
+ api.StreamOutput(
323
+ protocol=api.StreamProtocol.RTMP, urls=rtmp_urls
324
+ )
325
+ ],
326
+ )
327
+ )
328
+ logger.info(f"✓ Stream started successfully for room {call_id}")
329
+ except Exception as e:
330
+ logger.error(
331
+ f"Failed to start stream for room {call_id}: {e}", exc_info=True
332
+ )
333
+ raise LiveKitEgressError(f"Failed to start stream: {e}") from e
334
+
335
+ async def start_recording(
336
+ self,
337
+ call_id: str,
338
+ output_filepath: Optional[str] = None,
339
+ upload_to_s3: bool = True,
340
+ wait_for_completion: bool = True,
341
+ ) -> str:
342
+ """Starts recording a room.
343
+
344
+ The recording can be saved to a file on an Egress server or uploaded to an
345
+ S3 bucket.
346
+
347
+ Args:
348
+ call_id (str): Name of the room/call to record.
349
+ output_filepath (Optional[str]): The path or filename for the recording.
350
+ Defaults to a generated name.
351
+ upload_to_s3 (bool): If True, uploads to S3. This requires AWS
352
+ credentials to be configured in the environment. Defaults to True.
353
+ wait_for_completion (bool): If True and `upload_to_s3` is True, this
354
+ method will wait for the recording to finish and then download it
355
+ from S3 to a local 'recordings' directory. Defaults to True.
356
+
357
+ Returns:
358
+ str: The Egress ID of the recording.
359
+
360
+ Raises:
361
+ LiveKitConfigurationError: If S3 upload is requested but AWS
362
+ credentials are not configured.
363
+ LiveKitEgressError: If the Egress process fails.
364
+ """
365
+ filename = (
366
+ output_filepath
367
+ if output_filepath
368
+ else f"{call_id}-{uuid.uuid4().hex[:6]}.mp4"
369
+ )
370
+
371
+ if upload_to_s3:
372
+ access_key = get_env("AWS_ACCESS_KEY_ID")
373
+ secret_key = get_env("AWS_SECRET_ACCESS_KEY")
374
+ bucket = get_env("AWS_S3_BUCKET")
375
+ region = get_env("AWS_REGION")
376
+
377
+ if not access_key or not secret_key or not bucket:
378
+ raise LiveKitConfigurationError(
379
+ "AWS credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, "
380
+ "AWS_S3_BUCKET) are required for S3 upload."
381
+ )
382
+
383
+ file_output = api.EncodedFileOutput(
384
+ file_type=api.EncodedFileType.MP4,
385
+ filepath=filename,
386
+ s3=api.S3Upload(
387
+ access_key=access_key,
388
+ secret=secret_key,
389
+ bucket=bucket,
390
+ region=region,
391
+ ),
392
+ )
393
+ logger.info(
394
+ f"Starting recording. File will be saved to S3: s3://{bucket}/{filename}"
395
+ )
396
+ else:
397
+ file_output = api.EncodedFileOutput(
398
+ file_type=api.EncodedFileType.MP4,
399
+ filepath=filename,
400
+ )
401
+ logger.info(f"Starting recording. File will be saved locally: {filename}")
402
+
403
+ egress_info = await self.lk_api.egress.start_room_composite_egress(
404
+ api.RoomCompositeEgressRequest(
405
+ room_name=call_id,
406
+ layout="grid",
407
+ preset=api.EncodingOptionsPreset.H264_720P_30,
408
+ file_outputs=[file_output],
409
+ )
410
+ )
411
+
412
+ if wait_for_completion and upload_to_s3:
413
+ egress_id = egress_info.egress_id
414
+ logger.info(f"Waiting for egress {egress_id} to complete...")
415
+
416
+ while True:
417
+ try:
418
+ egress_list = await self.lk_api.egress.list_egress(
419
+ api.ListEgressRequest(egress_id=egress_id)
420
+ )
421
+ except Exception as e:
422
+ logger.error(f"Error checking egress status: {e}")
423
+ await asyncio.sleep(5)
424
+ continue
425
+
426
+ if not egress_list.items:
427
+ logger.warning("Egress info not found during polling.")
428
+ break
429
+
430
+ info = egress_list.items[0]
431
+ if info.status == api.EgressStatus.EGRESS_COMPLETE:
432
+ logger.info("Egress completed successfully.")
433
+ break
434
+ elif info.status == api.EgressStatus.EGRESS_FAILED:
435
+ raise LiveKitEgressError(f"Egress failed: {info.error}")
436
+ elif info.status == api.EgressStatus.EGRESS_LIMIT_REACHED:
437
+ raise LiveKitEgressError(f"Egress limit reached: {info.error}")
438
+
439
+ await asyncio.sleep(5)
440
+
441
+ # Download the recording from S3.
442
+ logger.info(f"Downloading {filename} from S3 bucket {bucket}...")
443
+ s3 = boto3.client(
444
+ "s3",
445
+ aws_access_key_id=access_key,
446
+ aws_secret_access_key=secret_key,
447
+ region_name=region,
448
+ )
449
+
450
+ local_dir = "recordings"
451
+ os.makedirs(local_dir, exist_ok=True)
452
+ local_path = os.path.join(local_dir, filename)
453
+
454
+ try:
455
+ s3.download_file(bucket, filename, local_path)
456
+ logger.info(f"Recording downloaded to: {local_path}")
457
+ except Exception as e:
458
+ logger.error(f"Failed to download recording: {e}")
459
+ raise e
460
+
461
+ return egress_info.egress_id
462
+
463
+ async def kick_participant(self, call_id: str, identity: str) -> None:
464
+ """Removes a participant from the call.
465
+
466
+ Args:
467
+ call_id (str): The room name.
468
+ identity (str): The identity of the participant to remove.
469
+ """
470
+ logger.info(f"Removing participant '{identity}' from room '{call_id}'")
471
+ try:
472
+ await self.lk_api.room.remove_participant(
473
+ api.RoomParticipantIdentity(room=call_id, identity=identity)
474
+ )
475
+ logger.info(f"✓ Participant '{identity}' removed successfully")
476
+ except Exception as e:
477
+ logger.error(
478
+ f"Failed to remove participant '{identity}' from room '{call_id}': {e}",
479
+ exc_info=True,
480
+ )
481
+ raise LiveKitRoomError(f"Failed to remove participant: {e}") from e
482
+
483
+ async def mute_participant(
484
+ self, call_id: str, identity: str, track_sid: str, muted: bool
485
+ ) -> None:
486
+ """Mutes or unmutes a participant's track.
487
+
488
+ Args:
489
+ call_id (str): The room name.
490
+ identity (str): The participant identity.
491
+ track_sid (str): The SID of the track to mute/unmute.
492
+ muted (bool): True to mute, False to unmute.
493
+ """
494
+ action = "Muting" if muted else "Unmuting"
495
+ logger.info(
496
+ f"{action} participant '{identity}' track '{track_sid}' in room '{call_id}'"
497
+ )
498
+ try:
499
+ await self.lk_api.room.mute_published_track(
500
+ api.MuteRoomTrackRequest(
501
+ room=call_id, identity=identity, track_sid=track_sid, muted=muted
502
+ )
503
+ )
504
+ logger.info(
505
+ f"✓ Participant '{identity}' {'muted' if muted else 'unmuted'} successfully"
506
+ )
507
+ except Exception as e:
508
+ logger.error(
509
+ f"Failed to {'mute' if muted else 'unmute'} participant '{identity}': {e}",
510
+ exc_info=True,
511
+ )
512
+ raise LiveKitRoomError(f"Failed to mute/unmute participant: {e}") from e
513
+
514
+ async def send_alert(
515
+ self, call_id: str, message: str, participant_identity: Optional[str] = None
516
+ ) -> None:
517
+ """Sends a data message (alert) to call participants.
518
+
519
+ Args:
520
+ call_id (str): The room name.
521
+ message (str): The message content.
522
+ participant_identity (Optional[str]): The identity of a specific
523
+ participant to send the message to. If None, sends to all.
524
+ """
525
+ target = participant_identity if participant_identity else "all participants"
526
+ logger.info(f"Sending alert to {target} in room '{call_id}'")
527
+ logger.info(f" Message: {message}")
528
+
529
+ destination_identities = [participant_identity] if participant_identity else []
530
+ data_packet = json.dumps({"type": "alert", "message": message}).encode("utf-8")
531
+
532
+ try:
533
+ await self.lk_api.room.send_data(
534
+ api.SendDataRequest(
535
+ room=call_id,
536
+ data=data_packet,
537
+ kind=1, # 1 = RELIABLE, 0 = LOSSY
538
+ destination_identities=destination_identities,
539
+ )
540
+ )
541
+ logger.info(f"✓ Alert sent successfully to {target}")
542
+ except Exception as e:
543
+ logger.error(f"Failed to send alert to {target}: {e}", exc_info=True)
544
+ raise LiveKitError(f"Failed to send alert: {e}") from e
545
+
546
+ async def get_participant_identities(self, call_id: str) -> List[Dict[str, Any]]:
547
+ """Gets a list of all participants in a room with their tracks.
548
+
549
+ Args:
550
+ call_id (str): The room name (call_id).
551
+
552
+ Returns:
553
+ List[Dict[str, Any]]: A list of dictionaries, each containing
554
+ information about a participant.
555
+
556
+ Example:
557
+ [
558
+ {
559
+ "identity": "participant-1",
560
+ "name": "Participant One",
561
+ "tracks": [
562
+ {
563
+ "sid": "TR_abc123",
564
+ "type": "audio",
565
+ "muted": False,
566
+ "source": "MICROPHONE"
567
+ }
568
+ ]
569
+ }
570
+ ]
571
+ """
572
+ logger.info(f"Fetching participants for room '{call_id}'")
573
+ try:
574
+ response = await self.lk_api.room.list_participants(
575
+ api.ListParticipantsRequest(room=call_id)
576
+ )
577
+ participants = []
578
+ for p in response.participants:
579
+ tracks = []
580
+ for track in p.tracks:
581
+ tracks.append(
582
+ {
583
+ "sid": track.sid,
584
+ "type": (
585
+ "audio"
586
+ if track.type == 1
587
+ else "video" if track.type == 2 else "unknown"
588
+ ),
589
+ "muted": track.muted,
590
+ "source": (
591
+ track.source.name
592
+ if hasattr(track.source, "name")
593
+ else str(track.source)
594
+ ),
595
+ }
596
+ )
597
+ participants.append(
598
+ {"identity": p.identity, "name": p.name, "tracks": tracks}
599
+ )
600
+ logger.info(
601
+ f"✓ Found {len(participants)} participant(s) in room '{call_id}'"
602
+ )
603
+ for p in participants:
604
+ logger.info(
605
+ f" - {p['identity']} ({p['name']}): {len(p['tracks'])} track(s)"
606
+ )
607
+ return participants
608
+ except Exception as e:
609
+ logger.error(
610
+ f"Failed to fetch participants for room '{call_id}': {e}", exc_info=True
611
+ )
612
+ raise LiveKitRoomError(f"Failed to fetch participants: {e}") from e
@@ -0,0 +1,23 @@
1
+ class LiveKitError(Exception):
2
+ """Base exception for all LiveKit-related errors."""
3
+ pass
4
+
5
+ class LiveKitConfigurationError(LiveKitError):
6
+ """Raised when configuration (URL, API Key, Secret) is missing or invalid."""
7
+ pass
8
+
9
+ class LiveKitRoomError(LiveKitError):
10
+ """Raised when creating, deleting, or managing a room fails."""
11
+ pass
12
+
13
+ class LiveKitSIPError(LiveKitError):
14
+ """Raised when SIP trunking or participant operations fail."""
15
+ pass
16
+
17
+ class LiveKitDispatchError(LiveKitError):
18
+ """Raised when agent dispatch operations fail."""
19
+ pass
20
+
21
+ class LiveKitEgressError(LiveKitError):
22
+ """Raised when starting or managing egress (recording/streaming) fails."""
23
+ pass
@@ -0,0 +1,33 @@
1
+ from typing import List, Optional, Protocol, runtime_checkable
2
+
3
+ @runtime_checkable
4
+ class VoiceProvider(Protocol):
5
+ """Protocol defining the interface for voice providers."""
6
+
7
+ async def start_outbound_call(self, phone_number: str, prompt_content: str, call_id: Optional[str] = None) -> str:
8
+ """Initiates an outbound call and returns the call/room ID."""
9
+ ...
10
+
11
+ async def delete_room(self, call_id: str) -> None:
12
+ """Ends the call/room."""
13
+ ...
14
+
15
+ async def start_stream(self, call_id: str, rtmp_urls: List[str]) -> None:
16
+ """Starts streaming the call to RTMP URLs."""
17
+ ...
18
+
19
+ async def start_recording(self, call_id: str, output_filepath: Optional[str] = None, upload_to_s3: bool = True, wait_for_completion: bool = True) -> str:
20
+ """Starts recording the call and returns the recording ID."""
21
+ ...
22
+
23
+ async def kick_participant(self, call_id: str, identity: str) -> None:
24
+ """Removes a participant from the call."""
25
+ ...
26
+
27
+ async def mute_participant(self, call_id: str, identity: str, track_sid: str, muted: bool) -> None:
28
+ """Mutes or unmutes a participant's track."""
29
+ ...
30
+
31
+ async def send_alert(self, call_id: str, message: str, participant_identity: Optional[str] = None) -> None:
32
+ """Sends a data message/alert to the call."""
33
+ ...
@@ -0,0 +1,17 @@
1
+ from .client import RetellManager
2
+ from .exceptions import (
3
+ RetellError,
4
+ RetellConfigurationError,
5
+ RetellAPIError,
6
+ RetellPhoneNumberError,
7
+ RetellCallError,
8
+ )
9
+
10
+ __all__ = [
11
+ "RetellManager",
12
+ "RetellError",
13
+ "RetellConfigurationError",
14
+ "RetellAPIError",
15
+ "RetellPhoneNumberError",
16
+ "RetellCallError",
17
+ ]