""" Daily.co API Client Complete async client for Daily.co REST API with Pydantic models. Reference: https://docs.daily.co/reference/rest-api """ from http import HTTPStatus from typing import Any, Literal from uuid import UUID import httpx import structlog from reflector.utils.string import NonEmptyString from .requests import ( CreateMeetingTokenRequest, CreateRoomRequest, CreateWebhookRequest, UpdateWebhookRequest, ) from .responses import ( MeetingParticipantsResponse, MeetingResponse, MeetingTokenResponse, RecordingResponse, RoomPresenceResponse, RoomResponse, WebhookResponse, ) logger = structlog.get_logger(__name__) RecordingType = Literal["cloud", "raw-tracks"] class DailyApiError(Exception): """Daily.co API error with full request/response context.""" def __init__(self, operation: str, response: httpx.Response): self.operation = operation self.response = response self.status_code = response.status_code self.response_body = response.text self.url = str(response.url) self.request_body = ( response.request.content.decode() if response.request.content else None ) super().__init__( f"Daily.co API error: {operation} failed with status {self.status_code}: {response.text}" ) class DailyApiClient: """ Complete async client for Daily.co REST API. Usage: # Direct usage client = DailyApiClient(api_key="your_api_key") room = await client.create_room(CreateRoomRequest(name="my-room")) await client.close() # Clean up when done # Context manager (recommended) async with DailyApiClient(api_key="your_api_key") as client: room = await client.create_room(CreateRoomRequest(name="my-room")) """ BASE_URL = "https://api.daily.co/v1" DEFAULT_TIMEOUT = 10.0 def __init__( self, api_key: NonEmptyString, webhook_secret: NonEmptyString | None = None, timeout: float = DEFAULT_TIMEOUT, base_url: NonEmptyString | None = None, ): """ Initialize Daily.co API client. Args: api_key: Daily.co API key (Bearer token) webhook_secret: Base64-encoded HMAC secret for webhook verification. Must match the 'hmac' value provided when creating webhooks. Generate with: base64.b64encode(os.urandom(32)).decode() timeout: Default request timeout in seconds base_url: Override base URL (for testing) """ self.api_key = api_key self.webhook_secret = webhook_secret self.timeout = timeout self.base_url = base_url or self.BASE_URL self.headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } self._client: httpx.AsyncClient | None = None async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): await self.close() async def _get_client(self) -> httpx.AsyncClient: if self._client is None: self._client = httpx.AsyncClient(timeout=self.timeout) return self._client async def close(self): if self._client is not None: await self._client.aclose() self._client = None async def _handle_response( self, response: httpx.Response, operation: str ) -> dict[str, Any]: """ Handle API response with error logging. Args: response: HTTP response operation: Operation name for logging (e.g., "create_room") Returns: Parsed JSON response Raises: DailyApiError: If request failed with full context """ if response.status_code >= 400: logger.error( f"Daily.co API error: {operation}", status_code=response.status_code, response_body=response.text, request_body=response.request.content.decode() if response.request.content else None, url=str(response.url), ) raise DailyApiError(operation, response) return response.json() # ============================================================================ # ROOMS # ============================================================================ async def create_room(self, request: CreateRoomRequest) -> RoomResponse: """ Create a new Daily.co room. Reference: https://docs.daily.co/reference/rest-api/rooms/create-room Args: request: Room creation request with name, privacy, and properties Returns: Created room data including URL and ID Raises: httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.post( f"{self.base_url}/rooms", headers=self.headers, json=request.model_dump(exclude_none=True), ) data = await self._handle_response(response, "create_room") return RoomResponse(**data) async def get_room(self, room_name: NonEmptyString) -> RoomResponse: """ Get room configuration. Args: room_name: Daily.co room name Returns: Room configuration data Raises: httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.get( f"{self.base_url}/rooms/{room_name}", headers=self.headers, ) data = await self._handle_response(response, "get_room") return RoomResponse(**data) async def get_room_presence( self, room_name: NonEmptyString ) -> RoomPresenceResponse: """ Get current participants in a room (real-time presence). Reference: https://docs.daily.co/reference/rest-api/rooms/get-room-presence Args: room_name: Daily.co room name Returns: List of currently present participants with join time and duration Raises: httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.get( f"{self.base_url}/rooms/{room_name}/presence", headers=self.headers, ) data = await self._handle_response(response, "get_room_presence") return RoomPresenceResponse(**data) async def delete_room(self, room_name: NonEmptyString) -> None: """ Delete a room (idempotent - succeeds even if room doesn't exist). Reference: https://docs.daily.co/reference/rest-api/rooms/delete-room Args: room_name: Daily.co room name Raises: httpx.HTTPStatusError: If API request fails (except 404) """ client = await self._get_client() response = await client.delete( f"{self.base_url}/rooms/{room_name}", headers=self.headers, ) # Idempotent delete - 404 means already deleted if response.status_code == HTTPStatus.NOT_FOUND: logger.debug("Room not found (already deleted)", room_name=room_name) return await self._handle_response(response, "delete_room") # ============================================================================ # MEETINGS # ============================================================================ async def get_meeting(self, meeting_id: NonEmptyString) -> MeetingResponse: """ Get full meeting information including participants. Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-information Args: meeting_id: Daily.co meeting/session ID Returns: Meeting metadata including room, duration, participants, and status Raises: httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.get( f"{self.base_url}/meetings/{meeting_id}", headers=self.headers, ) data = await self._handle_response(response, "get_meeting") return MeetingResponse(**data) async def get_meeting_participants( self, meeting_id: NonEmptyString, limit: int | None = None, joined_after: NonEmptyString | None = None, joined_before: NonEmptyString | None = None, ) -> MeetingParticipantsResponse: """ Get historical participant data from a completed meeting (paginated). Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants Args: meeting_id: Daily.co meeting/session ID limit: Maximum number of participant records to return joined_after: Return participants who joined after this participant_id joined_before: Return participants who joined before this participant_id Returns: List of participants with join times and duration Raises: httpx.HTTPStatusError: If API request fails (404 when no more participants) Note: For pagination, use joined_after with the last participant_id from previous response. Returns 404 when no more participants remain. """ params = {} if limit is not None: params["limit"] = limit if joined_after is not None: params["joined_after"] = joined_after if joined_before is not None: params["joined_before"] = joined_before client = await self._get_client() response = await client.get( f"{self.base_url}/meetings/{meeting_id}/participants", headers=self.headers, params=params, ) data = await self._handle_response(response, "get_meeting_participants") return MeetingParticipantsResponse(**data) # ============================================================================ # RECORDINGS # ============================================================================ async def get_recording(self, recording_id: NonEmptyString) -> RecordingResponse: """ https://docs.daily.co/reference/rest-api/recordings/get-recording-information Get recording metadata and status. """ client = await self._get_client() response = await client.get( f"{self.base_url}/recordings/{recording_id}", headers=self.headers, ) data = await self._handle_response(response, "get_recording") return RecordingResponse(**data) async def list_recordings( self, room_name: NonEmptyString | None = None, starting_after: str | None = None, ending_before: str | None = None, limit: int = 100, ) -> list[RecordingResponse]: """ List recordings with optional filters. Reference: https://docs.daily.co/reference/rest-api/recordings Args: room_name: Filter by room name starting_after: Pagination cursor - recording ID to start after ending_before: Pagination cursor - recording ID to end before limit: Max results per page (default 100, max 100) Note: starting_after/ending_before are pagination cursors (recording IDs), NOT time filters. API returns recordings in reverse chronological order. """ client = await self._get_client() params = {"limit": limit} if room_name: params["room_name"] = room_name if starting_after: params["starting_after"] = starting_after if ending_before: params["ending_before"] = ending_before response = await client.get( f"{self.base_url}/recordings", headers=self.headers, params=params, ) data = await self._handle_response(response, "list_recordings") if not isinstance(data, dict) or "data" not in data: logger.error( "Daily.co API returned unexpected format for list_recordings", data_type=type(data).__name__, data_keys=list(data.keys()) if isinstance(data, dict) else None, data_sample=str(data)[:500], room_name=room_name, operation="list_recordings", ) raise httpx.HTTPStatusError( message=f"Unexpected response format from list_recordings: {type(data).__name__}", request=response.request, response=response, ) return [RecordingResponse(**r) for r in data["data"]] async def start_recording( self, room_name: NonEmptyString, recording_type: RecordingType, instance_id: UUID, ) -> dict[str, Any]: """Start recording via REST API. Reference: https://docs.daily.co/reference/rest-api/rooms/recordings/start Args: room_name: Daily.co room name recording_type: Recording type instance_id: UUID for this recording session Returns: Recording start confirmation from Daily.co API Raises: DailyApiError: If API request fails """ client = await self._get_client() response = await client.post( f"{self.base_url}/rooms/{room_name}/recordings/start", headers=self.headers, json={ "type": recording_type, "instanceId": str(instance_id), }, ) return await self._handle_response(response, "start_recording") # ============================================================================ # MEETING TOKENS # ============================================================================ async def create_meeting_token( self, request: CreateMeetingTokenRequest ) -> MeetingTokenResponse: """ Create a meeting token for participant authentication. Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token Args: request: Token properties including room name, user_id, permissions Returns: JWT meeting token Raises: httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.post( f"{self.base_url}/meeting-tokens", headers=self.headers, json=request.model_dump(exclude_none=True), ) data = await self._handle_response(response, "create_meeting_token") return MeetingTokenResponse(**data) # ============================================================================ # WEBHOOKS # ============================================================================ async def list_webhooks(self) -> list[WebhookResponse]: """ List all configured webhooks for this account. Reference: https://docs.daily.co/reference/rest-api/webhooks Returns: List of webhook configurations Raises: httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.get( f"{self.base_url}/webhooks", headers=self.headers, ) data = await self._handle_response(response, "list_webhooks") # Daily.co returns array directly (not paginated) if isinstance(data, list): return [WebhookResponse(**wh) for wh in data] # Future-proof: handle potential pagination envelope if isinstance(data, dict) and "data" in data: return [WebhookResponse(**wh) for wh in data["data"]] logger.warning("Unexpected webhook list response format", data=data) return [] async def create_webhook(self, request: CreateWebhookRequest) -> WebhookResponse: """ Create a new webhook subscription. Reference: https://docs.daily.co/reference/rest-api/webhooks Args: request: Webhook configuration with URL, event types, and HMAC secret Returns: Created webhook with UUID and state Raises: httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.post( f"{self.base_url}/webhooks", headers=self.headers, json=request.model_dump(exclude_none=True), ) data = await self._handle_response(response, "create_webhook") return WebhookResponse(**data) async def update_webhook( self, webhook_uuid: NonEmptyString, request: UpdateWebhookRequest ) -> WebhookResponse: """ Update webhook configuration. Note: Daily.co may not support PATCH for all fields. Common pattern is delete + recreate. Reference: https://docs.daily.co/reference/rest-api/webhooks Args: webhook_uuid: Webhook UUID to update request: Updated webhook configuration Returns: Updated webhook configuration Raises: httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.patch( f"{self.base_url}/webhooks/{webhook_uuid}", headers=self.headers, json=request.model_dump(exclude_none=True), ) data = await self._handle_response(response, "update_webhook") return WebhookResponse(**data) async def delete_webhook(self, webhook_uuid: NonEmptyString) -> None: """ Delete a webhook. Reference: https://docs.daily.co/reference/rest-api/webhooks Args: webhook_uuid: Webhook UUID to delete Raises: httpx.HTTPStatusError: If webhook not found or deletion fails """ client = await self._get_client() response = await client.delete( f"{self.base_url}/webhooks/{webhook_uuid}", headers=self.headers, ) await self._handle_response(response, "delete_webhook") # ============================================================================ # HELPER METHODS # ============================================================================ async def find_webhook_by_url(self, url: NonEmptyString) -> WebhookResponse | None: """ Find a webhook by its URL. Args: url: Webhook endpoint URL to search for Returns: Webhook if found, None otherwise """ webhooks = await self.list_webhooks() for webhook in webhooks: if webhook.url == url: return webhook return None async def find_webhooks_by_pattern( self, pattern: NonEmptyString ) -> list[WebhookResponse]: """ Find webhooks matching a URL pattern (e.g., 'ngrok'). Args: pattern: String to match in webhook URLs Returns: List of matching webhooks """ webhooks = await self.list_webhooks() return [wh for wh in webhooks if pattern in wh.url]