mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 12:19:06 +00:00
vibe dailyco
This commit is contained in:
@@ -71,3 +71,27 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run
|
||||
|
||||
## Sentry DSN configuration
|
||||
#SENTRY_DSN=
|
||||
|
||||
## =======================================================
|
||||
## Video Platform Configuration
|
||||
## =======================================================
|
||||
|
||||
## Whereby (existing provider)
|
||||
#WHEREBY_API_KEY=your-whereby-api-key
|
||||
#WHEREBY_WEBHOOK_SECRET=your-whereby-webhook-secret
|
||||
#AWS_WHEREBY_ACCESS_KEY_ID=your-aws-key
|
||||
#AWS_WHEREBY_ACCESS_KEY_SECRET=your-aws-secret
|
||||
#AWS_PROCESS_RECORDING_QUEUE_URL=https://sqs.us-west-2.amazonaws.com/...
|
||||
|
||||
## Daily.co (new provider)
|
||||
#DAILY_API_KEY=your-daily-api-key
|
||||
#DAILY_WEBHOOK_SECRET=your-daily-webhook-secret
|
||||
#DAILY_SUBDOMAIN=your-subdomain
|
||||
#AWS_DAILY_S3_BUCKET=your-daily-bucket
|
||||
#AWS_DAILY_S3_REGION=us-west-2
|
||||
#AWS_DAILY_ROLE_ARN=arn:aws:iam::ACCOUNT:role/DailyRecording
|
||||
|
||||
## Platform Selection (Feature Flags)
|
||||
#DAILY_MIGRATION_ENABLED=false # Enable Daily.co support
|
||||
#DAILY_MIGRATION_ROOM_IDS=[] # Specific rooms to use Daily
|
||||
#DEFAULT_VIDEO_PLATFORM=whereby # Default platform for new rooms
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""add_platform_support
|
||||
|
||||
Revision ID: 1e49625677e4
|
||||
Revises: dc035ff72fd5
|
||||
Create Date: 2025-10-08 13:17:29.943612
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "1e49625677e4"
|
||||
down_revision: Union[str, None] = "dc035ff72fd5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add platform field with default 'whereby' for backward compatibility."""
|
||||
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"platform",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="whereby",
|
||||
)
|
||||
)
|
||||
|
||||
with op.batch_alter_table("meeting", schema=None) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"platform",
|
||||
sa.String(),
|
||||
nullable=False,
|
||||
server_default="whereby",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove platform field."""
|
||||
with op.batch_alter_table("meeting", schema=None) as batch_op:
|
||||
batch_op.drop_column("platform")
|
||||
|
||||
with op.batch_alter_table("room", schema=None) as batch_op:
|
||||
batch_op.drop_column("platform")
|
||||
@@ -12,6 +12,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
|
||||
from reflector.logger import logger
|
||||
from reflector.metrics import metrics_init
|
||||
from reflector.settings import settings
|
||||
from reflector.views.daily import router as daily_router
|
||||
from reflector.views.meetings import router as meetings_router
|
||||
from reflector.views.rooms import router as rooms_router
|
||||
from reflector.views.rtc_offer import router as rtc_offer_router
|
||||
@@ -94,6 +95,7 @@ app.include_router(user_router, prefix="/v1")
|
||||
app.include_router(user_ws_router, prefix="/v1")
|
||||
app.include_router(zulip_router, prefix="/v1")
|
||||
app.include_router(whereby_router, prefix="/v1")
|
||||
app.include_router(daily_router, prefix="/v1/daily")
|
||||
add_pagination(app)
|
||||
|
||||
# prepare celery
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from reflector.db import get_database, metadata
|
||||
from reflector.db.rooms import Room
|
||||
from reflector.platform_types import Platform
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
meetings = sa.Table(
|
||||
@@ -55,6 +56,12 @@ meetings = sa.Table(
|
||||
),
|
||||
),
|
||||
sa.Column("calendar_metadata", JSONB),
|
||||
sa.Column(
|
||||
"platform",
|
||||
sa.String,
|
||||
nullable=False,
|
||||
server_default="whereby",
|
||||
),
|
||||
sa.Index("idx_meeting_room_id", "room_id"),
|
||||
sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
|
||||
)
|
||||
@@ -94,13 +101,14 @@ class Meeting(BaseModel):
|
||||
is_locked: bool = False
|
||||
room_mode: Literal["normal", "group"] = "normal"
|
||||
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
||||
recording_trigger: Literal[
|
||||
recording_trigger: Literal[ # whereby-specific
|
||||
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||
] = "automatic-2nd-participant"
|
||||
num_clients: int = 0
|
||||
is_active: bool = True
|
||||
calendar_event_id: str | None = None
|
||||
calendar_metadata: dict[str, Any] | None = None
|
||||
platform: Platform = "whereby"
|
||||
|
||||
|
||||
class MeetingController:
|
||||
@@ -115,6 +123,7 @@ class MeetingController:
|
||||
room: Room,
|
||||
calendar_event_id: str | None = None,
|
||||
calendar_metadata: dict[str, Any] | None = None,
|
||||
platform: Platform = "whereby",
|
||||
):
|
||||
meeting = Meeting(
|
||||
id=id,
|
||||
@@ -130,6 +139,7 @@ class MeetingController:
|
||||
recording_trigger=room.recording_trigger,
|
||||
calendar_event_id=calendar_event_id,
|
||||
calendar_metadata=calendar_metadata,
|
||||
platform=platform,
|
||||
)
|
||||
query = meetings.insert().values(**meeting.model_dump())
|
||||
await get_database().execute(query)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from sqlite3 import IntegrityError
|
||||
from typing import Literal
|
||||
from typing import Literal, Optional
|
||||
|
||||
import sqlalchemy
|
||||
from fastapi import HTTPException
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.sql import false, or_
|
||||
|
||||
from reflector.db import get_database, metadata
|
||||
from reflector.platform_types import Platform
|
||||
from reflector.utils import generate_uuid4
|
||||
|
||||
rooms = sqlalchemy.Table(
|
||||
@@ -50,6 +51,12 @@ rooms = sqlalchemy.Table(
|
||||
),
|
||||
sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)),
|
||||
sqlalchemy.Column("ics_last_etag", sqlalchemy.Text),
|
||||
sqlalchemy.Column(
|
||||
"platform",
|
||||
sqlalchemy.String,
|
||||
nullable=False,
|
||||
server_default="whereby",
|
||||
),
|
||||
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
|
||||
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
|
||||
)
|
||||
@@ -66,7 +73,7 @@ class Room(BaseModel):
|
||||
is_locked: bool = False
|
||||
room_mode: Literal["normal", "group"] = "normal"
|
||||
recording_type: Literal["none", "local", "cloud"] = "cloud"
|
||||
recording_trigger: Literal[
|
||||
recording_trigger: Literal[ # whereby-specific
|
||||
"none", "prompt", "automatic", "automatic-2nd-participant"
|
||||
] = "automatic-2nd-participant"
|
||||
is_shared: bool = False
|
||||
@@ -77,6 +84,7 @@ class Room(BaseModel):
|
||||
ics_enabled: bool = False
|
||||
ics_last_sync: datetime | None = None
|
||||
ics_last_etag: str | None = None
|
||||
platform: Platform = "whereby"
|
||||
|
||||
|
||||
class RoomController:
|
||||
@@ -130,6 +138,7 @@ class RoomController:
|
||||
ics_url: str | None = None,
|
||||
ics_fetch_interval: int = 300,
|
||||
ics_enabled: bool = False,
|
||||
platform: Optional[Platform] = None,
|
||||
):
|
||||
"""
|
||||
Add a new room
|
||||
@@ -153,6 +162,7 @@ class RoomController:
|
||||
ics_url=ics_url,
|
||||
ics_fetch_interval=ics_fetch_interval,
|
||||
ics_enabled=ics_enabled,
|
||||
platform=platform or "whereby",
|
||||
)
|
||||
query = rooms.insert().values(**room.model_dump())
|
||||
try:
|
||||
|
||||
9
server/reflector/platform_types.py
Normal file
9
server/reflector/platform_types.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Platform type definitions.
|
||||
|
||||
This module exists solely to define the Platform literal type without any imports,
|
||||
preventing circular import issues when used across the codebase.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
Platform = Literal["whereby", "daily"]
|
||||
@@ -1,6 +1,7 @@
|
||||
from pydantic.types import PositiveInt
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from reflector.platform_types import Platform
|
||||
from reflector.utils.string import NonEmptyString
|
||||
|
||||
|
||||
@@ -129,6 +130,19 @@ class Settings(BaseSettings):
|
||||
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None
|
||||
SQS_POLLING_TIMEOUT_SECONDS: int = 60
|
||||
|
||||
# Daily.co integration
|
||||
DAILY_API_KEY: str | None = None
|
||||
DAILY_WEBHOOK_SECRET: str | None = None
|
||||
DAILY_SUBDOMAIN: str | None = None
|
||||
AWS_DAILY_S3_BUCKET: str | None = None
|
||||
AWS_DAILY_S3_REGION: str = "us-west-2"
|
||||
AWS_DAILY_ROLE_ARN: str | None = None
|
||||
|
||||
# Platform Migration Feature Flags
|
||||
DAILY_MIGRATION_ENABLED: bool = False
|
||||
DAILY_MIGRATION_ROOM_IDS: list[str] = []
|
||||
DEFAULT_VIDEO_PLATFORM: Platform = "whereby"
|
||||
|
||||
# Zulip integration
|
||||
ZULIP_REALM: str | None = None
|
||||
ZULIP_API_KEY: str | None = None
|
||||
|
||||
17
server/reflector/video_platforms/__init__.py
Normal file
17
server/reflector/video_platforms/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Video Platform Abstraction Layer
|
||||
"""
|
||||
This module provides an abstraction layer for different video conferencing platforms.
|
||||
It allows seamless switching between providers (Whereby, Daily.co, etc.) without
|
||||
changing the core application logic.
|
||||
"""
|
||||
|
||||
from .base import MeetingData, VideoPlatformClient, VideoPlatformConfig
|
||||
from .registry import get_platform_client, register_platform
|
||||
|
||||
__all__ = [
|
||||
"VideoPlatformClient",
|
||||
"VideoPlatformConfig",
|
||||
"MeetingData",
|
||||
"get_platform_client",
|
||||
"register_platform",
|
||||
]
|
||||
85
server/reflector/video_platforms/base.py
Normal file
85
server/reflector/video_platforms/base.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from reflector.platform_types import Platform
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from reflector.db.rooms import Room
|
||||
|
||||
RecordingType = Literal["none", "local", "cloud"]
|
||||
|
||||
|
||||
class MeetingData(BaseModel):
|
||||
meeting_id: str
|
||||
room_name: str
|
||||
room_url: str
|
||||
host_room_url: str
|
||||
platform: Platform
|
||||
extra_data: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class VideoPlatformConfig(BaseModel):
|
||||
"""Configuration for a video platform."""
|
||||
|
||||
api_key: str
|
||||
webhook_secret: str
|
||||
api_url: Optional[str] = None
|
||||
subdomain: Optional[str] = None
|
||||
s3_bucket: Optional[str] = None
|
||||
s3_region: Optional[str] = None
|
||||
aws_role_arn: Optional[str] = None
|
||||
aws_access_key_id: Optional[str] = None
|
||||
aws_access_key_secret: Optional[str] = None
|
||||
|
||||
|
||||
class VideoPlatformClient(ABC):
|
||||
"""Abstract base class for video platform integrations."""
|
||||
|
||||
PLATFORM_NAME: Platform
|
||||
|
||||
def __init__(self, config: VideoPlatformConfig):
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
async def create_meeting(
|
||||
self, room_name_prefix: str, end_date: datetime, room: "Room"
|
||||
) -> MeetingData:
|
||||
"""Create a new meeting room."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
|
||||
"""Get session information for a room."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete_room(self, room_name: str) -> bool:
|
||||
"""Delete a room. Returns True if successful."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
|
||||
"""Upload a logo to the room. Returns True if successful."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def verify_webhook_signature(
|
||||
self, body: bytes, signature: str, timestamp: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Verify webhook signature for security."""
|
||||
pass
|
||||
|
||||
def format_recording_config(self, room: "Room") -> Dict[str, Any]:
|
||||
"""Format recording configuration for the platform.
|
||||
Can be overridden by specific implementations."""
|
||||
if room.recording_type == "cloud" and self.config.s3_bucket:
|
||||
return {
|
||||
"type": room.recording_type,
|
||||
"bucket": self.config.s3_bucket,
|
||||
"region": self.config.s3_region,
|
||||
"trigger": room.recording_trigger,
|
||||
}
|
||||
return {"type": room.recording_type}
|
||||
180
server/reflector/video_platforms/daily.py
Normal file
180
server/reflector/video_platforms/daily.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from http import HTTPStatus
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from reflector.db.rooms import Room
|
||||
|
||||
from .base import (
|
||||
MeetingData,
|
||||
Platform,
|
||||
RecordingType,
|
||||
VideoPlatformClient,
|
||||
VideoPlatformConfig,
|
||||
)
|
||||
|
||||
|
||||
class DailyClient(VideoPlatformClient):
|
||||
PLATFORM_NAME: Platform = "daily"
|
||||
TIMEOUT = 10
|
||||
BASE_URL = "https://api.daily.co/v1"
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%S"
|
||||
RECORDING_NONE: RecordingType = "none"
|
||||
RECORDING_CLOUD: RecordingType = "cloud"
|
||||
|
||||
def __init__(self, config: VideoPlatformConfig):
|
||||
super().__init__(config)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {config.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def create_meeting(
|
||||
self, room_name_prefix: str, end_date: datetime, room: Room
|
||||
) -> MeetingData:
|
||||
"""Create a Daily.co room."""
|
||||
timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
|
||||
if room_name_prefix:
|
||||
room_name = f"{room_name_prefix}-{timestamp}"
|
||||
else:
|
||||
room_name = f"room-{timestamp}"
|
||||
|
||||
data = {
|
||||
"name": room_name,
|
||||
"privacy": "private" if room.is_locked else "public",
|
||||
"properties": {
|
||||
"enable_recording": "raw-tracks"
|
||||
if room.recording_type != self.RECORDING_NONE
|
||||
else False,
|
||||
"enable_chat": True,
|
||||
"enable_screenshare": True,
|
||||
"start_video_off": False,
|
||||
"start_audio_off": False,
|
||||
"exp": int(end_date.timestamp()),
|
||||
},
|
||||
}
|
||||
|
||||
# Configure S3 bucket for recordings
|
||||
if room.recording_type != self.RECORDING_NONE and self.config.s3_bucket:
|
||||
data["properties"]["recordings_bucket"] = {
|
||||
"bucket_name": self.config.s3_bucket,
|
||||
"bucket_region": self.config.s3_region,
|
||||
"assume_role_arn": self.config.aws_role_arn,
|
||||
"allow_api_access": True,
|
||||
}
|
||||
|
||||
from reflector.logger import logger
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE_URL}/rooms",
|
||||
headers=self.headers,
|
||||
json=data,
|
||||
timeout=self.TIMEOUT,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
logger.error(
|
||||
"Daily.co API error",
|
||||
status_code=response.status_code,
|
||||
response_body=response.text,
|
||||
request_data=data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Format response to match our standard
|
||||
room_url = result["url"]
|
||||
|
||||
return MeetingData(
|
||||
meeting_id=result["id"],
|
||||
room_name=result["name"],
|
||||
room_url=room_url,
|
||||
host_room_url=room_url,
|
||||
platform=self.PLATFORM_NAME,
|
||||
extra_data=result,
|
||||
)
|
||||
|
||||
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
|
||||
"""Get Daily.co room information."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.BASE_URL}/rooms/{room_name}",
|
||||
headers=self.headers,
|
||||
timeout=self.TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_room_presence(self, room_name: str) -> Dict[str, Any]:
|
||||
"""Get real-time participant data - Daily.co specific feature."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.BASE_URL}/rooms/{room_name}/presence",
|
||||
headers=self.headers,
|
||||
timeout=self.TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def delete_room(self, room_name: str) -> bool:
|
||||
"""Delete a Daily.co room."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.delete(
|
||||
f"{self.BASE_URL}/rooms/{room_name}",
|
||||
headers=self.headers,
|
||||
timeout=self.TIMEOUT,
|
||||
)
|
||||
# Daily.co returns 200 for success, 404 if room doesn't exist
|
||||
return response.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
|
||||
|
||||
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
|
||||
"""Daily.co doesn't support custom logos per room - this is a no-op."""
|
||||
return True
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, body: bytes, signature: str, timestamp: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Verify Daily.co webhook signature.
|
||||
|
||||
Daily.co uses:
|
||||
- X-Webhook-Signature header
|
||||
- X-Webhook-Timestamp header
|
||||
- Signature format: HMAC-SHA256(base64_decode(secret), timestamp + '.' + body)
|
||||
- Result is base64 encoded
|
||||
"""
|
||||
if not signature or not timestamp:
|
||||
return False
|
||||
|
||||
try:
|
||||
import base64
|
||||
|
||||
secret_bytes = base64.b64decode(self.config.webhook_secret)
|
||||
|
||||
signed_content = timestamp.encode() + b"." + body
|
||||
|
||||
expected = hmac.new(secret_bytes, signed_content, sha256).digest()
|
||||
expected_b64 = base64.b64encode(expected).decode()
|
||||
|
||||
return hmac.compare_digest(expected_b64, signature)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def create_meeting_token(self, room_name: str, enable_recording: bool) -> str:
|
||||
"""Create meeting token for auto-recording."""
|
||||
data = {"properties": {"room_name": room_name}}
|
||||
|
||||
if enable_recording:
|
||||
data["properties"]["start_cloud_recording"] = True
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE_URL}/meeting-tokens",
|
||||
headers=self.headers,
|
||||
json=data,
|
||||
timeout=self.TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["token"]
|
||||
80
server/reflector/video_platforms/factory.py
Normal file
80
server/reflector/video_platforms/factory.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""Factory for creating video platform clients based on configuration."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from reflector.settings import settings
|
||||
|
||||
from .base import Platform, VideoPlatformClient, VideoPlatformConfig
|
||||
from .registry import get_platform_client
|
||||
|
||||
|
||||
def get_platform_config(platform: Platform) -> VideoPlatformConfig:
|
||||
"""Get configuration for a specific platform."""
|
||||
if platform == "whereby":
|
||||
if not settings.WHEREBY_API_KEY:
|
||||
raise ValueError(
|
||||
"WHEREBY_API_KEY is required when platform='whereby'. "
|
||||
"Set WHEREBY_API_KEY environment variable."
|
||||
)
|
||||
return VideoPlatformConfig(
|
||||
api_key=settings.WHEREBY_API_KEY,
|
||||
webhook_secret=settings.WHEREBY_WEBHOOK_SECRET or "",
|
||||
api_url=settings.WHEREBY_API_URL,
|
||||
s3_bucket=settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
|
||||
s3_region=settings.RECORDING_STORAGE_AWS_REGION,
|
||||
aws_access_key_id=settings.AWS_WHEREBY_ACCESS_KEY_ID,
|
||||
aws_access_key_secret=settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
|
||||
)
|
||||
elif platform == "daily":
|
||||
if not settings.DAILY_API_KEY:
|
||||
raise ValueError(
|
||||
"DAILY_API_KEY is required when platform='daily'. "
|
||||
"Set DAILY_API_KEY environment variable."
|
||||
)
|
||||
if not settings.DAILY_SUBDOMAIN:
|
||||
raise ValueError(
|
||||
"DAILY_SUBDOMAIN is required when platform='daily'. "
|
||||
"Set DAILY_SUBDOMAIN environment variable."
|
||||
)
|
||||
return VideoPlatformConfig(
|
||||
api_key=settings.DAILY_API_KEY,
|
||||
webhook_secret=settings.DAILY_WEBHOOK_SECRET or "",
|
||||
subdomain=settings.DAILY_SUBDOMAIN,
|
||||
s3_bucket=settings.AWS_DAILY_S3_BUCKET,
|
||||
s3_region=settings.AWS_DAILY_S3_REGION,
|
||||
aws_role_arn=settings.AWS_DAILY_ROLE_ARN,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown platform: {platform}")
|
||||
|
||||
|
||||
def create_platform_client(platform: Platform) -> VideoPlatformClient:
|
||||
"""Create a video platform client instance."""
|
||||
config = get_platform_config(platform)
|
||||
return get_platform_client(platform, config)
|
||||
|
||||
|
||||
def get_platform_for_room(
|
||||
room_id: Optional[str] = None, room_platform: Optional[Platform] = None
|
||||
) -> Platform:
|
||||
"""Determine which platform to use for a room.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. DAILY_MIGRATION_ROOM_IDS - env var override for testing/migration
|
||||
2. room_platform - database persisted platform choice
|
||||
3. DEFAULT_VIDEO_PLATFORM - env var fallback
|
||||
"""
|
||||
# If Daily migration is disabled, always use Whereby
|
||||
if not settings.DAILY_MIGRATION_ENABLED:
|
||||
return "whereby"
|
||||
|
||||
# Highest priority: If room is in migration list, use Daily (env var override)
|
||||
if room_id and room_id in settings.DAILY_MIGRATION_ROOM_IDS:
|
||||
return "daily"
|
||||
|
||||
# Second priority: Use room's persisted platform from database
|
||||
if room_platform:
|
||||
return room_platform
|
||||
|
||||
# Fallback: Use default platform from env var
|
||||
return settings.DEFAULT_VIDEO_PLATFORM
|
||||
39
server/reflector/video_platforms/registry.py
Normal file
39
server/reflector/video_platforms/registry.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from typing import Dict, Type
|
||||
|
||||
from .base import Platform, VideoPlatformClient, VideoPlatformConfig
|
||||
|
||||
# Registry of available video platforms
|
||||
_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {}
|
||||
|
||||
|
||||
def register_platform(name: Platform, client_class: Type[VideoPlatformClient]):
|
||||
"""Register a video platform implementation."""
|
||||
_PLATFORMS[name] = client_class
|
||||
|
||||
|
||||
def get_platform_client(
|
||||
platform: Platform, config: VideoPlatformConfig
|
||||
) -> VideoPlatformClient:
|
||||
"""Get a video platform client instance."""
|
||||
if platform not in _PLATFORMS:
|
||||
raise ValueError(f"Unknown video platform: {platform}")
|
||||
|
||||
client_class = _PLATFORMS[platform]
|
||||
return client_class(config)
|
||||
|
||||
|
||||
def get_available_platforms() -> list[Platform]:
|
||||
"""Get list of available platform names."""
|
||||
return list(_PLATFORMS.keys())
|
||||
|
||||
|
||||
# Auto-register built-in platforms
|
||||
def _register_builtin_platforms():
|
||||
from .daily import DailyClient # noqa: PLC0415
|
||||
from .whereby import WherebyClient # noqa: PLC0415
|
||||
|
||||
register_platform("whereby", WherebyClient)
|
||||
register_platform("daily", DailyClient)
|
||||
|
||||
|
||||
_register_builtin_platforms()
|
||||
140
server/reflector/video_platforms/whereby.py
Normal file
140
server/reflector/video_platforms/whereby.py
Normal file
@@ -0,0 +1,140 @@
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from hashlib import sha256
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from reflector.db.rooms import Room
|
||||
|
||||
from .base import MeetingData, Platform, VideoPlatformClient, VideoPlatformConfig
|
||||
|
||||
|
||||
class WherebyClient(VideoPlatformClient):
|
||||
"""Whereby video platform implementation."""
|
||||
|
||||
PLATFORM_NAME: Platform = "whereby"
|
||||
TIMEOUT = 10 # seconds
|
||||
MAX_ELAPSED_TIME = 60 * 1000 # 1 minute in milliseconds
|
||||
|
||||
def __init__(self, config: VideoPlatformConfig):
|
||||
super().__init__(config)
|
||||
self.headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Authorization": f"Bearer {config.api_key}",
|
||||
}
|
||||
|
||||
async def create_meeting(
|
||||
self, room_name_prefix: str, end_date: datetime, room: Room
|
||||
) -> MeetingData:
|
||||
"""Create a Whereby meeting."""
|
||||
data = {
|
||||
"isLocked": room.is_locked,
|
||||
"roomNamePrefix": room_name_prefix,
|
||||
"roomNamePattern": "uuid",
|
||||
"roomMode": room.room_mode,
|
||||
"endDate": end_date.isoformat(),
|
||||
"fields": ["hostRoomUrl"],
|
||||
}
|
||||
|
||||
# Add recording configuration if cloud recording is enabled
|
||||
if room.recording_type == "cloud":
|
||||
data["recording"] = {
|
||||
"type": room.recording_type,
|
||||
"destination": {
|
||||
"provider": "s3",
|
||||
"bucket": self.config.s3_bucket,
|
||||
"accessKeyId": self.config.aws_access_key_id,
|
||||
"accessKeySecret": self.config.aws_access_key_secret,
|
||||
"fileFormat": "mp4",
|
||||
},
|
||||
"startTrigger": room.recording_trigger,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.config.api_url}/meetings",
|
||||
headers=self.headers,
|
||||
json=data,
|
||||
timeout=self.TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
return MeetingData(
|
||||
meeting_id=result["meetingId"],
|
||||
room_name=result["roomName"],
|
||||
room_url=result["roomUrl"],
|
||||
host_room_url=result["hostRoomUrl"],
|
||||
platform=self.PLATFORM_NAME,
|
||||
extra_data=result,
|
||||
)
|
||||
|
||||
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
|
||||
"""Get Whereby room session information."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.config.api_url}/insights/room-sessions?roomName={room_name}",
|
||||
headers=self.headers,
|
||||
timeout=self.TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def delete_room(self, room_name: str) -> bool:
|
||||
"""Whereby doesn't support room deletion - meetings expire automatically."""
|
||||
return True
|
||||
|
||||
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
|
||||
"""Upload logo to Whereby room."""
|
||||
async with httpx.AsyncClient() as client:
|
||||
with open(logo_path, "rb") as f:
|
||||
response = await client.put(
|
||||
f"{self.config.api_url}/rooms/{room_name}/theme/logo",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.config.api_key}",
|
||||
},
|
||||
timeout=self.TIMEOUT,
|
||||
files={"image": f},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, body: bytes, signature: str, timestamp: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Verify Whereby webhook signature."""
|
||||
if not signature:
|
||||
return False
|
||||
|
||||
matches = re.match(r"t=(.*),v1=(.*)", signature)
|
||||
if not matches:
|
||||
return False
|
||||
|
||||
ts, sig = matches.groups()
|
||||
|
||||
# Check timestamp to prevent replay attacks
|
||||
current_time = int(time.time() * 1000)
|
||||
diff_time = current_time - int(ts) * 1000
|
||||
if diff_time >= self.MAX_ELAPSED_TIME:
|
||||
return False
|
||||
|
||||
# Verify signature
|
||||
body_dict = json.loads(body)
|
||||
signed_payload = f"{ts}.{json.dumps(body_dict, separators=(',', ':'))}"
|
||||
hmac_obj = hmac.new(
|
||||
self.config.webhook_secret.encode("utf-8"),
|
||||
signed_payload.encode("utf-8"),
|
||||
sha256,
|
||||
)
|
||||
expected_signature = hmac_obj.hexdigest()
|
||||
|
||||
try:
|
||||
return hmac.compare_digest(
|
||||
expected_signature.encode("utf-8"), sig.encode("utf-8")
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
185
server/reflector/views/daily.py
Normal file
185
server/reflector/views/daily.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Daily.co webhook handler endpoint."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from reflector.db.meetings import meetings_controller
|
||||
from reflector.logger import logger
|
||||
from reflector.video_platforms.factory import create_platform_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DailyWebhookEvent(BaseModel):
|
||||
"""Daily webhook event structure."""
|
||||
|
||||
version: str
|
||||
type: str
|
||||
id: str
|
||||
payload: Dict[str, Any]
|
||||
event_ts: float
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def webhook(request: Request):
|
||||
"""Handle Daily webhook events.
|
||||
|
||||
Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook
|
||||
state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py
|
||||
"""
|
||||
body = await request.body()
|
||||
signature = request.headers.get("X-Webhook-Signature", "")
|
||||
timestamp = request.headers.get("X-Webhook-Timestamp", "")
|
||||
|
||||
client = create_platform_client("daily")
|
||||
if not client.verify_webhook_signature(body, signature, timestamp):
|
||||
logger.warning(
|
||||
"Invalid webhook signature",
|
||||
signature=signature,
|
||||
timestamp=timestamp,
|
||||
has_body=bool(body),
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
|
||||
# Parse the JSON body
|
||||
import json
|
||||
|
||||
try:
|
||||
body_json = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=422, detail="Invalid JSON")
|
||||
|
||||
# Handle Daily's test event during webhook creation
|
||||
if body_json.get("test") == "test":
|
||||
logger.info("Received Daily webhook test event")
|
||||
return {"status": "ok"}
|
||||
|
||||
# Parse as actual event
|
||||
try:
|
||||
event = DailyWebhookEvent(**body_json)
|
||||
except Exception as e:
|
||||
logger.error("Failed to parse webhook event", error=str(e), body=body.decode())
|
||||
raise HTTPException(status_code=422, detail="Invalid event format")
|
||||
|
||||
# Handle participant events
|
||||
if event.type == "participant.joined":
|
||||
await _handle_participant_joined(event)
|
||||
elif event.type == "participant.left":
|
||||
await _handle_participant_left(event)
|
||||
elif event.type == "recording.started":
|
||||
await _handle_recording_started(event)
|
||||
elif event.type == "recording.ready-to-download":
|
||||
await _handle_recording_ready(event)
|
||||
elif event.type == "recording.error":
|
||||
await _handle_recording_error(event)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _handle_participant_joined(event: DailyWebhookEvent):
|
||||
"""Handle participant joined event."""
|
||||
room_name = event.payload.get("room")
|
||||
if not room_name:
|
||||
logger.warning("participant.joined: no room in payload", payload=event.payload)
|
||||
return
|
||||
|
||||
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||
if meeting:
|
||||
current_count = getattr(meeting, "num_clients", 0)
|
||||
await meetings_controller.update_meeting(
|
||||
meeting.id, num_clients=current_count + 1
|
||||
)
|
||||
logger.info(
|
||||
"Participant joined",
|
||||
meeting_id=meeting.id,
|
||||
room_name=room_name,
|
||||
num_clients=current_count + 1,
|
||||
recording_type=meeting.recording_type,
|
||||
recording_trigger=meeting.recording_trigger,
|
||||
)
|
||||
else:
|
||||
logger.warning("participant.joined: meeting not found", room_name=room_name)
|
||||
|
||||
|
||||
async def _handle_participant_left(event: DailyWebhookEvent):
|
||||
"""Handle participant left event."""
|
||||
room_name = event.payload.get("room")
|
||||
if not room_name:
|
||||
return
|
||||
|
||||
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||
if meeting:
|
||||
current_count = getattr(meeting, "num_clients", 0)
|
||||
await meetings_controller.update_meeting(
|
||||
meeting.id, num_clients=max(0, current_count - 1)
|
||||
)
|
||||
|
||||
|
||||
async def _handle_recording_started(event: DailyWebhookEvent):
|
||||
"""Handle recording started event."""
|
||||
# Daily.co inconsistency: participant.* uses "room", recording.* uses "room_name"
|
||||
room_name = event.payload.get("room_name") or event.payload.get("room")
|
||||
if not room_name:
|
||||
logger.warning(
|
||||
"recording.started: no room_name in payload", payload=event.payload
|
||||
)
|
||||
return
|
||||
|
||||
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||
if meeting:
|
||||
logger.info(
|
||||
"Recording started",
|
||||
meeting_id=meeting.id,
|
||||
room_name=room_name,
|
||||
recording_id=event.payload.get("recording_id"),
|
||||
platform="daily",
|
||||
)
|
||||
else:
|
||||
logger.warning("recording.started: meeting not found", room_name=room_name)
|
||||
|
||||
|
||||
async def _handle_recording_ready(event: DailyWebhookEvent):
|
||||
"""Handle recording ready for download event."""
|
||||
room_name = event.payload.get("room")
|
||||
recording_id = event.payload.get("recording_id")
|
||||
download_link = event.payload.get("download_link")
|
||||
|
||||
if not room_name or not download_link:
|
||||
return
|
||||
|
||||
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||
if meeting:
|
||||
try:
|
||||
from reflector.worker.process import process_recording_from_url
|
||||
|
||||
process_recording_from_url.delay(
|
||||
recording_url=download_link,
|
||||
meeting_id=meeting.id,
|
||||
recording_id=recording_id or event.id,
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"Could not queue recording processing",
|
||||
meeting_id=meeting.id,
|
||||
room_name=room_name,
|
||||
platform="daily",
|
||||
)
|
||||
|
||||
|
||||
async def _handle_recording_error(event: DailyWebhookEvent):
|
||||
"""Handle recording error event."""
|
||||
room_name = event.payload.get("room")
|
||||
error = event.payload.get("error", "Unknown error")
|
||||
|
||||
if room_name:
|
||||
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||
if meeting:
|
||||
logger.error(
|
||||
"Recording error",
|
||||
meeting_id=meeting.id,
|
||||
room_name=room_name,
|
||||
error=error,
|
||||
platform="daily",
|
||||
)
|
||||
@@ -17,7 +17,11 @@ from reflector.db.rooms import rooms_controller
|
||||
from reflector.redis_cache import RedisAsyncLock
|
||||
from reflector.services.ics_sync import ics_sync_service
|
||||
from reflector.settings import settings
|
||||
from reflector.whereby import create_meeting, upload_logo
|
||||
from reflector.video_platforms.base import Platform
|
||||
from reflector.video_platforms.factory import (
|
||||
create_platform_client,
|
||||
get_platform_for_room,
|
||||
)
|
||||
from reflector.worker.webhook import test_webhook
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,6 +45,7 @@ class Room(BaseModel):
|
||||
ics_enabled: bool = False
|
||||
ics_last_sync: Optional[datetime] = None
|
||||
ics_last_etag: Optional[str] = None
|
||||
platform: Platform = "whereby"
|
||||
|
||||
|
||||
class RoomDetails(Room):
|
||||
@@ -68,6 +73,7 @@ class Meeting(BaseModel):
|
||||
is_active: bool = True
|
||||
calendar_event_id: str | None = None
|
||||
calendar_metadata: dict[str, Any] | None = None
|
||||
platform: Platform = "whereby"
|
||||
|
||||
|
||||
class CreateRoom(BaseModel):
|
||||
@@ -85,6 +91,7 @@ class CreateRoom(BaseModel):
|
||||
ics_url: Optional[str] = None
|
||||
ics_fetch_interval: int = 300
|
||||
ics_enabled: bool = False
|
||||
platform: Optional[Platform] = None
|
||||
|
||||
|
||||
class UpdateRoom(BaseModel):
|
||||
@@ -102,6 +109,7 @@ class UpdateRoom(BaseModel):
|
||||
ics_url: Optional[str] = None
|
||||
ics_fetch_interval: Optional[int] = None
|
||||
ics_enabled: Optional[bool] = None
|
||||
platform: Optional[Platform] = None
|
||||
|
||||
|
||||
class CreateRoomMeeting(BaseModel):
|
||||
@@ -251,6 +259,7 @@ async def rooms_create(
|
||||
ics_url=room.ics_url,
|
||||
ics_fetch_interval=room.ics_fetch_interval,
|
||||
ics_enabled=room.ics_enabled,
|
||||
platform=room.platform,
|
||||
)
|
||||
|
||||
|
||||
@@ -315,20 +324,27 @@ async def rooms_create_meeting(
|
||||
if meeting is None:
|
||||
end_date = current_time + timedelta(hours=8)
|
||||
|
||||
whereby_meeting = await create_meeting("", end_date=end_date, room=room)
|
||||
# Determine which platform to use
|
||||
platform = get_platform_for_room(room.id, room.platform)
|
||||
client = create_platform_client(platform)
|
||||
|
||||
await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
|
||||
# Create meeting via platform abstraction
|
||||
meeting_data = await client.create_meeting(
|
||||
room.name, end_date=end_date, room=room
|
||||
)
|
||||
|
||||
# Upload logo if supported by platform
|
||||
await client.upload_logo(meeting_data.room_name, "./images/logo.png")
|
||||
|
||||
meeting = await meetings_controller.create(
|
||||
id=whereby_meeting["meetingId"],
|
||||
room_name=whereby_meeting["roomName"],
|
||||
room_url=whereby_meeting["roomUrl"],
|
||||
host_room_url=whereby_meeting["hostRoomUrl"],
|
||||
start_date=parse_datetime_with_timezone(
|
||||
whereby_meeting["startDate"]
|
||||
),
|
||||
end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]),
|
||||
id=meeting_data.meeting_id,
|
||||
room_name=meeting_data.room_name,
|
||||
room_url=meeting_data.room_url,
|
||||
host_room_url=meeting_data.host_room_url,
|
||||
start_date=current_time,
|
||||
end_date=end_date,
|
||||
room=room,
|
||||
platform=platform,
|
||||
)
|
||||
except LockError:
|
||||
logger.warning("Failed to acquire lock for room %s within timeout", room_name)
|
||||
@@ -336,6 +352,16 @@ async def rooms_create_meeting(
|
||||
status_code=503, detail="Meeting creation in progress, please try again"
|
||||
)
|
||||
|
||||
if meeting.platform == "daily" and room.recording_trigger != "none":
|
||||
client = create_platform_client(meeting.platform)
|
||||
token = await client.create_meeting_token(
|
||||
meeting.room_name, enable_recording=True
|
||||
)
|
||||
meeting = meeting.model_copy()
|
||||
meeting.room_url += f"?t={token}"
|
||||
if meeting.host_room_url:
|
||||
meeting.host_room_url += f"?t={token}"
|
||||
|
||||
if user_id != room.user_id:
|
||||
meeting.host_room_url = ""
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from reflector.db.meetings import meetings_controller
|
||||
from reflector.db.rooms import rooms_controller
|
||||
from reflector.redis_cache import RedisAsyncLock
|
||||
from reflector.services.ics_sync import SyncStatus, ics_sync_service
|
||||
from reflector.whereby import create_meeting, upload_logo
|
||||
from reflector.video_platforms.factory import create_platform_client
|
||||
|
||||
logger = structlog.wrap_logger(get_task_logger(__name__))
|
||||
|
||||
@@ -104,20 +104,24 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room
|
||||
try:
|
||||
end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION)
|
||||
|
||||
whereby_meeting = await create_meeting(
|
||||
# Use platform abstraction to create meeting
|
||||
platform = room.platform
|
||||
client = create_platform_client(platform)
|
||||
|
||||
meeting_data = await client.create_meeting(
|
||||
"",
|
||||
end_date=end_date,
|
||||
room=room,
|
||||
)
|
||||
await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
|
||||
await client.upload_logo(meeting_data.room_name, "./images/logo.png")
|
||||
|
||||
meeting = await meetings_controller.create(
|
||||
id=whereby_meeting["meetingId"],
|
||||
room_name=whereby_meeting["roomName"],
|
||||
room_url=whereby_meeting["roomUrl"],
|
||||
host_room_url=whereby_meeting["hostRoomUrl"],
|
||||
start_date=datetime.fromisoformat(whereby_meeting["startDate"]),
|
||||
end_date=datetime.fromisoformat(whereby_meeting["endDate"]),
|
||||
id=meeting_data.meeting_id,
|
||||
room_name=meeting_data.room_name,
|
||||
room_url=meeting_data.room_url,
|
||||
host_room_url=meeting_data.host_room_url,
|
||||
start_date=event.start_time,
|
||||
end_date=end_date,
|
||||
room=room,
|
||||
calendar_event_id=event.id,
|
||||
calendar_metadata={
|
||||
@@ -125,6 +129,7 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room
|
||||
"description": event.description,
|
||||
"attendees": event.attendees,
|
||||
},
|
||||
platform=platform,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
72
server/scripts/recreate_daily_webhook.py
Normal file
72
server/scripts/recreate_daily_webhook.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recreate Daily.co webhook (fixes circuit-breaker FAILED state)."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import httpx
|
||||
|
||||
from reflector.settings import settings
|
||||
|
||||
|
||||
async def recreate_webhook(webhook_url: str):
|
||||
"""Delete all webhooks and create new one."""
|
||||
if not settings.DAILY_API_KEY:
|
||||
print("Error: DAILY_API_KEY not set")
|
||||
return 1
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.DAILY_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# List existing webhooks
|
||||
resp = await client.get("https://api.daily.co/v1/webhooks", headers=headers)
|
||||
resp.raise_for_status()
|
||||
webhooks = resp.json()
|
||||
|
||||
# Delete all existing webhooks
|
||||
for wh in webhooks:
|
||||
uuid = wh["uuid"]
|
||||
print(f"Deleting webhook {uuid} (state: {wh['state']})")
|
||||
await client.delete(
|
||||
f"https://api.daily.co/v1/webhooks/{uuid}", headers=headers
|
||||
)
|
||||
|
||||
# Create new webhook
|
||||
webhook_data = {
|
||||
"url": webhook_url,
|
||||
"eventTypes": [
|
||||
"participant.joined",
|
||||
"participant.left",
|
||||
"recording.started",
|
||||
"recording.ready-to-download",
|
||||
"recording.error",
|
||||
],
|
||||
"hmac": settings.DAILY_WEBHOOK_SECRET,
|
||||
}
|
||||
|
||||
resp = await client.post(
|
||||
"https://api.daily.co/v1/webhooks", headers=headers, json=webhook_data
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
print(f"Created webhook {result['uuid']} (state: {result['state']})")
|
||||
print(f"URL: {result['url']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python recreate_daily_webhook.py <webhook_url>")
|
||||
print(
|
||||
"Example: python recreate_daily_webhook.py https://example.com/v1/daily/webhook"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(asyncio.run(recreate_webhook(sys.argv[1])))
|
||||
@@ -6,6 +6,16 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def register_mock_platform():
|
||||
from mocks.mock_platform import MockPlatformClient
|
||||
|
||||
from reflector.video_platforms.registry import register_platform
|
||||
|
||||
register_platform("whereby", MockPlatformClient)
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def settings_configuration():
|
||||
# theses settings are linked to monadical for pytest-recording
|
||||
|
||||
0
server/tests/mocks/__init__.py
Normal file
0
server/tests/mocks/__init__.py
Normal file
111
server/tests/mocks/mock_platform.py
Normal file
111
server/tests/mocks/mock_platform.py
Normal file
@@ -0,0 +1,111 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Literal, Optional
|
||||
|
||||
from reflector.db.rooms import Room
|
||||
from reflector.video_platforms.base import (
|
||||
MeetingData,
|
||||
VideoPlatformClient,
|
||||
VideoPlatformConfig,
|
||||
)
|
||||
|
||||
MockPlatform = Literal["mock"]
|
||||
|
||||
|
||||
class MockPlatformClient(VideoPlatformClient):
|
||||
PLATFORM_NAME: MockPlatform = "mock"
|
||||
|
||||
def __init__(self, config: VideoPlatformConfig):
|
||||
super().__init__(config)
|
||||
self._rooms: Dict[str, Dict[str, Any]] = {}
|
||||
self._webhook_calls: list[Dict[str, Any]] = []
|
||||
|
||||
async def create_meeting(
|
||||
self, room_name_prefix: str, end_date: datetime, room: Room
|
||||
) -> MeetingData:
|
||||
meeting_id = str(uuid.uuid4())
|
||||
room_name = f"{room_name_prefix}-{meeting_id[:8]}"
|
||||
room_url = f"https://mock.video/{room_name}"
|
||||
host_room_url = f"{room_url}?host=true"
|
||||
|
||||
self._rooms[room_name] = {
|
||||
"id": meeting_id,
|
||||
"name": room_name,
|
||||
"url": room_url,
|
||||
"host_url": host_room_url,
|
||||
"end_date": end_date,
|
||||
"room": room,
|
||||
"participants": [],
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
return MeetingData.model_construct(
|
||||
meeting_id=meeting_id,
|
||||
room_name=room_name,
|
||||
room_url=room_url,
|
||||
host_room_url=host_room_url,
|
||||
platform="whereby",
|
||||
extra_data={"mock": True},
|
||||
)
|
||||
|
||||
async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
|
||||
if room_name not in self._rooms:
|
||||
return {"error": "Room not found"}
|
||||
|
||||
room_data = self._rooms[room_name]
|
||||
return {
|
||||
"roomName": room_name,
|
||||
"sessions": [
|
||||
{
|
||||
"sessionId": room_data["id"],
|
||||
"startTime": datetime.utcnow().isoformat(),
|
||||
"participants": room_data["participants"],
|
||||
"isActive": room_data["is_active"],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
async def delete_room(self, room_name: str) -> bool:
|
||||
if room_name in self._rooms:
|
||||
self._rooms[room_name]["is_active"] = False
|
||||
return True
|
||||
return False
|
||||
|
||||
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
|
||||
if room_name in self._rooms:
|
||||
self._rooms[room_name]["logo_path"] = logo_path
|
||||
return True
|
||||
return False
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, body: bytes, signature: str, timestamp: Optional[str] = None
|
||||
) -> bool:
|
||||
return signature == "valid"
|
||||
|
||||
def add_participant(
|
||||
self, room_name: str, participant_id: str, participant_name: str
|
||||
):
|
||||
if room_name in self._rooms:
|
||||
self._rooms[room_name]["participants"].append(
|
||||
{
|
||||
"id": participant_id,
|
||||
"name": participant_name,
|
||||
"joined_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
def trigger_webhook(self, event_type: str, data: Dict[str, Any]):
|
||||
self._webhook_calls.append(
|
||||
{
|
||||
"type": event_type,
|
||||
"data": data,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
def get_webhook_calls(self) -> list[Dict[str, Any]]:
|
||||
return self._webhook_calls.copy()
|
||||
|
||||
def clear_data(self):
|
||||
self._rooms.clear()
|
||||
self._webhook_calls.clear()
|
||||
237
www/app/[roomName]/components/DailyRoom.tsx
Normal file
237
www/app/[roomName]/components/DailyRoom.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Box, Button, Text, VStack, HStack, Icon } from "@chakra-ui/react";
|
||||
import { toaster } from "../../components/ui/toaster";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRecordingConsent } from "../../recordingConsentContext";
|
||||
import { useMeetingAudioConsent } from "../../lib/apiHooks";
|
||||
import { FaBars } from "react-icons/fa6";
|
||||
import DailyIframe, { DailyCall } from "@daily-co/daily-js";
|
||||
import type { components } from "../../reflector-api";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
const CONSENT_BUTTON_TOP_OFFSET = "56px";
|
||||
const TOAST_CHECK_INTERVAL_MS = 100;
|
||||
|
||||
interface DailyRoomProps {
|
||||
meeting: Meeting;
|
||||
}
|
||||
|
||||
function ConsentDialogButton({ meetingId }: { meetingId: string }) {
|
||||
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const audioConsentMutation = useMeetingAudioConsent();
|
||||
|
||||
const handleConsent = useCallback(
|
||||
async (meetingId: string, given: boolean) => {
|
||||
try {
|
||||
await audioConsentMutation.mutateAsync({
|
||||
params: {
|
||||
path: {
|
||||
meeting_id: meetingId,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
consent_given: given,
|
||||
},
|
||||
});
|
||||
|
||||
touch(meetingId);
|
||||
} catch (error) {
|
||||
console.error("Error submitting consent:", error);
|
||||
}
|
||||
},
|
||||
[audioConsentMutation, touch],
|
||||
);
|
||||
|
||||
const showConsentModal = useCallback(() => {
|
||||
if (modalOpen) return;
|
||||
|
||||
setModalOpen(true);
|
||||
|
||||
const toastId = toaster.create({
|
||||
placement: "top",
|
||||
duration: null,
|
||||
render: ({ dismiss }) => (
|
||||
<Box
|
||||
p={6}
|
||||
bg="rgba(255, 255, 255, 0.7)"
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
maxW="md"
|
||||
mx="auto"
|
||||
>
|
||||
<VStack gap={4} alignItems="center">
|
||||
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||
Can we have your permission to store this meeting's audio
|
||||
recording on our servers?
|
||||
</Text>
|
||||
<HStack gap={4} justifyContent="center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleConsent(meetingId, false).then(() => {
|
||||
/*signifies it's ok to now wait here.*/
|
||||
});
|
||||
dismiss();
|
||||
}}
|
||||
>
|
||||
No, delete after transcription
|
||||
</Button>
|
||||
<Button
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleConsent(meetingId, true).then(() => {
|
||||
/*signifies it's ok to now wait here.*/
|
||||
});
|
||||
dismiss();
|
||||
}}
|
||||
>
|
||||
Yes, store the audio
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
toastId.then((id) => {
|
||||
const checkToastStatus = setInterval(() => {
|
||||
if (!toaster.isActive(id)) {
|
||||
setModalOpen(false);
|
||||
clearInterval(checkToastStatus);
|
||||
}
|
||||
}, TOAST_CHECK_INTERVAL_MS);
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
toastId.then((id) => toaster.dismiss(id));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
toastId.then((id) => toaster.dismiss(id));
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
}, [meetingId, handleConsent, modalOpen]);
|
||||
|
||||
if (
|
||||
!consentState.ready ||
|
||||
hasConsent(meetingId) ||
|
||||
audioConsentMutation.isPending
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
position="absolute"
|
||||
top={CONSENT_BUTTON_TOP_OFFSET}
|
||||
left="8px"
|
||||
zIndex={1000}
|
||||
colorPalette="blue"
|
||||
size="sm"
|
||||
onClick={showConsentModal}
|
||||
>
|
||||
Meeting is being recorded
|
||||
<Icon as={FaBars} ml={2} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const recordingTypeRequiresConsent = (
|
||||
recordingType: Meeting["recording_type"],
|
||||
) => {
|
||||
return recordingType === "cloud";
|
||||
};
|
||||
|
||||
export default function DailyRoom({ meeting }: DailyRoomProps) {
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
const status = auth.status;
|
||||
const isAuthenticated = status === "authenticated";
|
||||
const [callFrame, setCallFrame] = useState<DailyCall | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const roomUrl = meeting?.host_room_url || meeting?.room_url;
|
||||
|
||||
const isLoading = status === "loading";
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
router.push("/browse");
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !roomUrl || !containerRef.current) return;
|
||||
|
||||
let frame: DailyCall | null = null;
|
||||
let destroyed = false;
|
||||
|
||||
const createAndJoin = async () => {
|
||||
try {
|
||||
const existingFrame = DailyIframe.getCallInstance();
|
||||
if (existingFrame) {
|
||||
await existingFrame.destroy();
|
||||
}
|
||||
|
||||
frame = DailyIframe.createFrame(containerRef.current!, {
|
||||
iframeStyle: {
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
border: "none",
|
||||
},
|
||||
showLeaveButton: true,
|
||||
showFullscreenButton: true,
|
||||
});
|
||||
|
||||
if (destroyed) {
|
||||
await frame.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
frame.on("left-meeting", handleLeave);
|
||||
await frame.join({ url: roomUrl });
|
||||
|
||||
if (!destroyed) {
|
||||
setCallFrame(frame);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating Daily frame:", error);
|
||||
}
|
||||
};
|
||||
|
||||
createAndJoin();
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
if (frame) {
|
||||
frame.destroy().catch((e) => {
|
||||
console.error("Error destroying frame:", e);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [roomUrl, isLoading, handleLeave]);
|
||||
|
||||
if (!roomUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box position="relative" width="100vw" height="100vh">
|
||||
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
{recordingTypeRequiresConsent(meeting.recording_type) && (
|
||||
<ConsentDialogButton meetingId={meeting.id} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
214
www/app/[roomName]/components/RoomContainer.tsx
Normal file
214
www/app/[roomName]/components/RoomContainer.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { roomMeetingUrl } from "../../lib/routes";
|
||||
import { useCallback, useEffect, useState, use } from "react";
|
||||
import { Box, Text, Spinner } from "@chakra-ui/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useRoomGetByName,
|
||||
useRoomsCreateMeeting,
|
||||
useRoomGetMeeting,
|
||||
} from "../../lib/apiHooks";
|
||||
import type { components } from "../../reflector-api";
|
||||
import MeetingSelection from "../MeetingSelection";
|
||||
import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
|
||||
import WherebyRoom from "./WherebyRoom";
|
||||
import DailyRoom from "./DailyRoom";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import { parseNonEmptyString } from "../../lib/utils";
|
||||
import { printApiError } from "../../api/_error";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
export type RoomDetails = {
|
||||
params: Promise<{
|
||||
roomName: string;
|
||||
meetingId?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function LoadingSpinner() {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<Spinner color="blue.500" size="xl" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoomContainer(details: RoomDetails) {
|
||||
const params = use(details.params);
|
||||
const roomName = parseNonEmptyString(
|
||||
params.roomName,
|
||||
true,
|
||||
"panic! params.roomName is required",
|
||||
);
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
const status = auth.status;
|
||||
const isAuthenticated = status === "authenticated";
|
||||
const { setError } = useError();
|
||||
|
||||
const roomQuery = useRoomGetByName(roomName);
|
||||
const createMeetingMutation = useRoomsCreateMeeting();
|
||||
|
||||
const room = roomQuery.data;
|
||||
|
||||
const pageMeetingId = params.meetingId;
|
||||
|
||||
const defaultMeeting = useRoomDefaultMeeting(
|
||||
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
|
||||
);
|
||||
|
||||
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
|
||||
|
||||
const meeting = explicitMeeting.data || defaultMeeting.response;
|
||||
|
||||
const isLoading =
|
||||
status === "loading" ||
|
||||
roomQuery.isLoading ||
|
||||
defaultMeeting?.loading ||
|
||||
explicitMeeting.isLoading ||
|
||||
createMeetingMutation.isPending;
|
||||
|
||||
const errors = [
|
||||
explicitMeeting.error,
|
||||
defaultMeeting.error,
|
||||
roomQuery.error,
|
||||
createMeetingMutation.error,
|
||||
].filter(Boolean);
|
||||
|
||||
const isOwner =
|
||||
isAuthenticated && room ? auth.user?.id === room.user_id : false;
|
||||
|
||||
const handleMeetingSelect = (selectedMeeting: Meeting) => {
|
||||
router.push(
|
||||
roomMeetingUrl(
|
||||
roomName,
|
||||
parseNonEmptyString(
|
||||
selectedMeeting.id,
|
||||
true,
|
||||
"panic! selectedMeeting.id is required",
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateUnscheduled = async () => {
|
||||
try {
|
||||
const newMeeting = await createMeetingMutation.mutateAsync({
|
||||
params: {
|
||||
path: { room_name: roomName },
|
||||
},
|
||||
body: {
|
||||
allow_duplicated: room ? room.ics_enabled : false,
|
||||
},
|
||||
});
|
||||
handleMeetingSelect(newMeeting);
|
||||
} catch (err) {
|
||||
console.error("Failed to create meeting:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<Text fontSize="lg">Room not found</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (room.ics_enabled && !params.meetingId) {
|
||||
return (
|
||||
<MeetingSelection
|
||||
roomName={roomName}
|
||||
isOwner={isOwner}
|
||||
isSharedRoom={room?.is_shared || false}
|
||||
authLoading={["loading", "refreshing"].includes(auth.status)}
|
||||
onMeetingSelect={handleMeetingSelect}
|
||||
onCreateUnscheduled={handleCreateUnscheduled}
|
||||
isCreatingMeeting={createMeetingMutation.isPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
{errors.map((error, i) => (
|
||||
<Text key={i} fontSize="lg">
|
||||
{printApiError(error)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!meeting) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
const platform = meeting.platform;
|
||||
|
||||
if (!platform) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<Text fontSize="lg">Meeting platform not configured</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
switch (platform) {
|
||||
case "daily":
|
||||
return <DailyRoom meeting={meeting} />;
|
||||
case "whereby":
|
||||
return <WherebyRoom meeting={meeting} />;
|
||||
default: {
|
||||
const _exhaustive: never = platform;
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
height="100vh"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
>
|
||||
<Text fontSize="lg">Unknown platform: {platform}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
271
www/app/[roomName]/components/WherebyRoom.tsx
Normal file
271
www/app/[roomName]/components/WherebyRoom.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, RefObject } from "react";
|
||||
import { Box, Button, Text, VStack, HStack, Icon } from "@chakra-ui/react";
|
||||
import { toaster } from "../../components/ui/toaster";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRecordingConsent } from "../../recordingConsentContext";
|
||||
import { useMeetingAudioConsent } from "../../lib/apiHooks";
|
||||
import type { components } from "../../reflector-api";
|
||||
import { FaBars } from "react-icons/fa6";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient";
|
||||
import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils";
|
||||
|
||||
type Meeting = components["schemas"]["Meeting"];
|
||||
|
||||
interface WherebyRoomProps {
|
||||
meeting: Meeting;
|
||||
}
|
||||
|
||||
const useConsentWherebyFocusManagement = (
|
||||
acceptButtonRef: RefObject<HTMLButtonElement>,
|
||||
wherebyRef: RefObject<HTMLElement>,
|
||||
) => {
|
||||
const currentFocusRef = useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (acceptButtonRef.current) {
|
||||
acceptButtonRef.current.focus();
|
||||
} else {
|
||||
console.error(
|
||||
"accept button ref not available yet for focus management - seems to be illegal state",
|
||||
);
|
||||
}
|
||||
|
||||
const handleWherebyReady = () => {
|
||||
console.log("whereby ready - refocusing consent button");
|
||||
currentFocusRef.current = document.activeElement as HTMLElement;
|
||||
if (acceptButtonRef.current) {
|
||||
acceptButtonRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (wherebyRef.current) {
|
||||
wherebyRef.current.addEventListener("ready", handleWherebyReady);
|
||||
} else {
|
||||
console.warn(
|
||||
"whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
|
||||
currentFocusRef.current?.focus();
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
const useConsentDialog = (
|
||||
meetingId: string,
|
||||
wherebyRef: RefObject<HTMLElement>,
|
||||
) => {
|
||||
const { state: consentState, touch, hasConsent } = useRecordingConsent();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const audioConsentMutation = useMeetingAudioConsent();
|
||||
|
||||
const handleConsent = useCallback(
|
||||
async (meetingId: string, given: boolean) => {
|
||||
try {
|
||||
await audioConsentMutation.mutateAsync({
|
||||
params: {
|
||||
path: {
|
||||
meeting_id: meetingId,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
consent_given: given,
|
||||
},
|
||||
});
|
||||
|
||||
touch(meetingId);
|
||||
} catch (error) {
|
||||
console.error("Error submitting consent:", error);
|
||||
}
|
||||
},
|
||||
[audioConsentMutation, touch],
|
||||
);
|
||||
|
||||
const showConsentModal = useCallback(() => {
|
||||
if (modalOpen) return;
|
||||
|
||||
setModalOpen(true);
|
||||
|
||||
const toastId = toaster.create({
|
||||
placement: "top",
|
||||
duration: null,
|
||||
render: ({ dismiss }) => {
|
||||
const AcceptButton = () => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
useConsentWherebyFocusManagement(buttonRef, wherebyRef);
|
||||
return (
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
colorPalette="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleConsent(meetingId, true).then(() => {
|
||||
/*signifies it's ok to now wait here.*/
|
||||
});
|
||||
dismiss();
|
||||
}}
|
||||
>
|
||||
Yes, store the audio
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={6}
|
||||
bg="rgba(255, 255, 255, 0.7)"
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
maxW="md"
|
||||
mx="auto"
|
||||
>
|
||||
<VStack gap={4} alignItems="center">
|
||||
<Text fontSize="md" textAlign="center" fontWeight="medium">
|
||||
Can we have your permission to store this meeting's audio
|
||||
recording on our servers?
|
||||
</Text>
|
||||
<HStack gap={4} justifyContent="center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleConsent(meetingId, false).then(() => {
|
||||
/*signifies it's ok to now wait here.*/
|
||||
});
|
||||
dismiss();
|
||||
}}
|
||||
>
|
||||
No, delete after transcription
|
||||
</Button>
|
||||
<AcceptButton />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
toastId.then((id) => {
|
||||
const checkToastStatus = setInterval(() => {
|
||||
if (!toaster.isActive(id)) {
|
||||
setModalOpen(false);
|
||||
clearInterval(checkToastStatus);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
toastId.then((id) => toaster.dismiss(id));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
const cleanup = () => {
|
||||
toastId.then((id) => toaster.dismiss(id));
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
|
||||
return cleanup;
|
||||
}, [meetingId, handleConsent, wherebyRef, modalOpen]);
|
||||
|
||||
return {
|
||||
showConsentModal,
|
||||
consentState,
|
||||
hasConsent,
|
||||
consentLoading: audioConsentMutation.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
function ConsentDialogButton({
|
||||
meetingId,
|
||||
wherebyRef,
|
||||
}: {
|
||||
meetingId: NonEmptyString;
|
||||
wherebyRef: React.RefObject<HTMLElement>;
|
||||
}) {
|
||||
const { showConsentModal, consentState, hasConsent, consentLoading } =
|
||||
useConsentDialog(meetingId, wherebyRef);
|
||||
|
||||
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
position="absolute"
|
||||
top="56px"
|
||||
left="8px"
|
||||
zIndex={1000}
|
||||
colorPalette="blue"
|
||||
size="sm"
|
||||
onClick={showConsentModal}
|
||||
>
|
||||
Meeting is being recorded
|
||||
<Icon as={FaBars} ml={2} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const recordingTypeRequiresConsent = (
|
||||
recordingType: NonNullable<Meeting["recording_type"]>,
|
||||
) => {
|
||||
return recordingType === "cloud";
|
||||
};
|
||||
|
||||
export default function WherebyRoom({ meeting }: WherebyRoomProps) {
|
||||
const wherebyLoaded = useWhereby();
|
||||
const wherebyRef = useRef<HTMLElement>(null);
|
||||
const router = useRouter();
|
||||
const auth = useAuth();
|
||||
const status = auth.status;
|
||||
const isAuthenticated = status === "authenticated";
|
||||
|
||||
const wherebyRoomUrl = getWherebyUrl(meeting);
|
||||
const recordingType = meeting.recording_type;
|
||||
const meetingId = meeting.id;
|
||||
|
||||
const isLoading = status === "loading";
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
router.push("/browse");
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded)
|
||||
return;
|
||||
|
||||
wherebyRef.current?.addEventListener("leave", handleLeave);
|
||||
|
||||
return () => {
|
||||
wherebyRef.current?.removeEventListener("leave", handleLeave);
|
||||
};
|
||||
}, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]);
|
||||
|
||||
if (!wherebyRoomUrl || !wherebyLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<whereby-embed
|
||||
ref={wherebyRef}
|
||||
room={wherebyRoomUrl}
|
||||
style={{ width: "100vw", height: "100vh" }}
|
||||
/>
|
||||
{recordingType &&
|
||||
recordingTypeRequiresConsent(recordingType) &&
|
||||
meetingId && (
|
||||
<ConsentDialogButton
|
||||
meetingId={assertExistsAndNonEmptyString(meetingId)}
|
||||
wherebyRef={wherebyRef}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import Room from "./room";
|
||||
import RoomContainer from "./components/RoomContainer";
|
||||
|
||||
export default Room;
|
||||
export default RoomContainer;
|
||||
|
||||
128
www/app/reflector-api.d.ts
vendored
128
www/app/reflector-api.d.ts
vendored
@@ -4,6 +4,23 @@
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Health */
|
||||
get: operations["health"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/metrics": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -644,6 +661,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/webhook": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Webhook
|
||||
* @description Handle Daily webhook events.
|
||||
*/
|
||||
post: operations["v1_webhook"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -750,6 +787,8 @@ export interface components {
|
||||
* @default false
|
||||
*/
|
||||
ics_enabled: boolean;
|
||||
/** Platform */
|
||||
platform?: ("whereby" | "daily") | null;
|
||||
};
|
||||
/** CreateRoomMeeting */
|
||||
CreateRoomMeeting: {
|
||||
@@ -775,6 +814,22 @@ export interface components {
|
||||
target_language: string;
|
||||
source_kind?: components["schemas"]["SourceKind"] | null;
|
||||
};
|
||||
/**
|
||||
* DailyWebhookEvent
|
||||
* @description Daily webhook event structure.
|
||||
*/
|
||||
DailyWebhookEvent: {
|
||||
/** Type */
|
||||
type: string;
|
||||
/** Id */
|
||||
id: string;
|
||||
/** Ts */
|
||||
ts: number;
|
||||
/** Data */
|
||||
data: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
/** DeletionStatus */
|
||||
DeletionStatus: {
|
||||
/** Status */
|
||||
@@ -1091,6 +1146,12 @@ export interface components {
|
||||
calendar_metadata?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Platform
|
||||
* @default whereby
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
};
|
||||
/** MeetingConsentRequest */
|
||||
MeetingConsentRequest: {
|
||||
@@ -1177,6 +1238,12 @@ export interface components {
|
||||
ics_last_sync?: string | null;
|
||||
/** Ics Last Etag */
|
||||
ics_last_etag?: string | null;
|
||||
/**
|
||||
* Platform
|
||||
* @default whereby
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
};
|
||||
/** RoomDetails */
|
||||
RoomDetails: {
|
||||
@@ -1223,6 +1290,12 @@ export interface components {
|
||||
ics_last_sync?: string | null;
|
||||
/** Ics Last Etag */
|
||||
ics_last_etag?: string | null;
|
||||
/**
|
||||
* Platform
|
||||
* @default whereby
|
||||
* @enum {string}
|
||||
*/
|
||||
platform: "whereby" | "daily";
|
||||
/** Webhook Url */
|
||||
webhook_url: string | null;
|
||||
/** Webhook Secret */
|
||||
@@ -1403,6 +1476,8 @@ export interface components {
|
||||
ics_fetch_interval?: number | null;
|
||||
/** Ics Enabled */
|
||||
ics_enabled?: boolean | null;
|
||||
/** Platform */
|
||||
platform?: ("whereby" | "daily") | null;
|
||||
};
|
||||
/** UpdateTranscript */
|
||||
UpdateTranscript: {
|
||||
@@ -1509,6 +1584,26 @@ export interface components {
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
health: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
metrics: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2983,4 +3078,37 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
v1_webhook: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DailyWebhookEvent"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.24.2",
|
||||
"@daily-co/daily-js": "^0.84.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
|
||||
96
www/pnpm-lock.yaml
generated
96
www/pnpm-lock.yaml
generated
@@ -10,6 +10,9 @@ importers:
|
||||
"@chakra-ui/react":
|
||||
specifier: ^3.24.2
|
||||
version: 3.24.2(@emotion/react@11.14.0(@types/react@18.2.20)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
"@daily-co/daily-js":
|
||||
specifier: ^0.84.0
|
||||
version: 0.84.0
|
||||
"@emotion/react":
|
||||
specifier: ^11.14.0
|
||||
version: 11.14.0(@types/react@18.2.20)(react@18.3.1)
|
||||
@@ -487,6 +490,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=12" }
|
||||
|
||||
"@daily-co/daily-js@0.84.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-/ynXrMDDkRXhLlHxiFNf9QU5yw4ZGPr56wNARgja/Tiid71UIniundTavCNF5cMb2I1vNoMh7oEJ/q8stg/V7g==,
|
||||
}
|
||||
engines: { node: ">=10.0.0" }
|
||||
|
||||
"@emnapi/core@1.4.5":
|
||||
resolution:
|
||||
{
|
||||
@@ -2293,6 +2303,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
"@sentry-internal/browser-utils@8.55.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==,
|
||||
}
|
||||
engines: { node: ">=14.18" }
|
||||
|
||||
"@sentry-internal/feedback@10.11.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -2300,6 +2317,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
"@sentry-internal/feedback@8.55.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==,
|
||||
}
|
||||
engines: { node: ">=14.18" }
|
||||
|
||||
"@sentry-internal/replay-canvas@10.11.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -2307,6 +2331,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
"@sentry-internal/replay-canvas@8.55.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==,
|
||||
}
|
||||
engines: { node: ">=14.18" }
|
||||
|
||||
"@sentry-internal/replay@10.11.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -2314,6 +2345,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
"@sentry-internal/replay@8.55.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==,
|
||||
}
|
||||
engines: { node: ">=14.18" }
|
||||
|
||||
"@sentry/babel-plugin-component-annotate@4.3.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -2328,6 +2366,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
"@sentry/browser@8.55.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==,
|
||||
}
|
||||
engines: { node: ">=14.18" }
|
||||
|
||||
"@sentry/bundler-plugin-core@4.3.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -2421,6 +2466,13 @@ packages:
|
||||
}
|
||||
engines: { node: ">=18" }
|
||||
|
||||
"@sentry/core@8.55.0":
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==,
|
||||
}
|
||||
engines: { node: ">=14.18" }
|
||||
|
||||
"@sentry/nextjs@10.11.0":
|
||||
resolution:
|
||||
{
|
||||
@@ -4029,6 +4081,12 @@ packages:
|
||||
}
|
||||
engines: { node: ">=8" }
|
||||
|
||||
bowser@2.12.1:
|
||||
resolution:
|
||||
{
|
||||
integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==,
|
||||
}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
resolution:
|
||||
{
|
||||
@@ -9288,6 +9346,14 @@ snapshots:
|
||||
"@jridgewell/trace-mapping": 0.3.9
|
||||
optional: true
|
||||
|
||||
"@daily-co/daily-js@0.84.0":
|
||||
dependencies:
|
||||
"@babel/runtime": 7.28.2
|
||||
"@sentry/browser": 8.55.0
|
||||
bowser: 2.12.1
|
||||
dequal: 2.0.3
|
||||
events: 3.3.0
|
||||
|
||||
"@emnapi/core@1.4.5":
|
||||
dependencies:
|
||||
"@emnapi/wasi-threads": 1.0.4
|
||||
@@ -10506,20 +10572,38 @@ snapshots:
|
||||
dependencies:
|
||||
"@sentry/core": 10.11.0
|
||||
|
||||
"@sentry-internal/browser-utils@8.55.0":
|
||||
dependencies:
|
||||
"@sentry/core": 8.55.0
|
||||
|
||||
"@sentry-internal/feedback@10.11.0":
|
||||
dependencies:
|
||||
"@sentry/core": 10.11.0
|
||||
|
||||
"@sentry-internal/feedback@8.55.0":
|
||||
dependencies:
|
||||
"@sentry/core": 8.55.0
|
||||
|
||||
"@sentry-internal/replay-canvas@10.11.0":
|
||||
dependencies:
|
||||
"@sentry-internal/replay": 10.11.0
|
||||
"@sentry/core": 10.11.0
|
||||
|
||||
"@sentry-internal/replay-canvas@8.55.0":
|
||||
dependencies:
|
||||
"@sentry-internal/replay": 8.55.0
|
||||
"@sentry/core": 8.55.0
|
||||
|
||||
"@sentry-internal/replay@10.11.0":
|
||||
dependencies:
|
||||
"@sentry-internal/browser-utils": 10.11.0
|
||||
"@sentry/core": 10.11.0
|
||||
|
||||
"@sentry-internal/replay@8.55.0":
|
||||
dependencies:
|
||||
"@sentry-internal/browser-utils": 8.55.0
|
||||
"@sentry/core": 8.55.0
|
||||
|
||||
"@sentry/babel-plugin-component-annotate@4.3.0": {}
|
||||
|
||||
"@sentry/browser@10.11.0":
|
||||
@@ -10530,6 +10614,14 @@ snapshots:
|
||||
"@sentry-internal/replay-canvas": 10.11.0
|
||||
"@sentry/core": 10.11.0
|
||||
|
||||
"@sentry/browser@8.55.0":
|
||||
dependencies:
|
||||
"@sentry-internal/browser-utils": 8.55.0
|
||||
"@sentry-internal/feedback": 8.55.0
|
||||
"@sentry-internal/replay": 8.55.0
|
||||
"@sentry-internal/replay-canvas": 8.55.0
|
||||
"@sentry/core": 8.55.0
|
||||
|
||||
"@sentry/bundler-plugin-core@4.3.0":
|
||||
dependencies:
|
||||
"@babel/core": 7.28.3
|
||||
@@ -10590,6 +10682,8 @@ snapshots:
|
||||
|
||||
"@sentry/core@10.11.0": {}
|
||||
|
||||
"@sentry/core@8.55.0": {}
|
||||
|
||||
"@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)":
|
||||
dependencies:
|
||||
"@opentelemetry/api": 1.9.0
|
||||
@@ -11967,6 +12061,8 @@ snapshots:
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bowser@2.12.1: {}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
Reference in New Issue
Block a user