feat: send email in share transcript and add email sending in room (#924)

* fix: add source language for file pipeline

* feat: send email in share transcript and add email sending in room

* fix: hide audio and video streaming for unauthenticated users

* fix: security order
This commit is contained in:
Juan Diego García
2026-03-24 17:17:52 -05:00
committed by GitHub
parent 74b9b97453
commit e2ba502697
28 changed files with 861 additions and 174 deletions

View File

@@ -0,0 +1,28 @@
"""add email_transcript_to to room
Revision ID: b4c7e8f9a012
Revises: a2b3c4d5e6f7
Create Date: 2026-03-24 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b4c7e8f9a012"
down_revision: Union[str, None] = "a2b3c4d5e6f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"room",
sa.Column("email_transcript_to", sa.String(), nullable=True),
)
def downgrade() -> None:
op.drop_column("room", "email_transcript_to")

View File

@@ -13,6 +13,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.config import router as config_router
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
@@ -107,6 +108,7 @@ app.include_router(transcripts_process_router, prefix="/v1")
app.include_router(user_router, prefix="/v1")
app.include_router(user_api_keys_router, prefix="/v1")
app.include_router(user_ws_router, prefix="/v1")
app.include_router(config_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")

View File

@@ -63,6 +63,7 @@ rooms = sqlalchemy.Table(
nullable=False,
server_default=sqlalchemy.sql.false(),
),
sqlalchemy.Column("email_transcript_to", sqlalchemy.String, nullable=True),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -92,6 +93,7 @@ class Room(BaseModel):
ics_last_etag: str | None = None
platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM)
skip_consent: bool = False
email_transcript_to: str | None = None
class RoomController:
@@ -147,6 +149,7 @@ class RoomController:
ics_enabled: bool = False,
platform: Platform = settings.DEFAULT_VIDEO_PLATFORM,
skip_consent: bool = False,
email_transcript_to: str | None = None,
):
"""
Add a new room
@@ -172,6 +175,7 @@ class RoomController:
"ics_enabled": ics_enabled,
"platform": platform,
"skip_consent": skip_consent,
"email_transcript_to": email_transcript_to,
}
room = Room(**room_data)

View File

@@ -1501,13 +1501,30 @@ async def send_email(input: PipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients:
recipients = (
list(meeting.email_recipients)
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
from reflector.db.rooms import rooms_controller # noqa: PLC0415
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
if not recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
await transcripts_controller.update(transcript, {"share_mode": "public"})
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript)
count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)

View File

@@ -896,14 +896,30 @@ async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients:
recipients = (
list(meeting.email_recipients)
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
from reflector.db.rooms import rooms_controller # noqa: PLC0415
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
if not recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
# Set transcript to public so the link works for anyone
await transcripts_controller.update(transcript, {"share_mode": "public"})
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript)
count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)

View File

@@ -397,13 +397,30 @@ async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult:
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients:
recipients = (
list(meeting.email_recipients)
if meeting and meeting.email_recipients
else []
)
# Also check room-level email
from reflector.db.rooms import rooms_controller # noqa: PLC0415
if transcript.room_id:
room = await rooms_controller.get_by_id(transcript.room_id)
if room and room.email_transcript_to:
if room.email_transcript_to not in recipients:
recipients.append(room.email_transcript_to)
if not recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
await transcripts_controller.update(transcript, {"share_mode": "public"})
# For room-level emails, do NOT change share_mode (only set public if meeting had recipients)
if meeting and meeting.email_recipients:
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript)
count = await send_transcript_email(recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)

View File

@@ -116,9 +116,12 @@ class Storage:
expires_in: int = 3600,
*,
bucket: str | None = None,
extra_params: dict | None = None,
) -> str:
"""Generate presigned URL. bucket: override instance default if provided."""
return await self._get_file_url(filename, operation, expires_in, bucket=bucket)
return await self._get_file_url(
filename, operation, expires_in, bucket=bucket, extra_params=extra_params
)
async def _get_file_url(
self,
@@ -127,6 +130,7 @@ class Storage:
expires_in: int = 3600,
*,
bucket: str | None = None,
extra_params: dict | None = None,
) -> str:
raise NotImplementedError

View File

