mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-08 14:56:49 +00:00
* feat: local LLM via Ollama + structured output response_format
- Add setup script (scripts/setup-local-llm.sh) for one-command Ollama setup
Mac: native Metal GPU, Linux: containerized via docker-compose profiles
- Add ollama-gpu and ollama-cpu docker-compose profiles for Linux
- Add extra_hosts to server/hatchet-worker-llm for host.docker.internal
- Pass response_format JSON schema in StructuredOutputWorkflow.extract()
enabling grammar-based constrained decoding on Ollama/llama.cpp/vLLM/OpenAI
- Update .env.example with Ollama as default LLM option
- Add Ollama PRD and local dev setup docs
* refactor: move Ollama services to docker-compose.standalone.yml
Ollama profiles (ollama-gpu, ollama-cpu) are only for Linux standalone
deployment. Mac devs never use them. Separate file keeps the main
compose clean and provides a natural home for future standalone services
(MinIO, etc.).
Linux: docker compose -f docker-compose.yml -f docker-compose.standalone.yml --profile ollama-gpu up -d
Mac: docker compose up -d (native Ollama, no standalone file needed)
* fix: correct PRD goal (demo/eval, not dev replacement) and processor naming
* chore: remove completed PRD, rename setup doc, drop response_format tests
- Remove docs/01_ollama.prd.md (implementation complete)
- Rename local-dev-setup.md -> standalone-local-setup.md
- Remove TestResponseFormat class from test_llm_retry.py
* docs: resolve standalone storage step — skip S3 for live-only mode
* docs: add TASKS.md for standalone env defaults + setup script work
* feat: add unified setup-local-dev.sh for standalone deployment
Single script takes fresh clone to working Reflector: Ollama/LLM setup,
env file generation (server/.env + www/.env.local), docker compose up,
health checks. No Hatchet in standalone — live pipeline is pure Celery.
* chore: rename to setup-standalone, remove redundant setup-local-llm.sh
* feat: add custom S3 endpoint support + Garage standalone storage
Add TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL setting to enable S3-compatible
backends (Garage, MinIO). When set, uses path-style addressing and
routes all requests to the custom endpoint. When unset, AWS behavior
is unchanged.
- AwsStorage: accept aws_endpoint_url, pass to all 6 session.client()
calls, configure path-style addressing and base_url
- Fix 4 direct AwsStorage constructions in Hatchet workflows to pass
endpoint_url (would have silently targeted wrong endpoint)
- Standalone: add Garage service to docker-compose.standalone.yml,
setup script initializes layout/bucket/key and writes credentials
- Fix compose_cmd() bug: Mac path was missing standalone yml
- garage.toml template with runtime secret generation via openssl
* fix: standalone setup — garage config, symlink handling, healthcheck
- garage.toml: fix rpc_secret field name (was secret_transmitter),
move to top-level per Garage v1.1.0 spec, remove unused [s3_web]
- setup-standalone.sh: resolve symlinked .env files before writing,
always ensure all standalone-critical vars via env_set,
fix garage key create/info syntax (positional arg, not --name),
avoid overwriting key secret with "(redacted)" on re-run,
use compose_cmd in health check
- docker-compose.standalone.yml: fix garage healthcheck (no curl in
image, use /garage stats instead)
* docs: update standalone md — symlink handling, garage config template
* docs: add troubleshooting section + port conflict check in setup script
Port conflicts from stale next dev / other worktree processes silently
shadow Docker container port mappings, causing env vars to appear ignored.
* fix: invalidate transcript query on STATUS websocket event
Without this, the processing page never redirects after completion
because the redirect logic watches the REST query data, not the
WebSocket status state.
Cherry-picked from feat-dag-progress (faec509a).
* fix: local env setup (#855)
* Ensure rate limit
* Increase nextjs compilation speed
* Fix daily no content handling
* Simplify daily webhook creation
* Fix webhook request validation
* feat: add local pyannote file diarization processor (#858)
* feat: add local pyannote file diarization processor
Enables file diarization without Modal by using pyannote.audio locally.
Downloads model bundle from S3 on first use, caches locally, patches
config to use local paths. Set DIARIZATION_BACKEND=pyannote to enable.
* fix: standalone setup enables pyannote diarization and public mode
Replace DIARIZATION_ENABLED=false with DIARIZATION_BACKEND=pyannote so
file uploads get speaker diarization out of the box. Add PUBLIC_MODE=true
so unauthenticated users can list/browse transcripts.
* fix: touch env files before first compose_cmd in standalone setup
docker-compose.yml references www/.env.local as env_file, but the
setup script only creates it in step 4. compose_cmd calls in step 3
(Garage) fail on a fresh clone when the file doesn't exist yet.
* feat: standalone uses self-hosted GPU service for transcription+diarization
Replace in-process pyannote approach with self-hosted gpu/self_hosted/ service.
Same HTTP API as Modal — just TRANSCRIPT_URL/DIARIZATION_URL point to local container.
- Add gpu/self_hosted/Dockerfile.cpu (GPU Dockerfile minus NVIDIA CUDA)
- Add S3 model bundle fallback in diarizer.py when HF_TOKEN not set
- Add gpu service to docker-compose.standalone.yml with compose env overrides
- Fix /browse empty in PUBLIC_MODE (search+list queries filtered out roomless transcripts)
- Remove audio_diarization_pyannote.py, file_diarization_pyannote.py and tests
- Remove pyannote-audio from server local deps
* fix: allow unauthenticated GPU requests when no API key configured
OAuth2PasswordBearer with auto_error=True rejects requests without
Authorization header before apikey_auth can check if auth is needed.
* fix: rename standalone gpu service to cpu to match Dockerfile.cpu usage
* docs: add programmatic testing section and fix gpu->cpu naming in setup script/docs
- Add "Testing programmatically" section to standalone docs with curl commands
for creating transcript, uploading audio, polling status, checking result
- Fix setup-standalone.sh to reference `cpu` service (was still `gpu` after rename)
- Update all docs references from gpu to cpu service naming
---------
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
* Fix websocket disconnect errors
* Fix event loop is closed in Celery workers
* Allow reprocessing idle multitrack transcripts
* feat: add local pyannote file diarization processor
Enables file diarization without Modal by using pyannote.audio locally.
Downloads model bundle from S3 on first use, caches locally, patches
config to use local paths. Set DIARIZATION_BACKEND=pyannote to enable.
* feat: standalone uses self-hosted GPU service for transcription+diarization
Replace in-process pyannote approach with self-hosted gpu/self_hosted/ service.
Same HTTP API as Modal — just TRANSCRIPT_URL/DIARIZATION_URL point to local container.
- Add gpu/self_hosted/Dockerfile.cpu (GPU Dockerfile minus NVIDIA CUDA)
- Add S3 model bundle fallback in diarizer.py when HF_TOKEN not set
- Add gpu service to docker-compose.standalone.yml with compose env overrides
- Fix /browse empty in PUBLIC_MODE (search+list queries filtered out roomless transcripts)
- Remove audio_diarization_pyannote.py, file_diarization_pyannote.py and tests
- Remove pyannote-audio from server local deps
* fix: set source_kind to FILE on audio file upload
The upload endpoint left source_kind as the default LIVE even when
a file was uploaded. Now sets it to FILE when the upload completes.
* Add hatchet env vars
* fix: improve port conflict detection and ollama model check in standalone setup
- Filter OrbStack/Docker Desktop PIDs from port conflict check (false positives on Mac)
- Check all infra ports (5432, 6379, 3900, 3903) not just app ports
- Fix ollama model detection to match on name column only
- Document OrbStack and cross-project port conflicts in troubleshooting
* fix: processing page auto-redirect after file upload completes
Three fixes for the processing page not redirecting when status becomes "ended":
- Add useWebSockets to processing page so it receives STATUS events
- Remove OAuth2PasswordBearer from auth_none — broke WebSocket endpoints (500)
- Reconnect stale Redis in ws_manager when Celery worker reuses dead event loop
* fix: mock Celery broker in idle transcript validation test
test_validation_idle_transcript_with_recording_allowed called
validate_transcript_for_processing without mocking
task_is_scheduled_or_active, which attempts a real Celery
broker connection (AMQP port 5672). Other tests in the same
file already mock this — apply the same pattern here.
* Enable server host mode
* Fix webrtc connection
* Remove turbopack
* fix: standalone GPU service connectivity with host network mode
Server runs with network_mode: host and can't resolve Docker service
names. Publish cpu port as 8100 on host, point server at localhost:8100.
Worker stays on bridge network using cpu:8000. Add dummy
TRANSCRIPT_MODAL_API_KEY since OpenAI SDK requires it even for local
endpoints.
---------
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
Co-authored-by: Sergey Mankovsky <sergey@mankovsky.dev>
611 lines
19 KiB
Python
611 lines
19 KiB
Python
"""
|
|
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)
|
|
|
|
if not response.content:
|
|
return {}
|
|
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]
|