Compare commits
8 Commits
modal
...
test-egress
| Author | SHA1 | Date | |
|---|---|---|---|
| aa96d04b5e | |||
| 5321ade8a5 | |||
| 5d7f9e1325 | |||
| 94c19d026d | |||
| 3da3641a19 | |||
| 21626b4c4c | |||
| f16cedf5a5 | |||
| 00e4679dc5 |
@@ -42,3 +42,4 @@ LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://localhost:7880
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
WORKER_SECRET=DevPurposeOnly
|
||||
|
||||
+1
-1
Submodule secrets updated: f5fbc16e6e...1a016aca31
@@ -1,6 +1,8 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import smtplib
|
||||
import uuid
|
||||
import requests
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -14,6 +16,9 @@ from rest_framework import (
|
||||
pagination,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
permissions as drf_permissions,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
@@ -249,6 +254,39 @@ class RoomViewSet(
|
||||
},
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
methods=["POST"],
|
||||
detail=True,
|
||||
url_path="summary",
|
||||
permission_classes=[drf_permissions.AllowAny],
|
||||
)
|
||||
def summary(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Wip"""
|
||||
|
||||
secret = self.request.data.get("secret", None)
|
||||
if secret is None or secret != settings.WORKER_SECRET:
|
||||
return drf_response.Response({"message": "Invalid secret"}, status=403)
|
||||
|
||||
summary = self.request.data.get("summary", None)
|
||||
transcript = self.request.data.get("transcript", None)
|
||||
|
||||
instance = self.get_object()
|
||||
owners = instance.get_owners()
|
||||
|
||||
email = owners[0].email
|
||||
|
||||
url = f"https://dinum-pad-dev.osc-fr1.scalingo.io/summary?email={email}"
|
||||
headers = {
|
||||
"Content-Type": "text/plain"
|
||||
}
|
||||
response = requests.post(url, headers=headers, data=summary, allow_redirects=False)
|
||||
try:
|
||||
instance.email_summary(summary=summary, transcript=transcript, owners=owners, link=response.headers['Location'])
|
||||
except smtplib.SMTPException:
|
||||
return drf_response.Response({"message": "Error"}, status=500)
|
||||
|
||||
return drf_response.Response({"message": "Webhook data received"}, status=200)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
|
||||
@@ -11,10 +11,14 @@ from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.core.mail import EmailMessage
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -150,11 +154,28 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
def email_user(self, subject, transcript, from_email=None, **kwargs):
|
||||
"""Email this user."""
|
||||
if not self.email:
|
||||
raise ValueError("User has no email address.")
|
||||
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
# mail.send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
|
||||
email = EmailMessage(
|
||||
subject,
|
||||
kwargs['html_message'],
|
||||
from_email,
|
||||
[self.email],
|
||||
)
|
||||
|
||||
# email.attach_alternative(kwargs['html_message'], "text/html")
|
||||
|
||||
transcript = transcript or ''
|
||||
|
||||
wip_file = ContentFile(transcript, 'transcript.txt')
|
||||
|
||||
email.content_subtype = "html"
|
||||
email.attach(wip_file.name, wip_file.read(), 'text/plain')
|
||||
email.send()
|
||||
|
||||
def get_teams(self):
|
||||
"""
|
||||
@@ -325,3 +346,30 @@ class Room(Resource):
|
||||
else:
|
||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
def get_owners(self):
|
||||
"""Fetch the user who is the owner of the room."""
|
||||
owner_accesses = self.accesses.filter(role=RoleChoices.OWNER)
|
||||
return [access.user for access in owner_accesses]
|
||||
|
||||
def email_summary(self, owners, summary, transcript, link):
|
||||
"""Wip"""
|
||||
|
||||
template_vars = {
|
||||
"title": _("A new summary is ready"),
|
||||
"room": self.slug,
|
||||
"summary": summary,
|
||||
"link": link,
|
||||
}
|
||||
|
||||
msg_plain = render_to_string("mail/text/summary.txt", template_vars)
|
||||
msg_html = render_to_string("mail/html/summary.html", template_vars)
|
||||
|
||||
for owner in owners:
|
||||
owner.email_user(
|
||||
subject=_("A new summary is ready"),
|
||||
transcript=transcript,
|
||||
from_email=settings.EMAIL_FROM,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
room=room,
|
||||
room_join=True,
|
||||
room_admin=True,
|
||||
room_record=True,
|
||||
can_update_own_metadata=True,
|
||||
can_publish_sources=[
|
||||
"camera",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -258,6 +258,7 @@ class Base(Configuration):
|
||||
EMAIL_HOST_PASSWORD = values.Value(None)
|
||||
EMAIL_PORT = values.PositiveIntegerValue(None)
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
@@ -371,6 +372,9 @@ class Base(Configuration):
|
||||
ANALYTICS_KEY = values.Value(
|
||||
None, environ_name="ANALYTICS_KEY", environ_prefix=None
|
||||
)
|
||||
WORKER_SECRET = values.Value(
|
||||
None, environ_name="WORKER_SECRET", environ_prefix=None
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { fetchServerApi } from './fetchServerApi'
|
||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useParams } from 'wouter'
|
||||
|
||||
export const useRecordRoom = () => {
|
||||
const data = useRoomData()
|
||||
const { roomId: roomSlug } = useParams()
|
||||
|
||||
const recordRoom = () => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
if (!roomSlug) {
|
||||
throw new Error('Room ID is not available')
|
||||
}
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(
|
||||
data.livekit.url,
|
||||
'/twirp/livekit.Egress/StartRoomCompositeEgress'
|
||||
),
|
||||
data.livekit.token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
room_name: data.livekit.room,
|
||||
audio_only: true,
|
||||
file_outputs: [
|
||||
{
|
||||
file_extension: 'ogg',
|
||||
filepath: `{room_name}_{time}_${roomSlug}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const stopRecordingRoom = (egressId: string) => {
|
||||
if (!data || !data?.livekit) {
|
||||
throw new Error('Room data is not available')
|
||||
}
|
||||
return fetchServerApi(
|
||||
buildServerApiUrl(data.livekit.url, '/twirp/livekit.Egress/StopEgress'),
|
||||
data.livekit.token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
egressId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return { recordRoom, stopRecordingRoom }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { egressStore } from '@/stores/egress.tsx'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export const RecordingIndicator = () => {
|
||||
const room = useRoomContext()
|
||||
const [isRecording, setIsRecording] = useState(room.isRecording)
|
||||
|
||||
const egressSnap = useSnapshot(egressStore)
|
||||
const egressIsStopping = egressSnap.egressIsStopping
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanges = (isRecording: boolean) => {
|
||||
if (!isRecording) {
|
||||
egressStore.egressIsStopping = false
|
||||
}
|
||||
|
||||
setIsRecording(isRecording)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanges)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
const getStatus = () => {
|
||||
if (egressIsStopping) {
|
||||
return 'saving recording'
|
||||
}
|
||||
if (isRecording) {
|
||||
return 'recording'
|
||||
}
|
||||
if (!isRecording) {
|
||||
return 'available'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Room status: {getStatus()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+2
@@ -8,6 +8,7 @@ import { MenuItem, Menu as RACMenu } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import { DialogState } from '@/features/rooms/livekit/components/controls/Options/OptionsButton'
|
||||
import { RecordingMenuItem } from './RecordingMenuItem.tsx'
|
||||
|
||||
// @todo try refactoring it to use MenuList component
|
||||
export const OptionsMenuItems = ({
|
||||
@@ -47,6 +48,7 @@ export const OptionsMenuItems = ({
|
||||
<RiSettings3Line size={18} />
|
||||
{t('options.items.settings')}
|
||||
</MenuItem>
|
||||
<RecordingMenuItem />
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { menuItemRecipe } from '@/primitives/menuItemRecipe.ts'
|
||||
import { RiPauseCircleLine, RiRecordCircleLine } from '@remixicon/react'
|
||||
import { useRecordRoom } from '@/features/rooms/livekit/api/recordRoom.ts'
|
||||
import { useState } from 'react'
|
||||
import { egressStore } from '@/stores/egress.tsx'
|
||||
import { useSnapshot } from 'valtio'
|
||||
|
||||
export const RecordingMenuItem = () => {
|
||||
const { recordRoom, stopRecordingRoom } = useRecordRoom()
|
||||
|
||||
const egressSnap = useSnapshot(egressStore)
|
||||
const egressId = egressSnap.egressId
|
||||
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const handleAction = async () => {
|
||||
if (egressId) {
|
||||
setIsPending(true)
|
||||
egressStore.egressIsStopping = true
|
||||
const response = await stopRecordingRoom(egressId)
|
||||
console.log(response)
|
||||
egressStore.egressId = undefined
|
||||
setIsPending(false)
|
||||
} else {
|
||||
setIsPending(true)
|
||||
const response = await recordRoom()
|
||||
egressStore.egressId = response['egress_id'] as string
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
isDisabled={isPending}
|
||||
className={menuItemRecipe({ icon: true })}
|
||||
onAction={handleAction}
|
||||
>
|
||||
{egressId ? (
|
||||
<>
|
||||
<RiPauseCircleLine size={18} />
|
||||
Stop recording room
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiRecordCircleLine size={18} />
|
||||
Record room
|
||||
</>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { participantsStore } from '@/stores/participants'
|
||||
import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||
import { RecordingIndicator } from '@/features/rooms/livekit/components/RecordingIndicator.tsx'
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -184,6 +185,7 @@ export function VideoConference({
|
||||
onWidgetChange={widgetUpdate}
|
||||
>
|
||||
<div className="lk-video-conference-inner">
|
||||
<RecordingIndicator />
|
||||
<LayoutWrapper>
|
||||
<div
|
||||
style={{ display: 'flex', position: 'relative', width: '100%' }}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
egressId: string | undefined
|
||||
egressIsStopping: boolean
|
||||
}
|
||||
|
||||
export const egressStore = proxy<State>({
|
||||
egressId: undefined,
|
||||
egressIsStopping: false,
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "main"
|
||||
tag: "v-hackathon"
|
||||
|
||||
backend:
|
||||
migrateJobAnnotations:
|
||||
@@ -92,6 +92,10 @@ backend:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: LIVEKIT_API_KEY
|
||||
WORKER_SECRET:
|
||||
secretKeyRef:
|
||||
name: backend
|
||||
key: WORKER_SECRET
|
||||
LIVEKIT_API_URL: https://livekit-staging.beta.numerique.gouv.fr
|
||||
ANALYTICS_KEY: Roi1k6IAc2DEqHB0
|
||||
ALLOW_UNREGISTERED_ROOMS: False
|
||||
@@ -108,7 +112,7 @@ frontend:
|
||||
image:
|
||||
repository: lasuite/meet-frontend
|
||||
pullPolicy: Always
|
||||
tag: "main"
|
||||
tag: "v-hackathon"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
|
||||
@@ -11,3 +11,6 @@ stringData:
|
||||
OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }}
|
||||
LIVEKIT_API_SECRET: {{ .Values.livekitApi.secret }}
|
||||
LIVEKIT_API_KEY: {{ .Values.livekitApi.key }}
|
||||
{{- if .Values.workerSecret }}
|
||||
WORKER_SECRET: {{ .Values.workerSecret }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<mjml>
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
|
||||
<mj-section mj-class="bg--white-100" padding="30px 20px 60px 20px">
|
||||
<mj-column>
|
||||
<mj-text font-size="14px">
|
||||
<p>Dear user,</p>
|
||||
<p>The meeting <strong>{{room}}</strong> has been successfully transcribed and summarized.</p>
|
||||
</mj-text>
|
||||
<mj-button href="{{link}}" font-size="14px" background-color="#346DB7" color="white">
|
||||
Get your summary
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
<mj-include path="./partial/footer.mjml" />
|
||||
</mjml>
|
||||
Reference in New Issue
Block a user