intellema-vdk 0.2.0__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.
- intellema_vdk/__init__.py +67 -10
- intellema_vdk/config.py +14 -0
- intellema_vdk/providers/__init__.py +35 -0
- intellema_vdk/providers/livekit/__init__.py +19 -0
- intellema_vdk/providers/livekit/client.py +612 -0
- intellema_vdk/providers/livekit/exceptions.py +23 -0
- intellema_vdk/providers/protocols.py +33 -0
- intellema_vdk/providers/retell/__init__.py +17 -0
- intellema_vdk/providers/retell/client.py +468 -0
- intellema_vdk/providers/retell/exceptions.py +19 -0
- intellema_vdk/{retell_lib → providers/retell}/import_phone_number.py +1 -1
- intellema_vdk/stt/__init__.py +17 -0
- intellema_vdk/stt/client.py +482 -0
- intellema_vdk/stt/exceptions.py +19 -0
- intellema_vdk/tts/__init__.py +15 -0
- intellema_vdk/tts/__pycache__/__init__.cpython-312.pyc +0 -0
- intellema_vdk/tts/__pycache__/client.cpython-312.pyc +0 -0
- intellema_vdk/tts/__pycache__/exceptions.cpython-312.pyc +0 -0
- intellema_vdk/tts/__pycache__/providers.cpython-312.pyc +0 -0
- intellema_vdk/tts/client.py +541 -0
- intellema_vdk/tts/exceptions.py +15 -0
- intellema_vdk/tts/providers.py +293 -0
- intellema_vdk/utils/logger_config.py +41 -0
- intellema_vdk-0.2.2.dist-info/METADATA +311 -0
- intellema_vdk-0.2.2.dist-info/RECORD +29 -0
- {intellema_vdk-0.2.0.dist-info → intellema_vdk-0.2.2.dist-info}/WHEEL +1 -1
- intellema_vdk/livekit_lib/__init__.py +0 -3
- intellema_vdk/livekit_lib/client.py +0 -280
- intellema_vdk/retell_lib/retell_client.py +0 -248
- intellema_vdk/speech_lib/__init__.py +0 -2
- intellema_vdk/speech_lib/stt_client.py +0 -108
- intellema_vdk/speech_lib/tts_streamer.py +0 -188
- intellema_vdk-0.2.0.dist-info/METADATA +0 -221
- intellema_vdk-0.2.0.dist-info/RECORD +0 -14
- /intellema_vdk/{retell_lib/__init__.py → stt/providers.py} +0 -0
- {intellema_vdk-0.2.0.dist-info → intellema_vdk-0.2.2.dist-info}/licenses/LICENSE +0 -0
- {intellema_vdk-0.2.0.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
|
+
]
|