mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-10 07:36:54 +00:00
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:
committed by
GitHub
parent
74b9b97453
commit
e2ba502697
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
20
server/reflector/views/config.py
Normal file
20
server/reflector/views/config.py
Normal 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(),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user