From b9ac22d4ed2d44a7c7f97f733dcf9569d5511667 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 17 Apr 2024 12:09:11 -0300 Subject: [PATCH] feat: AI Transcribe (#14140) * chore: transcribe * chore: provider * chore: use daily provider * chore: rebase * chore: remove types file * chore: remove recoil * chore: progress * chore: add recoil * chore: save progress * chore: save progress * fix: type err * fix: err * fix: save progress * feat: finish emails * chore: add new env variable * chore: fix type err * chore: turbo * chore: improvements --------- Co-authored-by: Udit Takkar Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Peer Richelsen Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> --- .env.appStore.example | 1 + apps/web/modules/videos/ai/ai-transcribe.tsx | 87 +++++++++ .../videos/views/videos-single-view.tsx | 72 ++++--- apps/web/package.json | 4 +- apps/web/pages/api/email.ts | 1 + apps/web/pages/api/recorded-daily-video.ts | 4 +- .../pages/api/send-daily-video-transcript.ts | 182 ++++++++++++++++++ apps/web/public/sparkles.svg | 1 + apps/web/public/static/locales/en/common.json | 7 + package.json | 2 + .../dailyvideo/lib/VideoApiAdapter.ts | 97 ++++++++-- packages/core/videoClient.ts | 24 +++ packages/emails/email-manager.ts | 15 ++ .../DailyVideoDownloadTranscriptEmail.tsx | 103 ++++++++++ packages/emails/src/templates/index.ts | 1 + ...e-daily-video-download-transcript-email.ts | 71 +++++++ ...r-daily-video-download-transcript-email.ts | 66 +++++++ packages/types/VideoApiAdapter.d.ts | 2 + turbo.json | 1 + yarn.lock | 173 +++++++++++++++-- 20 files changed, 859 insertions(+), 55 deletions(-) create mode 100644 apps/web/modules/videos/ai/ai-transcribe.tsx create mode 100644 apps/web/pages/api/send-daily-video-transcript.ts create mode 100644 apps/web/public/sparkles.svg create mode 100644 packages/emails/src/templates/DailyVideoDownloadTranscriptEmail.tsx create mode 100644 packages/emails/templates/attendee-daily-video-download-transcript-email.ts create mode 100644 packages/emails/templates/organizer-daily-video-download-transcript-email.ts diff --git a/.env.appStore.example b/.env.appStore.example index 15f9fa42fb..b799fc707e 100644 --- a/.env.appStore.example +++ b/.env.appStore.example @@ -38,6 +38,7 @@ BASECAMP3_USER_AGENT= DAILY_API_KEY= DAILY_SCALE_PLAN='' DAILY_WEBHOOK_SECRET='' +DAILY_MEETING_ENDED_WEBHOOK_SECRET='' # - GOOGLE CALENDAR/MEET/LOGIN # Needed to enable Google Calendar integration and Login with Google diff --git a/apps/web/modules/videos/ai/ai-transcribe.tsx b/apps/web/modules/videos/ai/ai-transcribe.tsx new file mode 100644 index 0000000000..b30b8c2a4d --- /dev/null +++ b/apps/web/modules/videos/ai/ai-transcribe.tsx @@ -0,0 +1,87 @@ +import { useTranscription } from "@daily-co/daily-react"; +import { useDaily, useDailyEvent } from "@daily-co/daily-react"; +import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react"; +import { Toaster } from "react-hot-toast"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { showToast } from "@calcom/ui"; + +export const CalAiTransctibe = () => { + const daily = useDaily(); + const { t } = useLocale(); + + const [transcript, setTranscript] = useState(""); + + const [transcriptHeight, setTranscriptHeight] = useState(0); + const transcriptRef = useRef(null); + + const transcription = useTranscription(); + + useDailyEvent( + "app-message", + useCallback((ev) => { + const data = ev?.data; + if (data.user_name && data.text) setTranscript(`${data.user_name}: ${data.text}`); + }, []) + ); + + useDailyEvent("transcription-started", (ev) => { + showToast(t("transcription_enabled"), "success"); + }); + + useDailyEvent("transcription-stopped", (ev) => { + showToast(t("transcription_stopped"), "success"); + }); + + useDailyEvent("custom-button-click", (ev) => { + if (ev?.button_id !== "transcription") { + return; + } + + if (transcription?.isTranscribing) { + daily?.stopTranscription(); + } else { + daily?.startTranscription(); + } + }); + + useLayoutEffect(() => { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setTranscriptHeight(entry.target.scrollHeight); + } + }); + + if (transcriptRef.current) { + observer.observe(transcriptRef.current); + } + + return () => observer.disconnect(); + }, []); + + useEffect(() => { + transcriptRef.current?.scrollTo({ + top: transcriptRef.current?.scrollHeight, + behavior: "smooth", + }); + }, [transcriptHeight]); + + return ( + <> + +
+ {transcript + ? transcript.split("\n").map((line, i) => ( + + {i > 0 &&
} + {line} +
+ )) + : ""} +
+ + ); +}; diff --git a/apps/web/modules/videos/views/videos-single-view.tsx b/apps/web/modules/videos/views/videos-single-view.tsx index 7a8d74897b..7a7ff1a972 100644 --- a/apps/web/modules/videos/views/videos-single-view.tsx +++ b/apps/web/modules/videos/views/videos-single-view.tsx @@ -1,22 +1,27 @@ "use client"; +import type { DailyCall } from "@daily-co/daily-js"; import DailyIframe from "@daily-co/daily-js"; +import { DailyProvider } from "@daily-co/daily-react"; import Head from "next/head"; import { useState, useEffect, useRef } from "react"; import dayjs from "@calcom/dayjs"; import classNames from "@calcom/lib/classNames"; -import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants"; +import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants"; import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML"; import { Icon } from "@calcom/ui"; +import { CalAiTransctibe } from "~/videos/ai/ai-transcribe"; + import { type PageProps } from "./videos-single-view.getServerSideProps"; export default function JoinCall(props: PageProps) { const { t } = useLocale(); const { meetingUrl, meetingPassword, booking } = props; + const [daily, setDaily] = useState(null); useEffect(() => { const callFrame = DailyIframe.createFrame({ @@ -42,8 +47,20 @@ export default function JoinCall(props: PageProps) { }, url: meetingUrl, ...(typeof meetingPassword === "string" && { token: meetingPassword }), + customTrayButtons: { + transcription: { + label: "Enable Transcription", + tooltip: "Toggle Transcription", + iconPath: `${WEBAPP_URL}/sparkles.svg`, + iconPathDarkMode: `${WEBAPP_URL}/sparkles.svg`, + }, + }, }); + + setDaily(callFrame); + callFrame.join(); + return () => { callFrame.destroy(); }; @@ -67,30 +84,35 @@ export default function JoinCall(props: PageProps) { -
- {booking?.user?.organization?.calVideoLogo ? ( - My Org Logo - ) : ( - Logo - )} -
- + +
+ +
+
+ {booking?.user?.organization?.calVideoLogo ? ( + My Org Logo + ) : ( + Logo + )} +
+ +
); } diff --git a/apps/web/package.json b/apps/web/package.json index a6e03b66bd..f65ad90e7c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,7 +38,8 @@ "@calcom/trpc": "*", "@calcom/tsconfig": "*", "@calcom/ui": "*", - "@daily-co/daily-js": "^0.37.0", + "@daily-co/daily-js": "^0.59.0", + "@daily-co/daily-react": "^0.17.2", "@formkit/auto-animate": "1.0.0-beta.5", "@glidejs/glide": "^3.5.2", "@hookform/error-message": "^2.0.0", @@ -121,6 +122,7 @@ "react-timezone-select": "^1.4.0", "react-turnstile": "^1.1.3", "react-use-intercom": "1.5.1", + "recoil": "^0.7.7", "remove-markdown": "^0.5.0", "rrule": "^2.7.1", "sanitize-html": "^2.10.0", diff --git a/apps/web/pages/api/email.ts b/apps/web/pages/api/email.ts index 9ca6337d32..17555b4cee 100644 --- a/apps/web/pages/api/email.ts +++ b/apps/web/pages/api/email.ts @@ -12,6 +12,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { res.setHeader("Content-Type", "text/html"); res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate"); + res.write( await renderEmail("MonthlyDigestEmail", { language: t, diff --git a/apps/web/pages/api/recorded-daily-video.ts b/apps/web/pages/api/recorded-daily-video.ts index 9c63ee7cc5..c1f4a980ee 100644 --- a/apps/web/pages/api/recorded-daily-video.ts +++ b/apps/web/pages/api/recorded-daily-video.ts @@ -15,7 +15,7 @@ import { getTranslation } from "@calcom/lib/server/i18n"; import prisma, { bookingMinimalSelect } from "@calcom/prisma"; import type { CalendarEvent } from "@calcom/types/Calendar"; -const log = logger.getSubLogger({ prefix: ["recorded-daily-video"] }); +const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] }); const schema = z .object({ @@ -116,7 +116,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const response = schema.safeParse(req.body); log.debug( - "Recording Request Body:", + "Daily video recording webhook Request Body:", safeStringify({ response, }) diff --git a/apps/web/pages/api/send-daily-video-transcript.ts b/apps/web/pages/api/send-daily-video-transcript.ts new file mode 100644 index 0000000000..dbb296a34c --- /dev/null +++ b/apps/web/pages/api/send-daily-video-transcript.ts @@ -0,0 +1,182 @@ +import { createHmac } from "crypto"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { z } from "zod"; + +import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient"; +import { sendDailyVideoTranscriptEmails } from "@calcom/emails"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { defaultHandler } from "@calcom/lib/server"; +import { getTranslation } from "@calcom/lib/server/i18n"; +import prisma, { bookingMinimalSelect } from "@calcom/prisma"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +const testRequestSchema = z.object({ + test: z.enum(["test"]), +}); + +const log = logger.getSubLogger({ prefix: ["send-daily-video-transcript-handler"] }); + +const schema = z + .object({ + version: z.string(), + type: z.string(), + id: z.string(), + payload: z + .object({ + meeting_id: z.string(), + end_ts: z.number().optional(), + room: z.string(), + start_ts: z.number().optional(), + }) + .passthrough(), + event_ts: z.number().optional(), + }) + .passthrough(); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (testRequestSchema.safeParse(req.body).success) { + return res.status(200).json({ message: "Test request successful" }); + } + + const hmacSecret = process.env.DAILY_MEETING_ENDED_WEBHOOK_SECRET; + if (!hmacSecret) { + return res.status(405).json({ message: "No Daily Webhook Secret" }); + } + + const signature = `${req.headers["x-webhook-timestamp"]}.${JSON.stringify(req.body)}`; + const base64DecodedSecret = Buffer.from(hmacSecret, "base64"); + const hmac = createHmac("sha256", base64DecodedSecret); + const computed_signature = hmac.update(signature).digest("base64"); + + if (req.headers["x-webhook-signature"] !== computed_signature) { + return res.status(403).json({ message: "Signature does not match" }); + } + + const response = schema.safeParse(req.body); + + log.debug( + "Daily video transcript webhook Request Body:", + safeStringify({ + response, + }) + ); + + if (!response.success || response.data.type !== "meeting.ended") { + return res.status(400).send({ + message: "Invalid Payload", + }); + } + + const { room, meeting_id } = response.data.payload; + + try { + const bookingReference = await prisma.bookingReference.findFirst({ + where: { type: "daily_video", uid: room, meetingId: room }, + select: { bookingId: true }, + }); + + if (!bookingReference || !bookingReference.bookingId) { + log.error( + "bookingReference Not found:", + safeStringify({ + bookingReference, + requestBody: req.body, + }) + ); + return res.status(404).send({ message: "Booking reference not found" }); + } + + const booking = await prisma.booking.findUniqueOrThrow({ + where: { + id: bookingReference.bookingId, + }, + select: { + ...bookingMinimalSelect, + uid: true, + location: true, + isRecorded: true, + eventTypeId: true, + eventType: { + select: { + teamId: true, + parentId: true, + }, + }, + user: { + select: { + id: true, + timeZone: true, + email: true, + name: true, + locale: true, + destinationCalendar: true, + }, + }, + }, + }); + + if (!booking) { + log.error( + "Booking Not Found:", + safeStringify({ + booking, + requestBody: req.body, + }) + ); + + return res.status(404).send({ + message: `Booking of room_name ${room} does not exist or does not contain daily video as location`, + }); + } + + const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room); + + if (!transcripts || !transcripts.length) + return res.status(200).json({ message: `No Transcripts found for room name ${room}` }); + + const t = await getTranslation(booking?.user?.locale ?? "en", "common"); + const attendeesListPromises = booking.attendees.map(async (attendee) => { + return { + id: attendee.id, + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: { + translate: await getTranslation(attendee.locale ?? "en", "common"), + locale: attendee.locale ?? "en", + }, + }; + }); + + const attendeesList = await Promise.all(attendeesListPromises); + + // Send emails + const evt: CalendarEvent = { + type: booking.title, + title: booking.title, + description: booking.description || undefined, + startTime: booking.startTime.toISOString(), + endTime: booking.endTime.toISOString(), + organizer: { + email: booking?.userPrimaryEmail || booking.user?.email || "Email-less", + name: booking.user?.name || "Nameless", + timeZone: booking.user?.timeZone || "Europe/London", + language: { translate: t, locale: booking?.user?.locale ?? "en" }, + }, + attendees: attendeesList, + uid: booking.uid, + }; + + await sendDailyVideoTranscriptEmails(evt, transcripts); + + return res.status(200).json({ message: "Success" }); + } catch (err) { + console.error("Error in /send-daily-video-transcript", err); + return res.status(500).json({ message: "something went wrong" }); + } +} + +export default defaultHandler({ + POST: Promise.resolve({ default: handler }), +}); diff --git a/apps/web/public/sparkles.svg b/apps/web/public/sparkles.svg new file mode 100644 index 0000000000..b235ca5415 --- /dev/null +++ b/apps/web/public/sparkles.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 22d94f0951..3eaff69976 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -19,6 +19,7 @@ "verify_email_banner_body": "Verify your email address to guarantee the best email and calendar deliverability", "verify_email_email_header": "Verify your email address", "verify_email_email_button": "Verify email", + "cal_ai_assistant":"Cal AI Assistant", "verify_email_change_description": "You have recently requested to change the email address you use to log into your {{appName}} account. Please click the button below to confirm your new email address.", "verify_email_change_success_toast": "Updated your email to {{email}}", "verify_email_change_failure_toast": "Failed to update email.", @@ -110,7 +111,9 @@ "event_still_awaiting_approval": "An event is still waiting for your approval", "booking_submitted_subject": "Booking Submitted: {{title}} at {{date}}", "download_recording_subject": "Download Recording: {{title}} at {{date}}", + "download_transcript_email_subject":"Download Transcript: {{title}} at {{date}}", "download_your_recording": "Download your recording", + "download_your_transcripts":"Download your Transcripts", "your_meeting_has_been_booked": "Your meeting has been booked", "event_type_has_been_rescheduled_on_time_date": "Your {{title}} has been rescheduled to {{date}}.", "event_has_been_rescheduled": "Updated - Your event has been rescheduled", @@ -1429,7 +1432,11 @@ "download_responses_description": "Download all responses to your form in CSV format.", "download": "Download", "download_recording": "Download Recording", + "transcription_enabled": "Transcriptions are enabled now", + "transcription_stopped": "Transcriptions are stopped now", + "download_transcript": "Download Transcript", "recording_from_your_recent_call": "A recording from your recent call on {{appName}} is ready for download", + "transcript_from_previous_call": "Transcript from your recent call on {{appName}} is ready to download. Links are valid only for 1 Hour", "link_valid_for_12_hrs":"Note: The download link is valid only for 12 hours. You can generate new download link by following instructions <1>here.", "create_your_first_form": "Create your first form", "create_your_first_form_description": "With Routing Forms you can ask qualifying questions and route to the correct person or event type.", diff --git a/package.json b/package.json index 27e7a5bf15..ccc3771f3c 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,8 @@ "vitest-mock-extended": "^1.1.3" }, "dependencies": { + "@daily-co/daily-js": "^0.59.0", + "city-timezones": "^1.2.1", "eslint": "^8.34.0", "turbo": "^1.10.1" }, diff --git a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts index 27356649df..5303e5fcdd 100644 --- a/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts +++ b/packages/app-store/dailyvideo/lib/VideoApiAdapter.ts @@ -30,9 +30,30 @@ const dailyReturnTypeSchema = z.object({ enable_chat: z.boolean(), enable_knocking: z.boolean(), enable_prejoin_ui: z.boolean(), + enable_transcription_storage: z.boolean(), }), }); +const getTranscripts = z.object({ + total_count: z.number(), + data: z.array( + z.object({ + transcriptId: z.string(), + domainId: z.string(), + roomId: z.string(), + mtgSessionId: z.string(), + duration: z.number(), + status: z.string(), + }) + ), +}); + +const getRooms = z + .object({ + id: z.string(), + }) + .passthrough(); + export interface DailyEventResult { id: string; name: string; @@ -86,6 +107,33 @@ function postToDailyAPI(endpoint: string, body: Record) { }); } +async function processTranscriptsInBatches(transcriptIds: Array) { + const batchSize = 5; // Batch size + const batches = []; // Array to hold batches of transcript IDs + + // Split transcript IDs into batches + for (let i = 0; i < transcriptIds.length; i += batchSize) { + batches.push(transcriptIds.slice(i, i + batchSize)); + } + + const allTranscriptsAccessLinks = []; // Array to hold all access links + + // Process each batch sequentially + for (const batch of batches) { + const batchPromises = batch.map((id) => + fetcher(`/transcript/${id}/access-link`) + .then(z.object({ link: z.string() }).parse) + .then((res) => res.link) + ); + + const accessLinks = await Promise.all(batchPromises); + + allTranscriptsAccessLinks.push(...accessLinks); + } + + return allTranscriptsAccessLinks; +} + const DailyVideoApiAdapter = (): VideoApiAdapter => { async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise { if (!event.uid) { @@ -120,19 +168,18 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { }, }, }); - if (scalePlan === "true" && !!hasTeamPlan === true) { - return { - privacy: "public", - properties: { - enable_prejoin_ui: true, - enable_knocking: true, - enable_screenshare: true, - enable_chat: true, - exp: exp, - enable_recording: "cloud", - }, - }; - } + + // Check if organizer has subscribed to Cal.ai + + const isCalAiSubscribed = await prisma.credential.findMany({ + where: { + userId: event.organizer.id, + type: "cal-ai_automation", + invalid: false, + paymentStatus: "active", + }, + }); + return { privacy: "public", properties: { @@ -141,6 +188,11 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { enable_screenshare: true, enable_chat: true, exp: exp, + enable_recording: scalePlan === "true" && !!hasTeamPlan === true ? "cloud" : undefined, + enable_transcription_storage: !!isCalAiSubscribed, + permissions: { + canAdmin: ["transcription"], + }, }, }; }; @@ -159,6 +211,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { exp: exp, enable_recording: "cloud", start_video_off: true, + enable_transcription_storage: true, }, }; @@ -210,6 +263,24 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => { throw new Error("Something went wrong! Unable to get recording access link"); } }, + getAllTranscriptsAccessLinkFromRoomName: async (roomName: string): Promise> => { + try { + const res = await fetcher(`/rooms/${roomName}`).then(getRooms.parse); + const roomId = res.id; + const allTranscripts = await fetcher(`/transcript?roomId=${roomId}`).then(getTranscripts.parse); + + const allTranscriptsIds = allTranscripts.data.map((transcript) => transcript.transcriptId); + + const allTranscriptsAccessLink = await processTranscriptsInBatches(allTranscriptsIds); + + const accessLinks = await Promise.all(allTranscriptsAccessLink); + + return Promise.resolve(accessLinks); + } catch (err) { + console.log("err", err); + throw new Error("Something went wrong! Unable to get transcription access link"); + } + }, }; }; diff --git a/packages/core/videoClient.ts b/packages/core/videoClient.ts index 854bf4a996..500005470d 100644 --- a/packages/core/videoClient.ts +++ b/packages/core/videoClient.ts @@ -274,6 +274,29 @@ const getDownloadLinkOfCalVideoByRecordingId = async (recordingId: string) => { return videoAdapter?.getRecordingDownloadLink?.(recordingId); }; +const getAllTranscriptsAccessLinkFromRoomName = async (roomName: string) => { + let dailyAppKeys: Awaited>; + try { + dailyAppKeys = await getDailyAppKeys(); + } catch (e) { + console.error("Error: Cal video provider is not installed."); + return; + } + const [videoAdapter] = await getVideoAdapters([ + { + id: 0, + appId: "daily-video", + type: "daily_video", + userId: null, + user: { email: "" }, + teamId: null, + key: dailyAppKeys, + invalid: false, + }, + ]); + return videoAdapter?.getAllTranscriptsAccessLinkFromRoomName?.(roomName); +}; + export { getBusyVideoTimes, createMeeting, @@ -281,4 +304,5 @@ export { deleteMeeting, getRecordingsOfCalVideoByRoomName, getDownloadLinkOfCalVideoByRecordingId, + getAllTranscriptsAccessLinkFromRoomName, }; diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 705155496d..6c75139bdf 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -17,6 +17,7 @@ import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment- import AttendeeCancelledEmail from "./templates/attendee-cancelled-email"; import AttendeeCancelledSeatEmail from "./templates/attendee-cancelled-seat-email"; import AttendeeDailyVideoDownloadRecordingEmail from "./templates/attendee-daily-video-download-recording-email"; +import AttendeeDailyVideoDownloadTranscriptEmail from "./templates/attendee-daily-video-download-transcript-email"; import AttendeeDeclinedEmail from "./templates/attendee-declined-email"; import AttendeeLocationChangeEmail from "./templates/attendee-location-change-email"; import AttendeeRequestEmail from "./templates/attendee-request-email"; @@ -46,6 +47,7 @@ import OrganizationEmailVerification from "./templates/organization-email-verifi import OrganizerAttendeeCancelledSeatEmail from "./templates/organizer-attendee-cancelled-seat-email"; import OrganizerCancelledEmail from "./templates/organizer-cancelled-email"; import OrganizerDailyVideoDownloadRecordingEmail from "./templates/organizer-daily-video-download-recording-email"; +import OrganizerDailyVideoDownloadTranscriptEmail from "./templates/organizer-daily-video-download-transcript-email"; import OrganizerLocationChangeEmail from "./templates/organizer-location-change-email"; import OrganizerPaymentRefundFailedEmail from "./templates/organizer-payment-refund-failed-email"; import OrganizerRequestEmail from "./templates/organizer-request-email"; @@ -481,6 +483,19 @@ export const sendDailyVideoRecordingEmails = async (calEvent: CalendarEvent, dow await Promise.all(emailsToSend); }; +export const sendDailyVideoTranscriptEmails = async (calEvent: CalendarEvent, transcripts: string[]) => { + const emailsToSend: Promise[] = []; + + emailsToSend.push(sendEmail(() => new OrganizerDailyVideoDownloadTranscriptEmail(calEvent, transcripts))); + + for (const attendee of calEvent.attendees) { + emailsToSend.push( + sendEmail(() => new AttendeeDailyVideoDownloadTranscriptEmail(calEvent, attendee, transcripts)) + ); + } + await Promise.all(emailsToSend); +}; + export const sendOrganizationEmailVerification = async (sendOrgInput: OrganizationEmailVerify) => { await sendEmail(() => new OrganizationEmailVerification(sendOrgInput)); }; diff --git a/packages/emails/src/templates/DailyVideoDownloadTranscriptEmail.tsx b/packages/emails/src/templates/DailyVideoDownloadTranscriptEmail.tsx new file mode 100644 index 0000000000..316e370ed4 --- /dev/null +++ b/packages/emails/src/templates/DailyVideoDownloadTranscriptEmail.tsx @@ -0,0 +1,103 @@ +import type { TFunction } from "next-i18next"; + +import { WEBAPP_URL, APP_NAME, COMPANY_NAME } from "@calcom/lib/constants"; + +import { V2BaseEmailHtml, CallToAction } from "../components"; + +interface DailyVideoDownloadTranscriptEmailProps { + language: TFunction; + transcriptDownloadLinks: Array; + title: string; + date: string; + name: string; +} + +export const DailyVideoDownloadTranscriptEmail = ( + props: DailyVideoDownloadTranscriptEmailProps & Partial> +) => { + const image = `${WEBAPP_URL}/emails/logo.png`; + return ( + +
+ + + +
+

+ <>{props.language("download_your_transcripts")} +

+

+ <>{props.language("hi_user_name", { name: props.name })}, +

+

+ <>{props.language("transcript_from_previous_call", { appName: APP_NAME })} +

+ + {props.transcriptDownloadLinks.map((downloadLink, index) => { + return ( +
+

+ <>{props.title} +

+

+ {props.date} Transcript {index + 1} +

+ +
+ ); + })} + +

+ <>{props.language("happy_scheduling")}, +

+

+ <>{props.language("the_calcom_team", { companyName: COMPANY_NAME })} +

+
+ ); +}; diff --git a/packages/emails/src/templates/index.ts b/packages/emails/src/templates/index.ts index 01f539375d..86e56ab575 100644 --- a/packages/emails/src/templates/index.ts +++ b/packages/emails/src/templates/index.ts @@ -27,6 +27,7 @@ export { VerifyAccountEmail } from "./VerifyAccountEmail"; export { VerifyEmailByCode } from "./VerifyEmailByCode"; export * from "@calcom/app-store/routing-forms/emails/components"; export { DailyVideoDownloadRecordingEmail } from "./DailyVideoDownloadRecordingEmail"; +export { DailyVideoDownloadTranscriptEmail } from "./DailyVideoDownloadTranscriptEmail"; export { OrganisationAccountVerifyEmail } from "./OrganizationAccountVerifyEmail"; export { OrgAutoInviteEmail } from "./OrgAutoInviteEmail"; export { MonthlyDigestEmail } from "./MonthlyDigestEmail"; diff --git a/packages/emails/templates/attendee-daily-video-download-transcript-email.ts b/packages/emails/templates/attendee-daily-video-download-transcript-email.ts new file mode 100644 index 0000000000..5c7355016c --- /dev/null +++ b/packages/emails/templates/attendee-daily-video-download-transcript-email.ts @@ -0,0 +1,71 @@ +import type { TFunction } from "next-i18next"; + +import { TimeFormat } from "@calcom/lib/timeFormat"; +import type { CalendarEvent, Person } from "@calcom/types/Calendar"; + +import { renderEmail } from "../"; +import BaseEmail from "./_base-email"; + +export default class AttendeeDailyVideoDownloadTranscriptEmail extends BaseEmail { + calEvent: CalendarEvent; + attendee: Person; + transcriptDownloadLinks: Array; + t: TFunction; + + constructor(calEvent: CalendarEvent, attendee: Person, transcriptDownloadLinks: string[]) { + super(); + this.name = "SEND_TRANSCRIPT_DOWNLOAD_LINK"; + this.calEvent = calEvent; + this.attendee = attendee; + this.transcriptDownloadLinks = transcriptDownloadLinks; + this.t = attendee.language.translate; + } + protected async getNodeMailerPayload(): Promise> { + return { + to: `${this.attendee.name} <${this.attendee.email}>`, + from: `${this.calEvent.organizer.name} <${this.getMailerOptions().from}>`, + replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email], + subject: `${this.t("download_transcript_email_subject", { + title: this.calEvent.title, + date: this.getFormattedDate(), + })}`, + html: await renderEmail("DailyVideoDownloadTranscriptEmail", { + title: this.calEvent.title, + date: this.getFormattedDate(), + transcriptDownloadLinks: this.transcriptDownloadLinks, + language: this.t, + name: this.attendee.name, + }), + }; + } + + protected getTimezone(): string { + return this.attendee.timeZone; + } + + protected getLocale(): string { + return this.attendee.language.locale; + } + + protected getInviteeStart(format: string) { + return this.getFormattedRecipientTime({ + time: this.calEvent.startTime, + format, + }); + } + + protected getInviteeEnd(format: string) { + return this.getFormattedRecipientTime({ + time: this.calEvent.endTime, + format, + }); + } + + protected getFormattedDate() { + const inviteeTimeFormat = this.attendee.timeFormat || TimeFormat.TWELVE_HOUR; + + return `${this.getInviteeStart(inviteeTimeFormat)} - ${this.getInviteeEnd(inviteeTimeFormat)}, ${this.t( + this.getInviteeStart("dddd").toLowerCase() + )}, ${this.t(this.getInviteeStart("MMMM").toLowerCase())} ${this.getInviteeStart("D, YYYY")}`; + } +} diff --git a/packages/emails/templates/organizer-daily-video-download-transcript-email.ts b/packages/emails/templates/organizer-daily-video-download-transcript-email.ts new file mode 100644 index 0000000000..a491ecf278 --- /dev/null +++ b/packages/emails/templates/organizer-daily-video-download-transcript-email.ts @@ -0,0 +1,66 @@ +import type { TFunction } from "next-i18next"; + +import { APP_NAME } from "@calcom/lib/constants"; +import { TimeFormat } from "@calcom/lib/timeFormat"; +import type { CalendarEvent } from "@calcom/types/Calendar"; + +import { renderEmail } from ".."; +import BaseEmail from "./_base-email"; + +export default class OrganizerDailyVideoDownloadTranscriptEmail extends BaseEmail { + calEvent: CalendarEvent; + transcriptDownloadLinks: Array; + t: TFunction; + + constructor(calEvent: CalendarEvent, transcriptDownloadLinks: string[]) { + super(); + this.name = "SEND_TRANSCRIPT_DOWNLOAD_LINK"; + this.calEvent = calEvent; + this.transcriptDownloadLinks = transcriptDownloadLinks; + this.t = this.calEvent.organizer.language.translate; + } + protected async getNodeMailerPayload(): Promise> { + return { + to: `${this.calEvent.organizer.email}>`, + from: `${APP_NAME} <${this.getMailerOptions().from}>`, + replyTo: [...this.calEvent.attendees.map(({ email }) => email), this.calEvent.organizer.email], + subject: `${this.t("download_transcript_email_subject", { + title: this.calEvent.title, + date: this.getFormattedDate(), + })}`, + html: await renderEmail("DailyVideoDownloadTranscriptEmail", { + title: this.calEvent.title, + date: this.getFormattedDate(), + transcriptDownloadLinks: this.transcriptDownloadLinks, + language: this.t, + name: this.calEvent.organizer.name, + }), + }; + } + + protected getTimezone(): string { + return this.calEvent.organizer.timeZone; + } + + protected getOrganizerStart(format: string) { + return this.getFormattedRecipientTime({ time: this.calEvent.startTime, format }); + } + + protected getOrganizerEnd(format: string) { + return this.getFormattedRecipientTime({ time: this.calEvent.endTime, format }); + } + + protected getLocale(): string { + return this.calEvent.organizer.language.locale; + } + + protected getFormattedDate() { + const organizerTimeFormat = this.calEvent.organizer.timeFormat || TimeFormat.TWELVE_HOUR; + + return `${this.getOrganizerStart(organizerTimeFormat)} - ${this.getOrganizerEnd( + organizerTimeFormat + )}, ${this.t(this.getOrganizerStart("dddd").toLowerCase())}, ${this.t( + this.getOrganizerStart("MMMM").toLowerCase() + )} ${this.getOrganizerStart("D, YYYY")}`; + } +} diff --git a/packages/types/VideoApiAdapter.d.ts b/packages/types/VideoApiAdapter.d.ts index 45553f6cc4..03cab0a658 100644 --- a/packages/types/VideoApiAdapter.d.ts +++ b/packages/types/VideoApiAdapter.d.ts @@ -26,6 +26,8 @@ export type VideoApiAdapter = getRecordingDownloadLink?(recordingId: string): Promise; createInstantCalVideoRoom?(endTime: string): Promise; + + getAllTranscriptsAccessLinkFromRoomName?(roomName: string): Promise>; } | undefined; diff --git a/turbo.json b/turbo.json index d23948229a..199880e8a5 100644 --- a/turbo.json +++ b/turbo.json @@ -255,6 +255,7 @@ "DAILY_API_KEY", "DAILY_SCALE_PLAN", "DAILY_WEBHOOK_SECRET", + "DAILY_MEETING_ENDED_WEBHOOK_SECRET", "DATABASE_DIRECT_URL", "DATABASE_URL", "DEBUG", diff --git a/yarn.lock b/yarn.lock index 0f4b596b51..7519f40864 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5177,7 +5177,8 @@ __metadata: "@calcom/tsconfig": "*" "@calcom/types": "*" "@calcom/ui": "*" - "@daily-co/daily-js": ^0.37.0 + "@daily-co/daily-js": ^0.59.0 + "@daily-co/daily-react": ^0.17.2 "@formkit/auto-animate": 1.0.0-beta.5 "@glidejs/glide": ^3.5.2 "@hookform/error-message": ^2.0.0 @@ -5293,6 +5294,7 @@ __metadata: react-timezone-select: ^1.4.0 react-turnstile: ^1.1.3 react-use-intercom: 1.5.1 + recoil: ^0.7.7 remove-markdown: ^0.5.0 rrule: ^2.7.1 sanitize-html: ^2.10.0 @@ -5813,17 +5815,30 @@ __metadata: languageName: node linkType: hard -"@daily-co/daily-js@npm:^0.37.0": - version: 0.37.0 - resolution: "@daily-co/daily-js@npm:0.37.0" +"@daily-co/daily-js@npm:^0.59.0": + version: 0.59.0 + resolution: "@daily-co/daily-js@npm:0.59.0" dependencies: "@babel/runtime": ^7.12.5 + "@sentry/browser": ^7.60.1 bowser: ^2.8.1 dequal: ^2.0.3 events: ^3.1.0 - fast-equals: ^1.6.3 - lodash: ^4.17.15 - checksum: 7264f9719b6b3747597d0096113eafea3a8c8ccf368bf967fdbd39a4929188940460510436360898cc870a9153ca950897624ea669fd7dc06070c39cecfc5786 + checksum: 5891ca13633a7c5c0997c4f4049afc0f67361c2f60a8f9a769d1f2a3bbc60c9d179681f2a3cf2e96598ee2967671ce2f5fef5eb490041912835ee66961f027ab + languageName: node + linkType: hard + +"@daily-co/daily-react@npm:^0.17.2": + version: 0.17.2 + resolution: "@daily-co/daily-react@npm:0.17.2" + dependencies: + fast-deep-equal: ^3.1.3 + lodash.throttle: ^4.1.1 + peerDependencies: + "@daily-co/daily-js": ">=0.45.0 <1" + react: ">=16.13.1" + recoil: ^0.7.0 + checksum: 87f679e6a3f28d2de5dbee4d5d8f97140fa6f3067e13615533dfcfde8d5496a7c48f3f9f916993c36cd90d7854d611ac69ae5243a973664a163c0d534496219c languageName: node linkType: hard @@ -13111,6 +13126,40 @@ __metadata: languageName: node linkType: hard +"@sentry-internal/feedback@npm:7.102.1": + version: 7.102.1 + resolution: "@sentry-internal/feedback@npm:7.102.1" + dependencies: + "@sentry/core": 7.102.1 + "@sentry/types": 7.102.1 + "@sentry/utils": 7.102.1 + checksum: 88a8649d08c80604cc4efc61b3b55b212be76ee8340f4de05db43b29ba86db72c138784640786dcef68ddd0bec1e3fcc1b421134bfb5345ed8fa177a953c7597 + languageName: node + linkType: hard + +"@sentry-internal/replay-canvas@npm:7.102.1": + version: 7.102.1 + resolution: "@sentry-internal/replay-canvas@npm:7.102.1" + dependencies: + "@sentry/core": 7.102.1 + "@sentry/replay": 7.102.1 + "@sentry/types": 7.102.1 + "@sentry/utils": 7.102.1 + checksum: f5b0dd73a5760663f9fdeee098e2c3d5e9c77b099825e03008bcebd722c432b642b30cbf4727ad432086f3a319b7bce8ac9e8c1f25809371c8297044355edf5f + languageName: node + linkType: hard + +"@sentry-internal/tracing@npm:7.102.1": + version: 7.102.1 + resolution: "@sentry-internal/tracing@npm:7.102.1" + dependencies: + "@sentry/core": 7.102.1 + "@sentry/types": 7.102.1 + "@sentry/utils": 7.102.1 + checksum: 7e7caa5864cfdc91187b1da38a402cc342c63697714e83885bb6fe179edf7ea91704d17783ad6774eac206813000ca25798e5a1d2592dfb8833052f91492f90a + languageName: node + linkType: hard + "@sentry-internal/tracing@npm:7.107.0": version: 7.107.0 resolution: "@sentry-internal/tracing@npm:7.107.0" @@ -13146,6 +13195,21 @@ __metadata: languageName: node linkType: hard +"@sentry/browser@npm:^7.60.1": + version: 7.102.1 + resolution: "@sentry/browser@npm:7.102.1" + dependencies: + "@sentry-internal/feedback": 7.102.1 + "@sentry-internal/replay-canvas": 7.102.1 + "@sentry-internal/tracing": 7.102.1 + "@sentry/core": 7.102.1 + "@sentry/replay": 7.102.1 + "@sentry/types": 7.102.1 + "@sentry/utils": 7.102.1 + checksum: 53908bd2c209f18b24452d10020d2a37f5300d5a24c6f092acd13baf6f5e2fe15b6c344289706fbf13c442335f8452c7043f4353aba2a940535a7873a224cc2a + languageName: node + linkType: hard + "@sentry/cli@npm:^1.74.6": version: 1.75.2 resolution: "@sentry/cli@npm:1.75.2" @@ -13162,6 +13226,16 @@ __metadata: languageName: node linkType: hard +"@sentry/core@npm:7.102.1": + version: 7.102.1 + resolution: "@sentry/core@npm:7.102.1" + dependencies: + "@sentry/types": 7.102.1 + "@sentry/utils": 7.102.1 + checksum: 4617cb4845499b52f36d7d212f38f306e68ca2a7a5645519491f55f66a6a9f422403c74a9d902ff5c8c241fd25e9c3187b14705e738bb823d340aa3dc6a2b273 + languageName: node + linkType: hard + "@sentry/core@npm:7.107.0": version: 7.107.0 resolution: "@sentry/core@npm:7.107.0" @@ -13261,6 +13335,18 @@ __metadata: languageName: node linkType: hard +"@sentry/replay@npm:7.102.1": + version: 7.102.1 + resolution: "@sentry/replay@npm:7.102.1" + dependencies: + "@sentry-internal/tracing": 7.102.1 + "@sentry/core": 7.102.1 + "@sentry/types": 7.102.1 + "@sentry/utils": 7.102.1 + checksum: 99ba652f8d0d0654cabda485339c1b4960dc9015835ba106582e6c3aefcd2544e4f0081fd28611fe1df939a4e8c3c2f672e195c294407e52c31bd24fd88db9bb + languageName: node + linkType: hard + "@sentry/replay@npm:7.77.0": version: 7.77.0 resolution: "@sentry/replay@npm:7.77.0" @@ -13282,6 +13368,13 @@ __metadata: languageName: node linkType: hard +"@sentry/types@npm:7.102.1": + version: 7.102.1 + resolution: "@sentry/types@npm:7.102.1" + checksum: e98e6e793d1d13e1bece57bd732a0606acdf23533aa3f3a5cbbb39c7359afef52f05848eb5ab785306e15846e29e7219e0fe63fc26798bda5224263162dea9d1 + languageName: node + linkType: hard + "@sentry/types@npm:7.107.0": version: 7.107.0 resolution: "@sentry/types@npm:7.107.0" @@ -13296,6 +13389,15 @@ __metadata: languageName: node linkType: hard +"@sentry/utils@npm:7.102.1": + version: 7.102.1 + resolution: "@sentry/utils@npm:7.102.1" + dependencies: + "@sentry/types": 7.102.1 + checksum: 95a4ad8fbe75e2afbeb4cb14ea5f465015b7d0c5b200a0e2f91e2944940c797c20dea534cb57f93fb164d313860437466bd247a2b38996ba6160c6d672d2d99a + languageName: node + linkType: hard + "@sentry/utils@npm:7.107.0": version: 7.107.0 resolution: "@sentry/utils@npm:7.107.0" @@ -17157,6 +17259,15 @@ __metadata: languageName: node linkType: hard +"@vercel/analytics@npm:^0.1.6": + version: 0.1.11 + resolution: "@vercel/analytics@npm:0.1.11" + peerDependencies: + react: ^16.8||^17||^18 + checksum: 05b8180ac6e23ebe7c09d74c43f8ee78c408cd0b6546e676389cbf4fba44dfeeae3648c9b52e2421be64fe3aeee8b026e6ea4bdfc0589fb5780670f2b090a167 + languageName: node + linkType: hard + "@vercel/edge-config@npm:^0.1.1": version: 0.1.1 resolution: "@vercel/edge-config@npm:0.1.1" @@ -20058,6 +20169,7 @@ __metadata: resolution: "calcom-monorepo@workspace:." dependencies: "@changesets/cli": ^2.26.1 + "@daily-co/daily-js": ^0.59.0 "@deploysentinel/playwright": ^0.3.3 "@playwright/test": ^1.31.2 "@snaplet/copycat": ^4.1.0 @@ -20066,6 +20178,7 @@ __metadata: "@types/jsonwebtoken": ^9.0.3 c8: ^7.13.0 checkly: latest + city-timezones: ^1.2.1 dotenv-checker: ^1.1.5 eslint: ^8.34.0 husky: ^8.0.0 @@ -25467,13 +25580,6 @@ __metadata: languageName: node linkType: hard -"fast-equals@npm:^1.6.3": - version: 1.6.3 - resolution: "fast-equals@npm:1.6.3" - checksum: d2de5af5e927cefbc7049e846226aa9ca71bdbae3e179a40774e2853c65e00d78158250af3635e7b3b8662ed7c0796d405962d8be3d1b87beb109668a46568ed - languageName: node - linkType: hard - "fast-equals@npm:^2.0.3": version: 2.0.4 resolution: "fast-equals@npm:2.0.4" @@ -27612,6 +27718,13 @@ __metadata: languageName: node linkType: hard +"hamt_plus@npm:1.0.2": + version: 1.0.2 + resolution: "hamt_plus@npm:1.0.2" + checksum: af26ea32db03009019cc83dfa9411521a2fa16079443de1a502c9be46d8b3c975acda8ed93fc5750ef08d3186d35901e2d8cfe717dd54bea67b358601fa74e4c + languageName: node + linkType: hard + "handlebars@npm:^4.7.7": version: 4.7.7 resolution: "handlebars@npm:4.7.7" @@ -30107,6 +30220,15 @@ __metadata: languageName: node linkType: hard +"isomorphic-ws@npm:^5.0.0": + version: 5.0.0 + resolution: "isomorphic-ws@npm:5.0.0" + peerDependencies: + ws: "*" + checksum: e20eb2aee09ba96247465fda40c6d22c1153394c0144fa34fe6609f341af4c8c564f60ea3ba762335a7a9c306809349f9b863c8beedf2beea09b299834ad5398 + languageName: node + linkType: hard + "isstream@npm:~0.1.2": version: 0.1.2 resolution: "isstream@npm:0.1.2" @@ -32556,6 +32678,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 129c0a28cee48b348aef146f638ef8a8b197944d4e9ec26c1890c19d9bf5a5690fe11b655c77a4551268819b32d27f4206343e30c78961f60b561b8608c8c805 + languageName: node + linkType: hard + "lodash.union@npm:^4.6.0": version: 4.6.0 resolution: "lodash.union@npm:4.6.0" @@ -40155,6 +40284,22 @@ __metadata: languageName: node linkType: hard +"recoil@npm:^0.7.7": + version: 0.7.7 + resolution: "recoil@npm:0.7.7" + dependencies: + hamt_plus: 1.0.2 + peerDependencies: + react: ">=16.13.1" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 65edecbcb8d2cde89bfd61ec679c200483472a6cd343c33e4e9142b6ce524fb17d1fecc2bfd8c392926aaa8178c81457f165b32abce2a9662f51f98822c0a9cc + languageName: node + linkType: hard + "redent@npm:^3.0.0": version: 3.0.0 resolution: "redent@npm:3.0.0"