@@ -170,16 +170,23 @@ class AwsStorage(Storage):
expires_in: int = 3600,
*,
bucket: str | None = None,
extra_params: dict | None = None,
) -> str:
actual_bucket = bucket or self._bucket_name
folder = self.aws_folder
s3filename = f"{folder}/{filename}" if folder else filename
params = {}
if extra_params:
params.update(extra_params)
# Always set Bucket/Key after extra_params to prevent overrides
params["Bucket"] = actual_bucket
params["Key"] = s3filename
async with self.session.client(
"s3", config=self.boto_config, endpoint_url=self._endpoint_url
) as client:
presigned_url = await client.generate_presigned_url(
operation,
Params={"Bucket": actual_bucket, "Key": s3filename},
Params=params,
ExpiresIn=expires_in,
)

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter
from pydantic import BaseModel
from reflector.email import is_email_configured
from reflector.settings import settings
router = APIRouter()
class ConfigResponse(BaseModel):
zulip_enabled: bool
email_enabled: bool
@router.get("/config", response_model=ConfigResponse)
async def get_config():
return ConfigResponse(
zulip_enabled=bool(settings.ZULIP_REALM),
email_enabled=is_email_configured(),
)

View File

@@ -44,6 +44,7 @@ class Room(BaseModel):
ics_last_etag: Optional[str] = None
platform: Platform
skip_consent: bool = False
email_transcript_to: str | None = None
class RoomDetails(Room):
@@ -93,6 +94,7 @@ class CreateRoom(BaseModel):
ics_enabled: bool = False
platform: Platform
skip_consent: bool = False
email_transcript_to: str | None = None
class UpdateRoom(BaseModel):
@@ -112,6 +114,7 @@ class UpdateRoom(BaseModel):
ics_enabled: Optional[bool] = None
platform: Optional[Platform] = None
skip_consent: Optional[bool] = None
email_transcript_to: Optional[str] = None
class CreateRoomMeeting(BaseModel):
@@ -253,6 +256,7 @@ async def rooms_create(
ics_enabled=room.ics_enabled,
platform=room.platform,
skip_consent=room.skip_consent,
email_transcript_to=room.email_transcript_to,
)

View File

