sayna-client 0.0.9__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.
sayna_client/errors.py ADDED
@@ -0,0 +1,81 @@
1
+ """Custom exceptions for the Sayna SDK."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class SaynaError(Exception):
7
+ """Base error class for all Sayna SDK errors."""
8
+
9
+ def __init__(self, message: str) -> None:
10
+ """Initialize the error.
11
+
12
+ Args:
13
+ message: Error description
14
+ """
15
+ super().__init__(message)
16
+ self.message = message
17
+
18
+
19
+ class SaynaNotConnectedError(SaynaError):
20
+ """Error raised when attempting to use the client before it's connected."""
21
+
22
+ def __init__(self, message: str = "Not connected to Sayna WebSocket") -> None:
23
+ """Initialize the error.
24
+
25
+ Args:
26
+ message: Error description
27
+ """
28
+ super().__init__(message)
29
+
30
+
31
+ class SaynaNotReadyError(SaynaError):
32
+ """Error raised when attempting operations before the client is ready."""
33
+
34
+ def __init__(
35
+ self,
36
+ message: str = "Sayna voice providers are not ready. Wait for the connection to be established.",
37
+ ) -> None:
38
+ """Initialize the error.
39
+
40
+ Args:
41
+ message: Error description
42
+ """
43
+ super().__init__(message)
44
+
45
+
46
+ class SaynaConnectionError(SaynaError):
47
+ """Error raised when WebSocket connection fails."""
48
+
49
+ def __init__(self, message: str, cause: Any = None) -> None:
50
+ """Initialize the error.
51
+
52
+ Args:
53
+ message: Error description
54
+ cause: The underlying exception that caused this error
55
+ """
56
+ super().__init__(message)
57
+ self.cause = cause
58
+
59
+
60
+ class SaynaValidationError(SaynaError):
61
+ """Error raised when invalid parameters are provided."""
62
+
63
+ def __init__(self, message: str) -> None:
64
+ """Initialize the error.
65
+
66
+ Args:
67
+ message: Error description
68
+ """
69
+ super().__init__(message)
70
+
71
+
72
+ class SaynaServerError(SaynaError):
73
+ """Error raised when the server returns an error."""
74
+
75
+ def __init__(self, message: str) -> None:
76
+ """Initialize the error.
77
+
78
+ Args:
79
+ message: Error description
80
+ """
81
+ super().__init__(message)
@@ -0,0 +1,235 @@
1
+ """Generic HTTP client for Sayna REST API calls."""
2
+
3
+ import json
4
+ from typing import Any, Optional
5
+
6
+ import aiohttp
7
+
8
+ from sayna_client.errors import SaynaServerError, SaynaValidationError
9
+
10
+
11
+ # HTTP status code constants
12
+ _HTTP_CLIENT_ERROR = 400
13
+ _HTTP_SERVER_ERROR = 500
14
+
15
+
16
+ class SaynaHttpClient:
17
+ """Generic HTTP client for making REST API requests to Sayna server."""
18
+
19
+ def __init__(self, base_url: str, api_key: Optional[str] = None) -> None:
20
+ """Initialize the HTTP client.
21
+
22
+ Args:
23
+ base_url: Base URL for the Sayna API (e.g., 'https://api.sayna.com')
24
+ api_key: Optional API key for authentication
25
+ """
26
+ self.base_url = base_url.rstrip("/")
27
+ self.api_key = api_key
28
+ self._session: Optional[aiohttp.ClientSession] = None
29
+
30
+ async def _ensure_session(self) -> aiohttp.ClientSession:
31
+ """Ensure an HTTP session exists."""
32
+ if self._session is None or self._session.closed:
33
+ headers = {"Content-Type": "application/json"}
34
+ if self.api_key:
35
+ headers["Authorization"] = f"Bearer {self.api_key}"
36
+
37
+ self._session = aiohttp.ClientSession(headers=headers)
38
+
39
+ return self._session
40
+
41
+ async def close(self) -> None:
42
+ """Close the HTTP session."""
43
+ if self._session and not self._session.closed:
44
+ await self._session.close()
45
+ self._session = None
46
+
47
+ async def get(
48
+ self,
49
+ endpoint: str,
50
+ params: Optional[dict[str, Any]] = None,
51
+ ) -> dict[str, Any]:
52
+ """Make a GET request.
53
+
54
+ Args:
55
+ endpoint: API endpoint path (e.g., '/voices')
56
+ params: Optional query parameters
57
+
58
+ Returns:
59
+ JSON response as a dictionary
60
+
61
+ Raises:
62
+ SaynaServerError: If the server returns an error
63
+ SaynaValidationError: If the request is invalid
64
+ """
65
+ session = await self._ensure_session()
66
+ url = f"{self.base_url}{endpoint}"
67
+
68
+ async with session.get(url, params=params) as response:
69
+ return await self._handle_response(response)
70
+
71
+ async def post(
72
+ self,
73
+ endpoint: str,
74
+ data: Optional[dict[str, Any]] = None,
75
+ json_data: Optional[dict[str, Any]] = None,
76
+ ) -> dict[str, Any]:
77
+ """Make a POST request.
78
+
79
+ Args:
80
+ endpoint: API endpoint path (e.g., '/speak')
81
+ data: Optional form data
82
+ json_data: Optional JSON payload
83
+
84
+ Returns:
85
+ JSON response as a dictionary
86
+
87
+ Raises:
88
+ SaynaServerError: If the server returns an error
89
+ SaynaValidationError: If the request is invalid
90
+ """
91
+ session = await self._ensure_session()
92
+ url = f"{self.base_url}{endpoint}"
93
+
94
+ async with session.post(url, data=data, json=json_data) as response:
95
+ return await self._handle_response(response)
96
+
97
+ async def delete(
98
+ self,
99
+ endpoint: str,
100
+ json_data: Optional[dict[str, Any]] = None,
101
+ ) -> dict[str, Any]:
102
+ """Make a DELETE request.
103
+
104
+ Args:
105
+ endpoint: API endpoint path (e.g., '/sip/hooks')
106
+ json_data: Optional JSON payload
107
+
108
+ Returns:
109
+ JSON response as a dictionary
110
+
111
+ Raises:
112
+ SaynaServerError: If the server returns an error
113
+ SaynaValidationError: If the request is invalid
114
+ """
115
+ session = await self._ensure_session()
116
+ url = f"{self.base_url}{endpoint}"
117
+
118
+ async with session.delete(url, json=json_data) as response:
119
+ return await self._handle_response(response)
120
+
121
+ async def get_binary(
122
+ self,
123
+ endpoint: str,
124
+ params: Optional[dict[str, Any]] = None,
125
+ ) -> tuple[bytes, dict[str, str]]:
126
+ """Make a GET request expecting binary response.
127
+
128
+ Args:
129
+ endpoint: API endpoint path (e.g., '/recording/abc123')
130
+ params: Optional query parameters
131
+
132
+ Returns:
133
+ Tuple of (binary_data, response_headers)
134
+
135
+ Raises:
136
+ SaynaServerError: If the server returns an error
137
+ SaynaValidationError: If the request is invalid
138
+ """
139
+ session = await self._ensure_session()
140
+ url = f"{self.base_url}{endpoint}"
141
+
142
+ async with session.get(url, params=params) as response:
143
+ if response.status >= _HTTP_CLIENT_ERROR:
144
+ # Try to parse error message
145
+ try:
146
+ error_data = await response.json()
147
+ error_msg = error_data.get("error", f"HTTP {response.status}")
148
+ except Exception:
149
+ error_msg = f"HTTP {response.status}: {response.reason}"
150
+
151
+ if response.status >= _HTTP_SERVER_ERROR:
152
+ raise SaynaServerError(error_msg)
153
+ raise SaynaValidationError(error_msg)
154
+
155
+ binary_data = await response.read()
156
+ headers = dict(response.headers)
157
+ return binary_data, headers
158
+
159
+ async def post_binary(
160
+ self,
161
+ endpoint: str,
162
+ json_data: Optional[dict[str, Any]] = None,
163
+ ) -> tuple[bytes, dict[str, str]]:
164
+ """Make a POST request expecting binary response.
165
+
166
+ Args:
167
+ endpoint: API endpoint path (e.g., '/speak')
168
+ json_data: Optional JSON payload
169
+
170
+ Returns:
171
+ Tuple of (binary_data, response_headers)
172
+
173
+ Raises:
174
+ SaynaServerError: If the server returns an error
175
+ SaynaValidationError: If the request is invalid
176
+ """
177
+ session = await self._ensure_session()
178
+ url = f"{self.base_url}{endpoint}"
179
+
180
+ async with session.post(url, json=json_data) as response:
181
+ if response.status >= _HTTP_CLIENT_ERROR:
182
+ # Try to parse error message
183
+ try:
184
+ error_data = await response.json()
185
+ error_msg = error_data.get("error", f"HTTP {response.status}")
186
+ except Exception:
187
+ error_msg = f"HTTP {response.status}: {response.reason}"
188
+
189
+ if response.status >= _HTTP_SERVER_ERROR:
190
+ raise SaynaServerError(error_msg)
191
+ raise SaynaValidationError(error_msg)
192
+
193
+ binary_data = await response.read()
194
+ headers = dict(response.headers)
195
+ return binary_data, headers
196
+
197
+ async def _handle_response(self, response: aiohttp.ClientResponse) -> dict[str, Any]:
198
+ """Handle HTTP response and raise appropriate errors.
199
+
200
+ Args:
201
+ response: aiohttp response object
202
+
203
+ Returns:
204
+ Parsed JSON response
205
+
206
+ Raises:
207
+ SaynaServerError: If the server returns a 5xx error
208
+ SaynaValidationError: If the request is invalid (4xx error)
209
+ """
210
+ if response.status >= _HTTP_CLIENT_ERROR:
211
+ try:
212
+ error_data = await response.json()
213
+ error_msg = error_data.get("error", f"HTTP {response.status}")
214
+ except Exception:
215
+ error_msg = f"HTTP {response.status}: {response.reason}"
216
+
217
+ if response.status >= _HTTP_SERVER_ERROR:
218
+ raise SaynaServerError(error_msg)
219
+ raise SaynaValidationError(error_msg)
220
+
221
+ try:
222
+ json_response: dict[str, Any] = await response.json()
223
+ return json_response
224
+ except json.JSONDecodeError as e:
225
+ msg = f"Failed to decode JSON response: {e}"
226
+ raise SaynaServerError(msg) from e
227
+
228
+ async def __aenter__(self) -> "SaynaHttpClient":
229
+ """Async context manager entry."""
230
+ await self._ensure_session()
231
+ return self
232
+
233
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
234
+ """Async context manager exit."""
235
+ await self.close()
sayna_client/types.py ADDED
@@ -0,0 +1,377 @@
1
+ """Type definitions for the Sayna SDK."""
2
+
3
+ from typing import Any, Literal, Optional, Union
4
+
5
+ from pydantic import BaseModel, Field, RootModel
6
+
7
+
8
+ class Pronunciation(BaseModel):
9
+ """Word pronunciation override for text-to-speech."""
10
+
11
+ word: str = Field(..., description="The word to be pronounced differently")
12
+ pronunciation: str = Field(..., description="Phonetic pronunciation or alternative spelling")
13
+
14
+
15
+ class STTConfig(BaseModel):
16
+ """Speech-to-Text (STT) configuration options."""
17
+
18
+ provider: str = Field(..., description="The STT provider to use (e.g., 'deepgram', 'google')")
19
+ language: str = Field(..., description="Language code for speech recognition (e.g., 'en-US')")
20
+ sample_rate: int = Field(..., description="Audio sample rate in Hz (e.g., 16000, 44100)")
21
+ channels: int = Field(..., description="Number of audio channels (1 for mono, 2 for stereo)")
22
+ punctuation: bool = Field(..., description="Whether to include punctuation in transcriptions")
23
+ encoding: str = Field(..., description="Audio encoding format (e.g., 'linear16', 'opus')")
24
+ model: str = Field(..., description="STT model identifier to use")
25
+
26
+
27
+ class TTSConfig(BaseModel):
28
+ """Text-to-Speech (TTS) configuration options."""
29
+
30
+ provider: str = Field(..., description="The TTS provider to use (e.g., 'elevenlabs', 'google')")
31
+ voice_id: str = Field(..., description="Voice identifier for the selected provider")
32
+ speaking_rate: float = Field(
33
+ ..., description="Speech rate multiplier (e.g., 1.0 for normal, 1.5 for faster)"
34
+ )
35
+ audio_format: str = Field(..., description="Audio format for TTS output (e.g., 'mp3', 'pcm')")
36
+ sample_rate: int = Field(..., description="Audio sample rate in Hz (e.g., 16000, 44100)")
37
+ connection_timeout: int = Field(..., description="Connection timeout in milliseconds")
38
+ request_timeout: int = Field(..., description="Request timeout in milliseconds")
39
+ model: str = Field(..., description="TTS model identifier to use")
40
+ pronunciations: list[Pronunciation] = Field(
41
+ default_factory=list, description="Custom pronunciation overrides"
42
+ )
43
+
44
+
45
+ class LiveKitConfig(BaseModel):
46
+ """LiveKit room configuration for real-time communication."""
47
+
48
+ room_name: str = Field(..., description="LiveKit room name to join")
49
+ enable_recording: Optional[bool] = Field(
50
+ default=False, description="Whether to enable session recording"
51
+ )
52
+ sayna_participant_identity: Optional[str] = Field(
53
+ default="sayna-ai", description="Identity assigned to the agent participant"
54
+ )
55
+ sayna_participant_name: Optional[str] = Field(
56
+ default="Sayna AI", description="Display name for the agent participant"
57
+ )
58
+ listen_participants: Optional[list[str]] = Field(
59
+ default_factory=list,
60
+ description="List of participant identities to monitor (empty = all participants)",
61
+ )
62
+
63
+
64
+ # ============================================================================
65
+ # Outgoing Messages (Client -> Server)
66
+ # ============================================================================
67
+
68
+
69
+ class ConfigMessage(BaseModel):
70
+ """Configuration message sent to initialize the Sayna WebSocket connection."""
71
+
72
+ type: Literal["config"] = "config"
73
+ stream_id: Optional[str] = Field(
74
+ default=None,
75
+ description="Session identifier used for recording paths; server generates a UUID when omitted",
76
+ )
77
+ audio: Optional[bool] = Field(default=True, description="Whether audio streaming is enabled")
78
+ stt_config: Optional[STTConfig] = Field(
79
+ default=None, description="Speech-to-text configuration (required when audio=true)"
80
+ )
81
+ tts_config: Optional[TTSConfig] = Field(
82
+ default=None, description="Text-to-speech configuration (required when audio=true)"
83
+ )
84
+ livekit: Optional[LiveKitConfig] = Field(
85
+ default=None, description="Optional LiveKit room configuration"
86
+ )
87
+
88
+
89
+ class SpeakMessage(BaseModel):
90
+ """Message to request text-to-speech synthesis."""
91
+
92
+ type: Literal["speak"] = "speak"
93
+ text: str = Field(..., description="Text to synthesize")
94
+ flush: Optional[bool] = Field(
95
+ default=None, description="Whether to flush the TTS queue before speaking"
96
+ )
97
+ allow_interruption: Optional[bool] = Field(
98
+ default=None, description="Whether this speech can be interrupted"
99
+ )
100
+
101
+
102
+ class ClearMessage(BaseModel):
103
+ """Message to clear the TTS queue."""
104
+
105
+ type: Literal["clear"] = "clear"
106
+
107
+
108
+ class SendMessageMessage(BaseModel):
109
+ """Message to send data to the Sayna session."""
110
+
111
+ type: Literal["send_message"] = "send_message"
112
+ message: str = Field(..., description="Message content")
113
+ role: str = Field(..., description="Message role (e.g., 'user', 'assistant')")
114
+ topic: Optional[str] = Field(default=None, description="Optional topic identifier")
115
+ debug: Optional[dict[str, Any]] = Field(default=None, description="Optional debug metadata")
116
+
117
+
118
+ # ============================================================================
119
+ # Incoming Messages (Server -> Client)
120
+ # ============================================================================
121
+
122
+
123
+ class ReadyMessage(BaseModel):
124
+ """Message received when the Sayna connection is ready."""
125
+
126
+ type: Literal["ready"] = "ready"
127
+ stream_id: Optional[str] = Field(
128
+ default=None,
129
+ description="Session identifier returned by server; may be auto-generated if not provided in config",
130
+ )
131
+ livekit_room_name: Optional[str] = Field(
132
+ default=None, description="LiveKit room name (present only when LiveKit is enabled)"
133
+ )
134
+ livekit_url: str = Field(..., description="LiveKit WebSocket URL configured on the server")
135
+ sayna_participant_identity: Optional[str] = Field(
136
+ default=None,
137
+ description="Identity assigned to the agent participant when LiveKit is enabled",
138
+ )
139
+ sayna_participant_name: Optional[str] = Field(
140
+ default=None,
141
+ description="Display name assigned to the agent participant when LiveKit is enabled",
142
+ )
143
+
144
+
145
+ class STTResultMessage(BaseModel):
146
+ """Speech-to-text transcription result."""
147
+
148
+ type: Literal["stt_result"] = "stt_result"
149
+ transcript: str = Field(..., description="Transcribed text")
150
+ is_final: bool = Field(..., description="Whether this is a final transcription")
151
+ is_speech_final: bool = Field(..., description="Whether speech has concluded")
152
+ confidence: float = Field(..., description="Transcription confidence score (0-1)")
153
+
154
+
155
+ class ErrorMessage(BaseModel):
156
+ """Error message from the Sayna server."""
157
+
158
+ type: Literal["error"] = "error"
159
+ message: str = Field(..., description="Error description")
160
+
161
+
162
+ class SaynaMessage(BaseModel):
163
+ """Message data from a Sayna session participant."""
164
+
165
+ message: Optional[str] = Field(default=None, description="Message content")
166
+ data: Optional[str] = Field(default=None, description="Additional data payload")
167
+ identity: str = Field(..., description="Participant identity")
168
+ topic: str = Field(..., description="Message topic")
169
+ room: str = Field(..., description="Room identifier")
170
+ timestamp: int = Field(..., description="Unix timestamp in milliseconds")
171
+
172
+
173
+ class MessageMessage(BaseModel):
174
+ """Wrapper for participant messages."""
175
+
176
+ type: Literal["message"] = "message"
177
+ message: SaynaMessage = Field(..., description="The message data")
178
+
179
+
180
+ class Participant(BaseModel):
181
+ """Information about a session participant."""
182
+
183
+ identity: str = Field(..., description="Unique participant identity")
184
+ name: Optional[str] = Field(default=None, description="Optional display name")
185
+ room: str = Field(..., description="Room identifier")
186
+ timestamp: int = Field(..., description="Unix timestamp in milliseconds")
187
+
188
+
189
+ class ParticipantDisconnectedMessage(BaseModel):
190
+ """Message received when a participant disconnects."""
191
+
192
+ type: Literal["participant_disconnected"] = "participant_disconnected"
193
+ participant: Participant = Field(..., description="The disconnected participant")
194
+
195
+
196
+ class TTSPlaybackCompleteMessage(BaseModel):
197
+ """Message received when the TTS playback is complete."""
198
+
199
+ type: Literal["tts_playback_complete"] = "tts_playback_complete"
200
+ timestamp: int = Field(..., description="Unix timestamp in milliseconds")
201
+
202
+
203
+ # ============================================================================
204
+ # REST API Types
205
+ # ============================================================================
206
+
207
+
208
+ class HealthResponse(BaseModel):
209
+ """Response from GET / health endpoint."""
210
+
211
+ status: str = Field(..., description="Health status (should be 'OK')")
212
+
213
+
214
+ class VoiceDescriptor(BaseModel):
215
+ """Voice descriptor from a TTS provider."""
216
+
217
+ id: str = Field(..., description="Provider-specific identifier for the voice profile")
218
+ sample: str = Field(default="", description="URL to a preview audio sample")
219
+ name: str = Field(..., description="Human-readable name supplied by the provider")
220
+ accent: str = Field(default="Unknown", description="Detected accent associated with the voice")
221
+ gender: str = Field(
222
+ default="Unknown", description="Inferred gender label from provider metadata"
223
+ )
224
+ language: str = Field(default="Unknown", description="Primary language for synthesis")
225
+
226
+
227
+ class VoicesResponse(RootModel[dict[str, list[VoiceDescriptor]]]):
228
+ """Response from GET /voices endpoint.
229
+
230
+ Dictionary where keys are provider names and values are lists of voice descriptors.
231
+ """
232
+
233
+ root: dict[str, list[VoiceDescriptor]]
234
+
235
+
236
+ class LiveKitTokenRequest(BaseModel):
237
+ """Request body for POST /livekit/token."""
238
+
239
+ room_name: str = Field(..., description="LiveKit room to join or create")
240
+ participant_name: str = Field(..., description="Display name assigned to the participant")
241
+ participant_identity: str = Field(..., description="Unique identifier for the participant")
242
+
243
+
244
+ class LiveKitTokenResponse(BaseModel):
245
+ """Response from POST /livekit/token."""
246
+
247
+ token: str = Field(..., description="JWT granting LiveKit permissions")
248
+ room_name: str = Field(..., description="Echo of the requested room")
249
+ participant_identity: str = Field(..., description="Echo of the requested identity")
250
+ livekit_url: str = Field(..., description="WebSocket endpoint for the LiveKit server")
251
+
252
+
253
+ class SpeakRequest(BaseModel):
254
+ """Request body for POST /speak."""
255
+
256
+ text: str = Field(..., description="Text to convert to speech")
257
+ tts_config: TTSConfig = Field(..., description="Provider configuration without API credentials")
258
+
259
+
260
+ class SipHook(BaseModel):
261
+ """A SIP webhook hook configuration.
262
+
263
+ Defines a mapping between a SIP domain pattern and a webhook URL
264
+ that will receive forwarded SIP events.
265
+ """
266
+
267
+ host: str = Field(
268
+ ...,
269
+ description="SIP domain pattern (case-insensitive) to match incoming SIP requests",
270
+ )
271
+ url: str = Field(
272
+ ...,
273
+ description="HTTPS URL to forward webhook events to when the host pattern matches",
274
+ )
275
+
276
+
277
+ class SipHooksResponse(BaseModel):
278
+ """Response from GET /sip/hooks and POST /sip/hooks endpoints.
279
+
280
+ Contains the list of all configured SIP webhook hooks.
281
+ """
282
+
283
+ hooks: list[SipHook] = Field(
284
+ default_factory=list,
285
+ description="List of configured SIP hooks",
286
+ )
287
+
288
+
289
+ class SetSipHooksRequest(BaseModel):
290
+ """Request body for POST /sip/hooks."""
291
+
292
+ hooks: list[SipHook] = Field(
293
+ ...,
294
+ description="List of hooks to add or replace. Existing hooks with matching hosts are replaced.",
295
+ )
296
+
297
+
298
+ class DeleteSipHooksRequest(BaseModel):
299
+ """Request body for DELETE /sip/hooks."""
300
+
301
+ hosts: list[str] = Field(
302
+ ...,
303
+ min_length=1,
304
+ description="List of host names to remove (case-insensitive). Must contain at least one host.",
305
+ )
306
+
307
+
308
+ # ============================================================================
309
+ # Webhook Types
310
+ # ============================================================================
311
+
312
+
313
+ class WebhookSIPParticipant(BaseModel):
314
+ """Participant information from a SIP webhook event."""
315
+
316
+ name: Optional[str] = Field(
317
+ default=None,
318
+ description="Display name of the SIP participant (may be None if not provided)",
319
+ )
320
+ identity: str = Field(..., description="Unique identity assigned to the participant")
321
+ sid: str = Field(..., description="Participant session ID from LiveKit")
322
+
323
+
324
+ class WebhookSIPRoom(BaseModel):
325
+ """Room information from a SIP webhook event."""
326
+
327
+ name: str = Field(..., description="Name of the LiveKit room")
328
+ sid: str = Field(..., description="Room session ID from LiveKit")
329
+
330
+
331
+ class WebhookSIPOutput(BaseModel):
332
+ """SIP webhook payload sent from Sayna service.
333
+
334
+ This represents a cryptographically signed webhook event forwarded by Sayna
335
+ when a SIP participant joins a LiveKit room. Use the WebhookReceiver class
336
+ to verify the signature and parse this payload securely.
337
+
338
+ See Also:
339
+ WebhookReceiver: Class for verifying and receiving webhooks
340
+
341
+ Example:
342
+ >>> from sayna_client import WebhookReceiver
343
+ >>> receiver = WebhookReceiver("your-secret-key")
344
+ >>> webhook = receiver.receive(headers, raw_body)
345
+ >>> print(f"From: {webhook.from_phone_number}")
346
+ >>> print(f"To: {webhook.to_phone_number}")
347
+ >>> print(f"Room: {webhook.room.name}")
348
+ """
349
+
350
+ participant: WebhookSIPParticipant = Field(..., description="SIP participant information")
351
+ room: WebhookSIPRoom = Field(..., description="LiveKit room information")
352
+ from_phone_number: str = Field(
353
+ ...,
354
+ description="Caller's phone number (E.164 format, e.g., '+15559876543')",
355
+ )
356
+ to_phone_number: str = Field(
357
+ ...,
358
+ description="Called phone number (E.164 format, e.g., '+15551234567')",
359
+ )
360
+ room_prefix: str = Field(..., description="Room name prefix configured in Sayna (e.g., 'sip-')")
361
+ sip_host: str = Field(
362
+ ..., description="SIP domain extracted from the To header (e.g., 'example.com')"
363
+ )
364
+
365
+
366
+ # ============================================================================
367
+ # Union Types
368
+ # ============================================================================
369
+
370
+ OutgoingMessage = Union[
371
+ ReadyMessage,
372
+ STTResultMessage,
373
+ ErrorMessage,
374
+ MessageMessage,
375
+ ParticipantDisconnectedMessage,
376
+ TTSPlaybackCompleteMessage,
377
+ ]