diff --git a/secrets b/secrets index 8ef9f451..2ef26100 160000 --- a/secrets +++ b/secrets @@ -1 +1 @@ -Subproject commit 8ef9f4513a63e313fdadd0a06c6f85091dad1013 +Subproject commit 2ef26100715e0cc7093d44aaed54ed5a9c68cb13 diff --git a/src/backend/core/api/demo.py b/src/backend/core/api/demo.py new file mode 100644 index 00000000..dc14f6cb --- /dev/null +++ b/src/backend/core/api/demo.py @@ -0,0 +1,316 @@ + +from rest_framework.decorators import api_view +from rest_framework.response import Response +from minio import Minio +from django.conf import settings +import openai +import logging +from ..models import Room, RoleChoices + +import tempfile +import os +import smtplib +import requests + + +logger = logging.getLogger(__name__) + + +def get_prompt(transcript, date): + return f""" + + Audience: Coworkers. + + **Do:** + - Detect the language of the transcript and provide your entire response in the same language. + - If any part of the transcript is unclear or lacks detail, politely inform the user, specifying which areas need further clarification. + - Ensure the accuracy of all information and refrain from adding unverified details. + - Format the response using proper markdown and structured sections. + - Be concise and avoid repeating yourself between the sections. + - Be super precise on nickname + - Be a nit-picker + - Auto-evaluate your response + + **Don't:** + - Write something your are not sure. + - Write something that is not mention in the transcript. + - Don't make mistake while mentioning someone + + **Task:** + Summarize the provided meeting transcript into clear and well-organized meeting minutes. The summary should be structured into the following sections, excluding irrelevant or inapplicable details: + + 1. **Summary**: Write a TL;DR of the meeting. + 2. **Subjects Discussed**: List the key points or issues in bullet points. + 4. **Next Steps**: Provide action items as bullet points, assigning each task to a responsible individual and including deadlines (if mentioned). Format action items as tickable checkboxes. Ensure every action is assigned and, if a deadline is provided, that it is clearly stated. + + **Transcript**: + {transcript} + + **Response:** + + ### Summary [Translate this title based on the transcript’s language] + [Provide a brief overview of the key points discussed] + + ### Subjects Discussed [Translate this title based on the transcript’s language] + - [Summarize each topic concisely] + + ### Next Steps [Translate this title based on the transcript’s language] + - [ ] Action item [Assign to the responsible individual(s) and include a deadline if applicable, follow this strict format: Action - List of owner(s), deadline.] + + """ + +def get_room_and_owners(slug): + """Wip.""" + + try: + room = Room.objects.get(slug=slug) + owner_accesses = room.accesses.filter(role=RoleChoices.OWNER) + owners = [access.user for access in owner_accesses] + + logger.info("Room %s has owners: %s", slug, owners) + + except Room.DoesNotExist: + logger.error("Room with slug %s does not exist", slug) + + owners = None + room = None + + return room, owners + + +def remove_temporary_file(path): + """Wip.""" + + if not path or not os.path.exists(path): + return + + os.remove(path) + logger.info("Temporary file %s has been deleted.", path) + +def get_blocknote_content(summary): + """Wip.""" + + if not settings.BLOCKNOTE_CONVERTER_URL: + logger.error("BLOCKNOTE_CONVERTER_URL is not configured") + return None + + headers = { + "Content-Type": "application/json" + } + + data = { + "markdown": summary + } + + logger.info("Converting summary in BlockNote.js…") + response = requests.post(settings.BLOCKNOTE_CONVERTER_URL, headers=headers, json=data) + + if response.status_code != 200: + logger.error(f"Failed to convert summary. Status code: {response.status_code}") + return None + + response_data = response.json() + if not 'content' in response_data: + logger.error(f"Content is missing: %s", response_data) + return None + + content = response_data['content'] + logger.info("Base64 content: %s", content) + + return content + + + +def get_document_link(content, email): + """Wip.""" + + logger.info("Create a document for %s", email) + + if not settings.DOCS_BASE_URL: + logger.error("DOCS_BASE_URL is not configured") + return None + + headers = { + "Content-Type": "application/json" + } + + data = { + "content": content, + "owner": email + } + + logger.info("Querying docs…") + response = requests.post(f"{settings.DOCS_BASE_URL}/api/v1.0/summary/", headers=headers, json=data) + + if response.status_code != 200: + logger.error(f"Failed to get document's id. Status code: {response.status_code}") + return None + + response_data = response.json() + if not 'id' in response_data: + logger.error(f"ID is missing: %s", response_data) + return None + + id = response_data['id'] + logger.info("Document's id: %s", id) + + return f"{settings.DOCS_BASE_URL}/docs/{id}/" + + +def email_owner_with_summary(room, link, owner): + """Wip.""" + + logger.info("Emailing owner: %s", owner) + + try: + room.email_summary(owners=[owner], link=link) + except smtplib.SMTPException: + logger.error("Error while emailing owner") + +def strip_room_slug(filename): + """Wip.""" + return filename.split("_")[2].split(".")[0] + +def strip_room_date(filename): + """Wip.""" + return filename.split("_")[1].split(".")[0] + + +def get_minio_client(): + """Wip.""" + + try: + return Minio( + settings.MINIO_URL, + access_key=settings.MINIO_ACCESS_KEY, + secret_key=settings.MINIO_SECRET_KEY, + ) + except Exception as e: + logger.error("An error occurred while creating the Minio client %s: %s", settings.MINIO_URL, str(e)) + + +def download_temporary_file(minio_client, filename): + """Wip.""" + + temp_file_path = None + + logger.info('downloading file %s', filename) + + try: + audio_file_stream = minio_client.get_object(settings.MINIO_BUCKET, object_name=filename) + + with tempfile.NamedTemporaryFile(delete=False, suffix='.ogg') as temp_audio_file: + + for data in audio_file_stream.stream(32 * 1024): + temp_audio_file.write(data) + + temp_file_path = temp_audio_file.name + logger.info('Temporary file created at %s', temp_file_path) + + audio_file_stream.close() + audio_file_stream.release_conn() + + except Exception as e: + logger.error("An error occurred while accessing the object: %s", str(e)) + + return temp_file_path + + +# todo - discuss retry policy if the webhook fail +@api_view(["POST"]) +def minio_webhook(request): + + data = request.data + + logger.info('Minio webhook sent %s', data) + + record = data["Records"][0] + s3 = record['s3'] + bucket = s3['bucket'] + bucket_name = bucket['name'] + object = s3['object'] + filename = object['key'] + + if bucket_name != settings.MINIO_BUCKET: + logger.info('Not interested in this bucket: %s', bucket_name) + return Response("Not interested in this bucket") + + if object['contentType'] != 'audio/ogg': + logger.info('Not interested in this file type: %s', object['contentType']) + return Response("Not interested in this file type") + + room_slug = strip_room_slug(filename) + room_date = strip_room_date(filename) + logger.info('file received %s for room %s', filename, room_slug) + + minio_client = get_minio_client() + + temp_file_path = None + summary = None + + try: + temp_file_path = download_temporary_file(minio_client, filename) + + if settings.OPENAI_ENABLE and temp_file_path: + logger.info('Initiating OpenAI client …') + openai_client = openai.OpenAI( + api_key=settings.OPENAI_API_KEY, + ) + + with open(temp_file_path, "rb") as audio_file: + logger.info('Querying transcription …') + transcript = openai_client.audio.transcriptions.create( + model="whisper-1", + file=audio_file + ) + + logger.info('Transcript: \n %s', transcript) + prompt = get_prompt(transcript.text, room_date) + + logger.info('Prompt: \n %s', prompt) + + summary_response = openai_client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": "You are a concise and structured assistant, that summarizes meeting transcripts."}, + {"role": "user", "content": prompt} + ], + ) + + summary = summary_response.choices[0].message.content + logger.info('Summary: \n %s', summary) + + except Exception as e: + logger.error("An error occurred: %s", str(e)) + raise + + finally: + remove_temporary_file(temp_file_path) + + if not summary: + logger.error("Empty summary.") + return Response("") + + room, owners = get_room_and_owners(room_slug) + + if not owners or not room: + logger.error("No owners in room %s", room_slug) + return Response("") + + content = get_blocknote_content(summary) + + if not content: + logger.error("Empty content.") + return Response("") + + owner = owners[0] + + link = get_document_link(content, owner.email) + + if not link: + logger.error("Empty link.") + return Response("") + + email_owner_with_summary(room, link, owner) + + return Response("") diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f2d4e77b..7b39f40a 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -15,6 +15,8 @@ from django.utils.text import capfirst, slugify from django.utils.translation import gettext_lazy as _ +from django.template.loader import render_to_string + from timezone_field import TimeZoneField logger = getLogger(__name__) @@ -325,3 +327,24 @@ def clean_fields(self, exclude=None): else: raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'}) super().clean_fields(exclude=exclude) + + + def email_summary(self, owners, link): + """Wip""" + + template_vars = { + "title": "Votre résumé est prêt", + "link": link, + "room": self.slug, + } + msg_html = render_to_string("mail/html/summary.html", template_vars) + msg_plain = render_to_string("mail/text/invitation.txt", template_vars) + + for owner in owners: + owner.email_user( + subject="Votre résumé est prêt", + from_email=settings.EMAIL_FROM, + message=msg_plain, + html_message=msg_html, + fail_silently=False, + ) diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index f7f0c5a9..2786f58b 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -5,7 +5,7 @@ from rest_framework.routers import DefaultRouter -from core.api import get_frontend_configuration, viewsets +from core.api import get_frontend_configuration, viewsets, demo from core.authentication.urls import urlpatterns as oidc_urls # - Main endpoints @@ -24,6 +24,7 @@ *router.urls, *oidc_urls, path("config/", get_frontend_configuration, name="config"), + path("minio-webhook/", demo.minio_webhook, name="demo"), ] ), ), diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 304b5de7..56ac4b0d 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -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", diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index bf2deaa5..03b3b2e8 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -386,6 +386,53 @@ class Base(Configuration): None, environ_name="ANALYTICS_KEY", environ_prefix=None ) + # todo - totally wip + AWS_S3_ENDPOINT_URL = values.Value( + environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None + ) + AWS_S3_ACCESS_KEY_ID = values.Value( + environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None + ) + AWS_S3_SECRET_ACCESS_KEY = values.Value( + environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None + ) + AWS_S3_REGION_NAME = values.Value( + environ_name="AWS_S3_REGION_NAME", environ_prefix=None + ) + AWS_STORAGE_BUCKET_NAME = values.Value( + "meet-media-storage", + environ_name="AWS_STORAGE_BUCKET_NAME", + environ_prefix=None, + ) + + OPENAI_API_KEY = values.Value( + None, environ_name="OPENAI_API_KEY", environ_prefix=None + ) + OPENAI_ENABLE = values.BooleanValue( + True, environ_name="OPENAI_ENABLE", environ_prefix=None + ) + + # todo - totally wip + MINIO_ACCESS_KEY = values.Value( + None, environ_name="MINIO_ACCESS_KEY", environ_prefix=None + ) + MINIO_SECRET_KEY = values.Value( + None, environ_name="MINIO_SECRET_KEY", environ_prefix=None + ) + MINIO_URL = values.Value( + None, environ_name="MINIO_URL", environ_prefix=None + ) + MINIO_BUCKET = values.Value( + 'livekit-staging-livekit-egress', environ_name="MINIO_BUCKET", environ_prefix=None + ) + + BLOCKNOTE_CONVERTER_URL = values.Value( + 'https://converter-blocknote.osc-fr1.scalingo.io/', environ_name="", environ_prefix=None + ) + DOCS_BASE_URL = values.Value( + 'https://docs-ia.beta.numerique.gouv.fr', environ_name="", environ_prefix=None + ) + # pylint: disable=invalid-name @property def ENVIRONMENT(self): @@ -529,6 +576,20 @@ class Production(Base): ALLOWED_HOSTS=["foo.com", "foo.fr"] """ + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, + } + # Security ALLOWED_HOSTS = [ *values.ListValue([], environ_name="ALLOWED_HOSTS"), diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index c71c5e78..9a78884e 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -58,6 +58,8 @@ dependencies = [ "whitenoise==6.7.0", "mozilla-django-oidc==4.0.1", "livekit-api==0.7.0", + "minio==7.2.9", + "openai==1.51.2" ] [project.urls] diff --git a/src/frontend/src/features/rooms/components/RecordingIndicator.tsx b/src/frontend/src/features/rooms/components/RecordingIndicator.tsx new file mode 100644 index 00000000..675ef7bc --- /dev/null +++ b/src/frontend/src/features/rooms/components/RecordingIndicator.tsx @@ -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.ts' +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 ( +
+ Room status: {getStatus()} +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/api/recordRoom.ts b/src/frontend/src/features/rooms/livekit/api/recordRoom.ts new file mode 100644 index 00000000..c463c5d0 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/api/recordRoom.ts @@ -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 } +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsMenuItems.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsMenuItems.tsx index d84e4cb6..e7dc868c 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsMenuItems.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Options/OptionsMenuItems.tsx @@ -10,6 +10,7 @@ import { Dispatch, SetStateAction } from 'react' import { DialogState } from './OptionsButton' import { Separator } from '@/primitives/Separator' import { useSidePanel } from '../../../hooks/useSidePanel' +import { RecordingMenuItem } from '@/features/rooms/livekit/components/controls/Options/RecordingMenuItem' // @todo try refactoring it to use MenuList component export const OptionsMenuItems = ({ @@ -34,6 +35,7 @@ export const OptionsMenuItems = ({ {t('effects')} +
diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Options/RecordingMenuItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Options/RecordingMenuItem.tsx new file mode 100644 index 00000000..935e8e8a --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Options/RecordingMenuItem.tsx @@ -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.ts' +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 ( + + {egressId ? ( + <> + + Arrêter l'enregistrement + + ) : ( + <> + + Enregistrer la réunion + + )} + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index ff81b297..c7b203dc 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -28,6 +28,7 @@ import { FocusLayout } from '../components/FocusLayout' import { ParticipantTile } from '../components/ParticipantTile' import { SidePanel } from '../components/SidePanel' import { useSidePanel } from '../hooks/useSidePanel' +import { RecordingIndicator } from '@/features/rooms/components/RecordingIndicator.tsx' const LayoutWrapper = styled( 'div', @@ -165,6 +166,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) { transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms', }} > +
({ + egressId: undefined, + egressIsStopping: false, +}) diff --git a/src/helm/env.d/production/values.meet.yaml.gotmpl b/src/helm/env.d/production/values.meet.yaml.gotmpl index 8844ddbd..75b9b198 100644 --- a/src/helm/env.d/production/values.meet.yaml.gotmpl +++ b/src/helm/env.d/production/values.meet.yaml.gotmpl @@ -99,6 +99,23 @@ backend: FRONTEND_SILENCE_LIVEKIT_DEBUG: False FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio.numerique.gouv.fr'}" FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}" + AWS_S3_ENDPOINT_URL: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: url + AWS_S3_ACCESS_KEY_ID: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: accessKey + AWS_S3_SECRET_ACCESS_KEY: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: secretKey + AWS_STORAGE_BUCKET_NAME: + secretKeyRef: + name: impress-media-storage.bucket.libre.sh + key: bucket + AWS_S3_REGION_NAME: local createsuperuser: command: diff --git a/src/helm/env.d/staging/values.meet.yaml.gotmpl b/src/helm/env.d/staging/values.meet.yaml.gotmpl index fa090c39..23432335 100644 --- a/src/helm/env.d/staging/values.meet.yaml.gotmpl +++ b/src/helm/env.d/staging/values.meet.yaml.gotmpl @@ -1,7 +1,7 @@ image: repository: lasuite/meet-backend pullPolicy: Always - tag: "main" + tag: "v-hackathon" backend: migrateJobAnnotations: @@ -97,6 +97,41 @@ backend: ALLOW_UNREGISTERED_ROOMS: False FRONTEND_ANALYTICS: "{'id': 'phc_RPYko028Oqtj0c9exLIWwrlrjLxSdxT0ntW0Lam4iom', 'host': 'https://product.visio-staging.beta.numerique.gouv.fr'}" FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041'}" + AWS_S3_ENDPOINT_URL: + secretKeyRef: + name: meet-media-storage.bucket.libre.sh + key: url + AWS_S3_ACCESS_KEY_ID: + secretKeyRef: + name: meet-media-storage.bucket.libre.sh + key: accessKey + AWS_S3_SECRET_ACCESS_KEY: + secretKeyRef: + name: meet-media-storage.bucket.libre.sh + key: secretKey + AWS_STORAGE_BUCKET_NAME: + secretKeyRef: + name: meet-media-storage.bucket.libre.sh + key: bucket + AWS_S3_REGION_NAME: local + OPENAI_API_KEY: + secretKeyRef: + name: backend + key: OPENAI_API_KEY + OPENAI_ENABLE: True + MINIO_ACCESS_KEY: + secretKeyRef: + name: backend + key: MINIO_ACCESS_KEY + MINIO_SECRET_KEY: + secretKeyRef: + name: backend + key: MINIO_SECRET_KEY + MINIO_URL: + secretKeyRef: + name: backend + key: MINIO_URL + createsuperuser: command: @@ -110,7 +145,7 @@ frontend: image: repository: lasuite/meet-frontend pullPolicy: Always - tag: "main" + tag: "v-hackathon" ingress: enabled: true diff --git a/src/helm/extra/templates/s3.yml b/src/helm/extra/templates/s3.yml new file mode 100644 index 00000000..4ca831ea --- /dev/null +++ b/src/helm/extra/templates/s3.yml @@ -0,0 +1,8 @@ +apiVersion: core.libre.sh/v1alpha1 +kind: Bucket +metadata: + name: meet-media-storage + namespace: {{ .Release.Namespace | quote }} +spec: + provider: data + versioned: true \ No newline at end of file diff --git a/src/helm/meet/templates/secrets.yaml b/src/helm/meet/templates/secrets.yaml index 14cf213d..adae816f 100644 --- a/src/helm/meet/templates/secrets.yaml +++ b/src/helm/meet/templates/secrets.yaml @@ -15,3 +15,16 @@ stringData: OIDC_RP_CLIENT_SECRET: {{ .Values.oidc.clientSecret }} LIVEKIT_API_SECRET: {{ .Values.livekitApi.secret }} LIVEKIT_API_KEY: {{ .Values.livekitApi.key }} +{{- if .Values.openaiApiKey }} + OPENAI_API_KEY: {{ .Values.openaiApiKey }} +{{- end }} +{{- if .Values.minioAccessKey }} + MINIO_ACCESS_KEY: {{ .Values.minioAccessKey }} +{{- end }} +{{- if .Values.minioSecretKey }} + MINIO_SECRET_KEY: {{ .Values.minioSecretKey }} +{{- end }} +{{- if .Values.minioUrl }} + MINIO_URL: {{ .Values.minioUrl }} +{{- end }} + diff --git a/src/mail/mjml/summary.mjml b/src/mail/mjml/summary.mjml new file mode 100644 index 00000000..2e9d38a7 --- /dev/null +++ b/src/mail/mjml/summary.mjml @@ -0,0 +1,19 @@ + + + + + + + +

Cher utilisateur,

+

La réunion {{room}} a été transcrite et résumée avec succès.

+
+ + Obtenez votre résumé + +
+
+
+
+ +