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 <udit222001@gmail.com>
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 <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
This commit is contained in:
Erik
2024-04-17 11:09:11 -04:00
committed by GitHub
co-authored by Udit Takkar Udit Takkar Joe Au-Yeung Peer Richelsen Carina Wollendorfer
parent ebca5c6409
commit b9ac22d4ed
20 changed files with 859 additions and 55 deletions
+1
View File
@@ -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
@@ -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<HTMLDivElement | null>(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 (
<>
<Toaster position="bottom-right" />
<div
id="cal-ai-thing"
ref={transcriptRef}
className="max-h-full overflow-x-hidden overflow-y-scroll p-2 text-center text-white">
{transcript
? transcript.split("\n").map((line, i) => (
<Fragment key={`transcript-${i}`}>
{i > 0 && <br />}
{line}
</Fragment>
))
: ""}
</div>
</>
);
};
@@ -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<DailyCall | null>(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) {
<meta property="twitter:title" content={`${APP_NAME} Video`} />
<meta property="twitter:description" content={t("quick_video_meeting")} />
</Head>
<div style={{ zIndex: 2, position: "relative" }}>
{booking?.user?.organization?.calVideoLogo ? (
<img
className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
src={booking.user.organization.calVideoLogo}
alt="My Org Logo"
style={{
top: 32,
left: 32,
}}
/>
) : (
<img
className="fixed z-10 hidden sm:inline-block"
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
alt="Logo"
style={{
top: 32,
left: 32,
}}
/>
)}
</div>
<VideoMeetingInfo booking={booking} />
<DailyProvider callObject={daily}>
<div className="mx-auto" style={{ zIndex: 2, position: "absolute", bottom: 60, width: "100%" }}>
<CalAiTransctibe />
</div>
<div style={{ zIndex: 2, position: "relative" }}>
{booking?.user?.organization?.calVideoLogo ? (
<img
className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
src={booking.user.organization.calVideoLogo}
alt="My Org Logo"
style={{
top: 32,
left: 32,
}}
/>
) : (
<img
className="fixed z-10 hidden sm:inline-block"
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
alt="Logo"
style={{
top: 32,
left: 32,
}}
/>
)}
</div>
<VideoMeetingInfo booking={booking} />
</DailyProvider>
</>
);
}
+3 -1
View File
@@ -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",
+1
View File
@@ -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,
+2 -2
View File
@@ -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,
})
@@ -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 }),
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f4f4f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M19 17v4"/><path d="M3 5h4"/><path d="M17 19h4"/></svg>

After

Width:  |  Height:  |  Size: 467 B

@@ -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</1>.",
"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.",
+2
View File
@@ -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"
},
@@ -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<string, unknown>) {
});
}
async function processTranscriptsInBatches(transcriptIds: Array<string>) {
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<VideoCallData> {
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<Array<string>> => {
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");
}
},
};
};
+24
View File
@@ -274,6 +274,29 @@ const getDownloadLinkOfCalVideoByRecordingId = async (recordingId: string) => {
return videoAdapter?.getRecordingDownloadLink?.(recordingId);
};
const getAllTranscriptsAccessLinkFromRoomName = async (roomName: string) => {
let dailyAppKeys: Awaited<ReturnType<typeof getDailyAppKeys>>;
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,
};
+15
View File
@@ -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<unknown>[] = [];
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));
};
@@ -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<string>;
title: string;
date: string;
name: string;
}
export const DailyVideoDownloadTranscriptEmail = (
props: DailyVideoDownloadTranscriptEmailProps & Partial<React.ComponentProps<typeof V2BaseEmailHtml>>
) => {
const image = `${WEBAPP_URL}/emails/logo.png`;
return (
<V2BaseEmailHtml
subject={props.language("download_transcript_email_subject", {
title: props.title,
date: props.date,
})}>
<div style={{ width: "89px", marginBottom: "35px" }}>
<a href={WEBAPP_URL} target="_blank" rel="noreferrer">
<img
height="19"
src={image}
style={{
border: "0",
display: "block",
outline: "none",
textDecoration: "none",
height: "19px",
width: "100%",
fontSize: "13px",
}}
width="89"
alt=""
/>
</a>
</div>
<p
style={{
fontSize: "32px",
fontWeight: "600",
lineHeight: "38.5px",
marginBottom: "40px",
color: "black",
}}>
<>{props.language("download_your_transcripts")}</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px" }}>
<>{props.language("hi_user_name", { name: props.name })},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginBottom: "40px" }}>
<>{props.language("transcript_from_previous_call", { appName: APP_NAME })}</>
</p>
{props.transcriptDownloadLinks.map((downloadLink, index) => {
return (
<div
key={downloadLink}
style={{
backgroundColor: "#F3F4F6",
padding: "32px",
marginBottom: "40px",
}}>
<p
style={{
fontSize: "18px",
lineHeight: "20px",
fontWeight: 600,
marginBottom: "8px",
color: "black",
}}>
<>{props.title}</>
</p>
<p
style={{
fontWeight: 400,
lineHeight: "24px",
marginBottom: "24px",
marginTop: "0px",
color: "black",
}}>
{props.date} Transcript {index + 1}
</p>
<CallToAction label={props.language("download_transcript")} href={downloadLink} />
</div>
);
})}
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "32px", marginBottom: "8px" }}>
<>{props.language("happy_scheduling")},</>
</p>
<p style={{ fontWeight: 400, lineHeight: "24px", marginTop: "0px" }}>
<>{props.language("the_calcom_team", { companyName: COMPANY_NAME })}</>
</p>
</V2BaseEmailHtml>
);
};
+1
View File
@@ -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";
@@ -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<string>;
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<Record<string, unknown>> {
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")}`;
}
}
@@ -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<string>;
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<Record<string, unknown>> {
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")}`;
}
}
+2
View File
@@ -26,6 +26,8 @@ export type VideoApiAdapter =
getRecordingDownloadLink?(recordingId: string): Promise<GetAccessLinkResponseSchema>;
createInstantCalVideoRoom?(endTime: string): Promise<VideoCallData>;
getAllTranscriptsAccessLinkFromRoomName?(roomName: string): Promise<Array<string>>;
}
| undefined;
+1
View File
@@ -255,6 +255,7 @@
"DAILY_API_KEY",
"DAILY_SCALE_PLAN",
"DAILY_WEBHOOK_SECRET",
"DAILY_MEETING_ENDED_WEBHOOK_SECRET",
"DATABASE_DIRECT_URL",
"DATABASE_URL",
"DEBUG",
+159 -14
View File
@@ -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"