@@ -40,6 +40,7 @@ from reflector.db.transcripts import (
transcripts_controller,
)
from reflector.db.users import user_controller
from reflector.email import is_email_configured, send_transcript_email
from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.processors.types import Word
from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment
@@ -718,3 +719,31 @@ async def transcript_post_to_zulip(
await transcripts_controller.update(
transcript, {"zulip_message_id": response["id"]}
)
class SendEmailRequest(BaseModel):
email: str
class SendEmailResponse(BaseModel):
sent: int
@router.post("/transcripts/{transcript_id}/email", response_model=SendEmailResponse)
async def transcript_send_email(
transcript_id: str,
request: SendEmailRequest,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
if not is_email_configured():
raise HTTPException(status_code=400, detail="Email not configured")
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
if not transcripts_controller.user_can_mutate(transcript, user_id):
raise HTTPException(status_code=403, detail="Not authorized")
sent = await send_transcript_email([request.email], transcript)
return SendEmailResponse(sent=sent)

View File

@@ -53,9 +53,22 @@ async def transcript_get_audio_mp3(
else:
user_id = token_user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not user_id and not token:
# No authentication provided at all. Only anonymous transcripts
# (user_id=None) are accessible without auth, to preserve
# pipeline access via _generate_local_audio_link().
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript or transcript.deleted_at is not None:
raise HTTPException(status_code=404, detail="Transcript not found")
if transcript.user_id is not None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
else:
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if transcript.audio_location == "storage":
# proxy S3 file, to prevent issue with CORS
@@ -94,16 +107,16 @@ async def transcript_get_audio_mp3(
request,
transcript.audio_mp3_filename,
content_type="audio/mpeg",
content_disposition=f"attachment; filename={filename}",
content_disposition=f"inline; filename={filename}",
)
@router.get("/transcripts/{transcript_id}/audio/waveform")
async def transcript_get_audio_waveform(
transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> AudioWaveform:
user_id = user["sub"] if user else None
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)

View File

@@ -2,16 +2,14 @@
Transcript cloud video endpoint — returns a presigned URL for streaming playback.
"""
from typing import Annotated, Optional
from typing import Annotated
import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.meetings import meetings_controller
from reflector.db.transcripts import transcripts_controller
from reflector.settings import settings
from reflector.storage import get_source_storage
router = APIRouter()
@@ -30,26 +28,9 @@ class VideoUrlResponse(BaseModel):
)
async def transcript_get_video_url(
transcript_id: str,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
token: str | None = None,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
user_id = user["sub"] if user else None
if not user_id and token:
try:
token_user = await auth.verify_raw_token(token)
except Exception:
token_user = None
if not token_user:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
except jwt.PyJWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
else:
user_id = token_user["sub"]
user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
@@ -66,7 +47,11 @@ async def transcript_get_video_url(
url = await source_storage.get_file_url(
meeting.daily_composed_video_s3_key,
operation="get_object",
expires_in=3600,
expires_in=900,
extra_params={
"ResponseContentDisposition": "inline",
"ResponseContentType": "video/mp4",
},
)
return VideoUrlResponse(

View File

@@ -137,6 +137,7 @@ async def mock_storage():
operation: str = "get_object",
expires_in: int = 3600,
bucket=None,
extra_params=None,
):
return f"http://test-storage/{path}"

View File

@@ -373,9 +373,9 @@ async def test_audio_mp3_requires_token_for_owned_transcript(
tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(audio_path, tr.audio_mp3_filename)
# Anonymous GET without token should be 403 or 404 depending on access; we call mp3
# Anonymous GET without token should be 401 (auth required)
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert resp.status_code == 403
assert resp.status_code == 401
# With token should succeed
token = create_access_token(
@@ -898,7 +898,7 @@ async def test_anonymous_transcript_in_list_when_public_mode(client, monkeypatch
@pytest.mark.asyncio
async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir):
"""Anonymous transcript audio (mp3) is accessible without authentication
because user_id=None bypasses share_mode checks."""
because user_id=None bypasses the auth requirement (pipeline access)."""
monkeypatch.setattr(settings, "PUBLIC_MODE", True)
monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
@@ -920,7 +920,7 @@ async def test_anonymous_transcript_audio_accessible(client, monkeypatch, tmpdir
resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
assert (
resp.status_code == 200
), f"Anonymous transcript audio should be accessible: {resp.text}"
), f"Anonymous transcript audio should be accessible for pipeline: {resp.text}"
@pytest.mark.asyncio

View File

@@ -40,7 +40,7 @@ async def fake_transcript(tmpdir, client, monkeypatch):
],
)
async def test_transcript_audio_download(
fake_transcript, url_suffix, content_type, client
authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200
@@ -61,7 +61,7 @@ async def test_transcript_audio_download(
],
)
async def test_transcript_audio_download_head(
fake_transcript, url_suffix, content_type, client
authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.head(f"/transcripts/{fake_transcript.id}/audio{url_suffix}")
assert response.status_code == 200
@@ -82,7 +82,7 @@ async def test_transcript_audio_download_head(
],
)
async def test_transcript_audio_download_range(
fake_transcript, url_suffix, content_type, client
authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",
@@ -102,7 +102,7 @@ async def test_transcript_audio_download_range(
],
)
async def test_transcript_audio_download_range_with_seek(
fake_transcript, url_suffix, content_type, client
authenticated_client, fake_transcript, url_suffix, content_type, client
):
response = await client.get(
f"/transcripts/{fake_transcript.id}/audio{url_suffix}",

View File

@@ -98,10 +98,10 @@ async def private_transcript(tmpdir):
@pytest.mark.asyncio
async def test_audio_mp3_private_no_auth_returns_403(private_transcript, client):
"""Without auth, accessing a private transcript's audio returns 403."""
async def test_audio_mp3_private_no_auth_returns_401(private_transcript, client):
"""Without auth, accessing a private transcript's audio returns 401."""
response = await client.get(f"/transcripts/{private_transcript.id}/audio/mp3")
assert response.status_code == 403
assert response.status_code == 401
@pytest.mark.asyncio
@@ -125,8 +125,8 @@ async def test_audio_mp3_with_bearer_header(private_transcript, client):
@pytest.mark.asyncio
async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
"""Public transcripts are accessible without any auth."""
async def test_audio_mp3_public_transcript_no_auth_returns_401(tmpdir, client):
"""Public transcripts require authentication for audio access."""
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.settings import settings
@@ -146,8 +146,7 @@ async def test_audio_mp3_public_transcript_no_auth_ok(tmpdir, client):
shutil.copy(mp3_source, audio_filename)
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"
assert response.status_code == 401
# ---------------------------------------------------------------------------
@@ -299,11 +298,9 @@ async def test_local_audio_link_token_works_with_authentik_backend(
"""_generate_local_audio_link creates an HS256 token via create_access_token.
When the Authentik (RS256) auth backend is active, verify_raw_token uses
JWTAuth which expects RS256 + public key. The HS256 token created by
_generate_local_audio_link will fail verification, returning 401.
This test documents the bug: the internal audio URL generated for the
diarization pipeline is unusable under the JWT auth backend.
JWTAuth which expects RS256 + public key. The HS256 token fails RS256
verification, but the audio endpoint's HS256 fallback (jwt.decode with
SECRET_KEY) correctly handles it, so the request succeeds with 200.
"""
from urllib.parse import parse_qs, urlparse
@@ -322,6 +319,55 @@ async def test_local_audio_link_token_works_with_authentik_backend(
f"/transcripts/{private_transcript.id}/audio/mp3?token={token}"
)
# BUG: this should be 200 (the token was created by our own server),
# but the Authentik backend rejects it because it's HS256, not RS256.
# The HS256 fallback in the audio endpoint handles this correctly.
assert response.status_code == 200
# ---------------------------------------------------------------------------
# Waveform endpoint auth tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_waveform_requires_authentication(client):
"""Waveform endpoint returns 401 for unauthenticated requests."""
response = await client.get("/transcripts/any-id/audio/waveform")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_audio_mp3_authenticated_user_accesses_anonymous_transcript(
tmpdir, client
):
"""Authenticated user can access audio for an anonymous (user_id=None) transcript."""
from reflector.app import app
from reflector.auth import current_user, current_user_optional
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.settings import settings
settings.DATA_DIR = Path(tmpdir)
transcript = await transcripts_controller.add(
"Anonymous audio test",
source_kind=SourceKind.FILE,
user_id=None,
share_mode="private",
)
await transcripts_controller.update(transcript, {"status": "ended"})
audio_filename = transcript.audio_mp3_filename
mp3_source = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
audio_filename.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(mp3_source, audio_filename)
_user = lambda: {"sub": "some-authenticated-user", "email": "user@example.com"}
app.dependency_overrides[current_user] = _user
app.dependency_overrides[current_user_optional] = _user
try:
response = await client.get(f"/transcripts/{transcript.id}/audio/mp3")
finally:
del app.dependency_overrides[current_user]
del app.dependency_overrides[current_user_optional]
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"

View File

@@ -103,3 +103,162 @@ async def test_transcript_get_includes_video_fields(authenticated_client, client
data = response.json()
assert data["has_cloud_video"] is False
assert data["cloud_video_duration"] is None
@pytest.mark.asyncio
async def test_video_url_requires_authentication(client):
"""Test that video URL endpoint returns 401 for unauthenticated requests."""
response = await client.get("/transcripts/any-id/video/url")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_video_url_presigned_params(authenticated_client, client):
"""Test that presigned URL is generated with short expiry and inline disposition."""
from reflector.db import get_database
from reflector.db.meetings import meetings
meeting_id = "test-meeting-presigned-params"
await get_database().execute(
meetings.insert().values(
id=meeting_id,
room_name="Presigned Params Meeting",
room_url="https://example.com",
host_room_url="https://example.com/host",
start_date=datetime.now(timezone.utc),
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
room_id=None,
daily_composed_video_s3_key="recordings/video.mp4",
daily_composed_video_duration=60,
)
)
transcript = await transcripts_controller.add(
name="presigned-params",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="randomuserid",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/presigned-url"
)
mock_storage.return_value = mock_instance
await client.get(f"/transcripts/{transcript.id}/video/url")
mock_instance.get_file_url.assert_called_once_with(
"recordings/video.mp4",
operation="get_object",
expires_in=900,
extra_params={
"ResponseContentDisposition": "inline",
"ResponseContentType": "video/mp4",
},
)
async def _create_meeting_with_video(meeting_id):
"""Helper to create a meeting with cloud video."""
from reflector.db import get_database
from reflector.db.meetings import meetings
await get_database().execute(
meetings.insert().values(
id=meeting_id,
room_name="Video Meeting",
room_url="https://example.com",
host_room_url="https://example.com/host",
start_date=datetime.now(timezone.utc),
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
room_id=None,
daily_composed_video_s3_key="recordings/video.mp4",
daily_composed_video_duration=60,
)
)
@pytest.mark.asyncio
async def test_video_url_private_transcript_denies_non_owner(
authenticated_client, client
):
"""Authenticated non-owner cannot access video for a private transcript."""
meeting_id = "test-meeting-private-deny"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="private-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="private",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_video_url_public_transcript_allows_authenticated_non_owner(
authenticated_client, client
):
"""Authenticated non-owner can access video for a public transcript."""
meeting_id = "test-meeting-public-allow"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="public-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="public",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 200
@pytest.mark.asyncio
async def test_video_url_semi_private_allows_authenticated_non_owner(
authenticated_client, client
):
"""Authenticated non-owner can access video for a semi-private transcript."""
meeting_id = "test-meeting-semi-private-allow"
await _create_meeting_with_video(meeting_id)
transcript = await transcripts_controller.add(
name="semi-private-video",
source_kind=SourceKind.ROOM,
meeting_id=meeting_id,
user_id="other-owner",
share_mode="semi-private",
)
with patch("reflector.views.transcripts_video.get_source_storage") as mock_storage:
mock_instance = AsyncMock()
mock_instance.get_file_url = AsyncMock(
return_value="https://s3.example.com/url"
)
mock_storage.return_value = mock_instance
response = await client.get(f"/transcripts/{transcript.id}/video/url")
assert response.status_code == 200

View File

@@ -31,6 +31,7 @@ import {
useZulipTopics,
useRoomGet,
useRoomTestWebhook,
useConfig,
} from "../../lib/apiHooks";
import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination";
@@ -92,6 +93,7 @@ const roomInitialState = {
icsFetchInterval: 5,
platform: "whereby",
skipConsent: false,
emailTranscriptTo: "",
};
export default function RoomsList() {
@@ -133,11 +135,15 @@ export default function RoomsList() {
null,
);
const [showWebhookSecret, setShowWebhookSecret] = useState(false);
const [emailTranscriptEnabled, setEmailTranscriptEnabled] = useState(false);
const createRoomMutation = useRoomCreate();
const updateRoomMutation = useRoomUpdate();
const deleteRoomMutation = useRoomDelete();
const { data: streams = [] } = useZulipStreams();
const { data: config } = useConfig();
const zulipEnabled = config?.zulip_enabled ?? false;
const emailEnabled = config?.email_enabled ?? false;
const { data: streams = [] } = useZulipStreams(zulipEnabled);
const { data: topics = [] } = useZulipTopics(selectedStreamId);
const {
@@ -177,6 +183,7 @@ export default function RoomsList() {
icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5,
platform: detailedEditedRoom.platform,
skipConsent: detailedEditedRoom.skip_consent || false,
emailTranscriptTo: detailedEditedRoom.email_transcript_to || "",
}
: null,
[detailedEditedRoom],
@@ -329,6 +336,7 @@ export default function RoomsList() {
ics_fetch_interval: room.icsFetchInterval,
platform,
skip_consent: room.skipConsent,
email_transcript_to: room.emailTranscriptTo || null,
};
if (isEditing) {
@@ -369,6 +377,7 @@ export default function RoomsList() {
// Reset states
setShowWebhookSecret(false);
setWebhookTestResult(null);
setEmailTranscriptEnabled(!!roomData.email_transcript_to);
setRoomInput({
name: roomData.name,
@@ -392,6 +401,7 @@ export default function RoomsList() {
icsFetchInterval: roomData.ics_fetch_interval || 5,
platform: roomData.platform,
skipConsent: roomData.skip_consent || false,
emailTranscriptTo: roomData.email_transcript_to || "",
});
setEditRoomId(roomId);
setIsEditing(true);
@@ -469,6 +479,7 @@ export default function RoomsList() {
setNameError("");
setShowWebhookSecret(false);
setWebhookTestResult(null);
setEmailTranscriptEnabled(false);
onOpen();
}}
>
@@ -504,7 +515,9 @@ export default function RoomsList() {
<Tabs.List>
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="calendar">Calendar</Tabs.Trigger>
<Tabs.Trigger value="share">Share</Tabs.Trigger>
{(zulipEnabled || emailEnabled) && (
<Tabs.Trigger value="share">Share</Tabs.Trigger>
)}
<Tabs.Trigger value="webhook">WebHook</Tabs.Trigger>
</Tabs.List>
@@ -831,96 +844,144 @@ export default function RoomsList() {
</Tabs.Content>
<Tabs.Content value="share" pt={6}>
<Field.Root>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({ ...room, zulipTopic: e.value[0] })
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
{emailEnabled && (
<>
<Field.Root>
<Checkbox.Root
checked={emailTranscriptEnabled}
onCheckedChange={(e) => {
setEmailTranscriptEnabled(!!e.checked);
if (!e.checked) {
setRoomInput({
...room,
emailTranscriptTo: "",
});
}
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Email me transcript when processed
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
{emailTranscriptEnabled && (
<Field.Root mt={2}>
<Input
name="emailTranscriptTo"
type="email"
placeholder="your@email.com"
value={room.emailTranscriptTo}
onChange={handleRoomChange}
/>
<Field.HelperText>
Transcript will be emailed to this address after
processing
</Field.HelperText>
</Field.Root>
)}
</>
)}
{zulipEnabled && (
<>
<Field.Root mt={emailEnabled ? 4 : 0}>
<Checkbox.Root
name="zulipAutoPost"
checked={room.zulipAutoPost}
onCheckedChange={(e) => {
const syntheticEvent = {
target: {
name: "zulipAutoPost",
type: "checkbox",
checked: e.checked,
},
};
handleRoomChange(syntheticEvent);
}}
>
<Checkbox.HiddenInput />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>
Automatically post transcription to Zulip
</Checkbox.Label>
</Checkbox.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip stream</Field.Label>
<Select.Root
value={room.zulipStream ? [room.zulipStream] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipStream: e.value[0],
zulipTopic: "",
})
}
collection={streamCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select stream" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{streamOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
<Field.Root mt={4}>
<Field.Label>Zulip topic</Field.Label>
<Select.Root
value={room.zulipTopic ? [room.zulipTopic] : []}
onValueChange={(e) =>
setRoomInput({
...room,
zulipTopic: e.value[0],
})
}
collection={topicCollection}
disabled={!room.zulipAutoPost}
>
<Select.HiddenSelect />
<Select.Control>
<Select.Trigger>
<Select.ValueText placeholder="Select topic" />
</Select.Trigger>
<Select.IndicatorGroup>
<Select.Indicator />
</Select.IndicatorGroup>
</Select.Control>
<Select.Positioner>
<Select.Content>
{topicOptions.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
</Select.Positioner>
</Select.Root>
</Field.Root>
</>
)}
</Tabs.Content>
<Tabs.Content value="webhook" pt={6}>

View File

@@ -24,6 +24,8 @@ import {
} from "@chakra-ui/react";
import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
import { useAuth } from "../../../lib/AuthProvider";
import { featureEnabled } from "../../../lib/features";
type TranscriptDetails = {
params: Promise<{
@@ -57,7 +59,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
const [finalSummaryElement, setFinalSummaryElement] =
useState<HTMLDivElement | null>(null);
const hasCloudVideo = !!transcript.data?.has_cloud_video;
const auth = useAuth();
const isAuthenticated =
auth.status === "authenticated" || !featureEnabled("requireLogin");
const hasCloudVideo = !!transcript.data?.has_cloud_video && isAuthenticated;
const [videoExpanded, setVideoExpanded] = useState(false);
const [videoNewBadge, setVideoNewBadge] = useState(() => {
if (typeof window === "undefined") return true;
@@ -145,7 +150,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
mt={4}
mb={4}
>
{!mp3.audioDeleted && (
{isAuthenticated && !mp3.audioDeleted && (
<>
{waveform.waveform && mp3.media && topics.topics ? (
<Player

View File

@@ -21,6 +21,10 @@ import { useAuth } from "../../../lib/AuthProvider";
import { featureEnabled } from "../../../lib/features";
import { SearchableLanguageSelect } from "../../../components/SearchableLanguageSelect";
const sourceLanguages = supportedLanguages.filter(
(l) => l.value && l.value !== "NOTRANSLATION",
);
const TranscriptCreate = () => {
const router = useRouter();
const auth = useAuth();
@@ -33,8 +37,13 @@ const TranscriptCreate = () => {
const nameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
};
const [sourceLanguage, setSourceLanguage] = useState<string>("");
const [targetLanguage, setTargetLanguage] = useState<string>("NOTRANSLATION");
const onSourceLanguageChange = (newval) => {
(!newval || typeof newval === "string") &&
setSourceLanguage(newval || "en");
};
const onLanguageChange = (newval) => {
(!newval || typeof newval === "string") && setTargetLanguage(newval);
};
@@ -55,7 +64,7 @@ const TranscriptCreate = () => {
const targetLang = getTargetLanguage();
createTranscript.create({
name,
source_language: "en",
source_language: sourceLanguage || "en",
target_language: targetLang || "en",
source_kind: "live",
});
@@ -67,7 +76,7 @@ const TranscriptCreate = () => {
const targetLang = getTargetLanguage();
createTranscript.create({
name,
source_language: "en",
source_language: sourceLanguage || "en",
target_language: targetLang || "en",
source_kind: "file",
});
@@ -160,6 +169,15 @@ const TranscriptCreate = () => {
placeholder="Optional"
/>
</Box>
<Box mb={4}>
<Text mb={1}>Audio language</Text>
<SearchableLanguageSelect
options={sourceLanguages}
value={sourceLanguage}
onChange={onSourceLanguageChange}
placeholder="Select language"
/>
</Box>
<Box mb={4}>
<Text mb={1}>Do you want to enable live translation?</Text>
<SearchableLanguageSelect

View File

@@ -18,10 +18,11 @@ import {
createListCollection,
} from "@chakra-ui/react";
import { LuShare2 } from "react-icons/lu";
import { useTranscriptUpdate } from "../../lib/apiHooks";
import { useTranscriptUpdate, useConfig } from "../../lib/apiHooks";
import ShareLink from "./shareLink";
import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
import ShareEmail from "./shareEmail";
import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features";
@@ -55,6 +56,9 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [shareLoading, setShareLoading] = useState(false);
const requireLogin = featureEnabled("requireLogin");
const updateTranscriptMutation = useTranscriptUpdate();
const { data: config } = useConfig();
const zulipEnabled = config?.zulip_enabled ?? false;
const emailEnabled = config?.email_enabled ?? false;
const updateShareMode = async (selectedValue: string) => {
const selectedOption = shareOptionsData.find(
@@ -169,14 +173,20 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
<Text fontSize="sm" mb="2" fontWeight={"bold"}>
Share options
</Text>
<Flex gap={2} mb={2}>
{requireLogin && (
<Flex gap={2} mb={2} flexWrap="wrap">
{requireLogin && zulipEnabled && (
<ShareZulip
transcript={props.transcript}
topics={props.topics}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
{emailEnabled && (
<ShareEmail
transcript={props.transcript}
disabled={toShareMode(shareMode.value) === "private"}
/>
)}
<ShareCopy
finalSummaryElement={props.finalSummaryElement}
transcript={props.transcript}

View File

@@ -0,0 +1,110 @@
import { useState } from "react";
import type { components } from "../../reflector-api";
type GetTranscriptWithParticipants =
components["schemas"]["GetTranscriptWithParticipants"];
import {
Button,
Dialog,
CloseButton,
Input,
Box,
Text,
} from "@chakra-ui/react";
import { LuMail } from "react-icons/lu";
import { useTranscriptSendEmail } from "../../lib/apiHooks";
type ShareEmailProps = {
transcript: GetTranscriptWithParticipants;
disabled: boolean;
};
export default function ShareEmail(props: ShareEmailProps) {
const [showModal, setShowModal] = useState(false);
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const sendEmailMutation = useTranscriptSendEmail();
const handleSend = async () => {
if (!email) return;
try {
await sendEmailMutation.mutateAsync({
params: {
path: { transcript_id: props.transcript.id },
},
body: { email },
});
setSent(true);
setTimeout(() => {
setSent(false);
setShowModal(false);
setEmail("");
}, 2000);
} catch (error) {
console.error("Error sending email:", error);
}
};
return (
<>
<Button disabled={props.disabled} onClick={() => setShowModal(true)}>
<LuMail /> Send Email
</Button>
<Dialog.Root
open={showModal}
onOpenChange={(e) => {
setShowModal(e.open);
if (!e.open) {
setSent(false);
setEmail("");
}
}}
size="md"
>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Send Transcript via Email</Dialog.Title>
<Dialog.CloseTrigger asChild>
<CloseButton />
</Dialog.CloseTrigger>
</Dialog.Header>
<Dialog.Body>
{sent ? (
<Text color="green.500">Email sent successfully!</Text>
) : (
<Box>
<Text mb={2}>
Enter the email address to send this transcript to:
</Text>
<Input
type="email"
placeholder="recipient@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
/>
</Box>
)}
</Dialog.Body>
<Dialog.Footer>
<Button variant="ghost" onClick={() => setShowModal(false)}>
Close
</Button>
{!sent && (
<Button
disabled={!email || sendEmailMutation.isPending}
onClick={handleSend}
>
{sendEmailMutation.isPending ? "Sending..." : "Send"}
</Button>
)}
</Dialog.Footer>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
}

View File

@@ -56,7 +56,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
}, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]);
useEffect(() => {
if (!transcriptId || later || !transcript) return;
if (!transcriptId || later || !transcript || !accessTokenInfo) return;
let stopped = false;
let audioElement: HTMLAudioElement | null = null;
@@ -113,7 +113,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
if (handleError) audioElement.removeEventListener("error", handleError);
}
};
}, [transcriptId, transcript, later]);
}, [transcriptId, transcript, later, accessTokenInfo]);
const getNow = () => {
setLater(false);

View File

@@ -39,17 +39,16 @@ export default function VideoPlayer({
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (accessToken) {
params.set("token", accessToken);
}
const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url?${params}`;
const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url`;
const headers: Record<string, string> = {};
if (accessToken) {
headers["Authorization"] = `Bearer ${accessToken}`;
}
const resp = await fetch(url, { headers });
if (!resp.ok) {
if (resp.status === 401) {
throw new Error("Sign in to view the video recording");
}
throw new Error("Failed to load video");
}
const data = await resp.json();
@@ -90,7 +89,7 @@ export default function VideoPlayer({
w="fit-content"
maxW="100%"
>
<Text fontSize="sm">Failed to load video recording</Text>
<Text fontSize="sm">{error || "Failed to load video recording"}</Text>
</Box>
);
}
@@ -132,10 +131,14 @@ export default function VideoPlayer({
</Flex>
</Flex>
{/* Video element with visible controls */}
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
src={videoUrl}
controls
autoPlay
controlsList="nodownload"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
style={{
display: "block",
width: "100%",

View File

@@ -67,7 +67,7 @@ export function SearchableLanguageSelect({
const collection = useMemo(() => createListCollection({ items }), [items]);
const selectedValues = value && value !== "NOTRANSLATION" ? [value] : [];
const selectedValues = value ? [value] : [];
return (
<Combobox.Root

View File

@@ -228,7 +228,11 @@ export function useRoomDelete() {
});
}
export function useZulipStreams() {
export function useConfig() {
return $api.useQuery("get", "/v1/config", {});
}
export function useZulipStreams(enabled: boolean = true) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery(
@@ -236,7 +240,7 @@ export function useZulipStreams() {
"/v1/zulip/streams",
{},
{
enabled: isAuthenticated,
enabled: enabled && isAuthenticated,
},
);
}
@@ -291,6 +295,16 @@ export function useTranscriptPostToZulip() {
});
}
export function useTranscriptSendEmail() {
const { setError } = useError();
return $api.useMutation("post", "/v1/transcripts/{transcript_id}/email", {
onError: (error) => {
setError(error as Error, "There was an error sending the email");
},
});
}
export function useTranscriptUploadAudio() {
const { setError } = useError();
const queryClient = useQueryClient();

View File

@@ -456,6 +456,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/email": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Transcript Send Email */
post: operations["v1_transcript_send_email"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/audio/mp3": {
parameters: {
query?: never;
@@ -739,6 +756,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Config */
get: operations["v1_get_config"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/zulip/streams": {
parameters: {
query?: never;
@@ -942,6 +976,13 @@ export interface components {
*/
updated_at: string;
};
/** ConfigResponse */
ConfigResponse: {
/** Zulip Enabled */
zulip_enabled: boolean;
/** Email Enabled */
email_enabled: boolean;
};
/** CreateApiKeyRequest */
CreateApiKeyRequest: {
/** Name */
@@ -1025,6 +1066,8 @@ export interface components {
* @default false
*/
skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
};
/** CreateRoomMeeting */
CreateRoomMeeting: {
@@ -1844,6 +1887,8 @@ export interface components {
* @default false
*/
skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
};
/** RoomDetails */
RoomDetails: {
@@ -1900,6 +1945,8 @@ export interface components {
* @default false
*/
skip_consent: boolean;
/** Email Transcript To */
email_transcript_to?: string | null;
/** Webhook Url */
webhook_url: string | null;
/** Webhook Secret */
@@ -1984,6 +2031,16 @@ export interface components {
/** Change Seq */
change_seq?: number | null;
};
/** SendEmailRequest */
SendEmailRequest: {
/** Email */
email: string;
};
/** SendEmailResponse */
SendEmailResponse: {
/** Sent */
sent: number;
};
/**
* SourceKind
* @enum {string}
@@ -2264,6 +2321,8 @@ export interface components {
platform?: ("whereby" | "daily") | null;
/** Skip Consent */
skip_consent?: boolean | null;
/** Email Transcript To */
email_transcript_to?: string | null;
};
/** UpdateTranscript */
UpdateTranscript: {
@@ -3497,6 +3556,41 @@ export interface operations {
};
};
};
v1_transcript_send_email: {
parameters: {
query?: never;
header?: never;
path: {
transcript_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SendEmailRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SendEmailResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcript_get_audio_mp3: {
parameters: {
query?: {
@@ -4167,6 +4261,26 @@ export interface operations {
};
};
};
v1_get_config: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConfigResponse"];
};
};
};
};
v1_zulip_get_streams: {
parameters: {
query?: never;