feat: daily.co support as alternative to whereby (#691)

* llm instructions

* vibe dailyco

* vibe dailyco

* doc update (vibe)

* dont show recording ui on call

* stub processor (vibe)

* stub processor (vibe) self-review

* stub processor (vibe) self-review

* chore(main): release 0.14.0 (#670)

* Add multitrack pipeline

* Mixdown audio tracks

* Mixdown with pyav filter graph

* Trigger multitrack processing for daily recordings

* apply platform from envs in priority: non-dry

* Use explicit track keys for processing

* Align tracks of a multitrack recording

* Generate waveforms for the mixed audio

* Emit multriack pipeline events

* Fix multitrack pipeline track alignment

* dailico docs

* Enable multitrack reprocessing

* modal temp files uniform names, cleanup. remove llm temporary docs

* docs cleanup

* dont proceed with raw recordings if any of the downloads fail

* dry transcription pipelines

* remove is_miltitrack

* comments

* explicit dailyco room name

* docs

* remove stub data/method

* frontend daily/whereby code self-review (no-mistake)

* frontend daily/whereby code self-review (no-mistakes)

* frontend daily/whereby code self-review (no-mistakes)

* consent cleanup for multitrack (no-mistakes)

* llm fun

* remove extra comments

* fix tests

* merge migrations

* Store participant names

* Get participants by meeting session id

* pop back main branch migration

* s3 paddington (no-mistakes)

* comment

* pr comments

* pr comments

* pr comments

* platform / meeting cleanup

* Use participant names in summary generation

* platform assignment to meeting at controller level

* pr comment

* room playform properly default none

* room playform properly default none

* restore migration lost

* streaming WIP

* extract storage / use common storage / proper env vars for storage

* fix mocks tests

* remove fall back

* streaming for multifile

* cenrtal storage abstraction (no-mistakes)

* remove dead code / vars

* Set participant user id for authenticated users

* whereby recording name parsing fix

* whereby recording name parsing fix

* more file stream

* storage dry + tests

* remove homemade boto3 streaming and use proper boto

* update migration guide

* webhook creation script - print uuid

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
Co-authored-by: Mathieu Virbel <mat@meltingrocks.com>
Co-authored-by: Sergey Mankovsky <sergey@monadical.com>
This commit is contained in:
Igor Monadical
2025-11-12 21:21:16 -05:00
committed by GitHub
parent 372202b0e1
commit 1473fd82dc
71 changed files with 4985 additions and 468 deletions

View File

@@ -1,3 +1,3 @@
import Room from "../room";
import RoomContainer from "../components/RoomContainer";
export default Room;
export default RoomContainer;

View File

@@ -0,0 +1,93 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { Box } from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import DailyIframe, { DailyCall } from "@daily-co/daily-js";
import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
import {
ConsentDialogButton,
recordingTypeRequiresConsent,
} from "../../lib/consent";
type Meeting = components["schemas"]["Meeting"];
interface DailyRoomProps {
meeting: Meeting;
}
export default function DailyRoom({ meeting }: DailyRoomProps) {
const router = useRouter();
const auth = useAuth();
const status = auth.status;
const containerRef = useRef<HTMLDivElement>(null);
const roomUrl = meeting?.host_room_url || meeting?.room_url;
const isLoading = status === "loading";
const handleLeave = useCallback(() => {
router.push("/browse");
}, [router]);
useEffect(() => {
if (isLoading || !roomUrl || !containerRef.current) return;
let frame: DailyCall | null = null;
let destroyed = false;
const createAndJoin = async () => {
try {
const existingFrame = DailyIframe.getCallInstance();
if (existingFrame) {
await existingFrame.destroy();
}
frame = DailyIframe.createFrame(containerRef.current!, {
iframeStyle: {
width: "100vw",
height: "100vh",
border: "none",
},
showLeaveButton: true,
showFullscreenButton: true,
});
if (destroyed) {
await frame.destroy();
return;
}
frame.on("left-meeting", handleLeave);
await frame.join({ url: roomUrl });
} catch (error) {
console.error("Error creating Daily frame:", error);
}
};
createAndJoin();
return () => {
destroyed = true;
if (frame) {
frame.destroy().catch((e) => {
console.error("Error destroying frame:", e);
});
}
};
}, [roomUrl, isLoading, handleLeave]);
if (!roomUrl) {
return null;
}
return (
<Box position="relative" width="100vw" height="100vh">
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
{meeting.recording_type &&
recordingTypeRequiresConsent(meeting.recording_type) &&
meeting.id && <ConsentDialogButton meetingId={meeting.id} />}
</Box>
);
}

View File

@@ -0,0 +1,214 @@
"use client";
import { roomMeetingUrl } from "../../lib/routes";
import { useCallback, useEffect, useState, use } from "react";
import { Box, Text, Spinner } from "@chakra-ui/react";
import { useRouter } from "next/navigation";
import {
useRoomGetByName,
useRoomsCreateMeeting,
useRoomGetMeeting,
} from "../../lib/apiHooks";
import type { components } from "../../reflector-api";
import MeetingSelection from "../MeetingSelection";
import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
import WherebyRoom from "./WherebyRoom";
import DailyRoom from "./DailyRoom";
import { useAuth } from "../../lib/AuthProvider";
import { useError } from "../../(errors)/errorContext";
import { parseNonEmptyString } from "../../lib/utils";
import { printApiError } from "../../api/_error";
type Meeting = components["schemas"]["Meeting"];
export type RoomDetails = {
params: Promise<{
roomName: string;
meetingId?: string;
}>;
};
function LoadingSpinner() {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Spinner color="blue.500" size="xl" />
</Box>
);
}
export default function RoomContainer(details: RoomDetails) {
const params = use(details.params);
const roomName = parseNonEmptyString(
params.roomName,
true,
"panic! params.roomName is required",
);
const router = useRouter();
const auth = useAuth();
const status = auth.status;
const isAuthenticated = status === "authenticated";
const { setError } = useError();
const roomQuery = useRoomGetByName(roomName);
const createMeetingMutation = useRoomsCreateMeeting();
const room = roomQuery.data;
const pageMeetingId = params.meetingId;
const defaultMeeting = useRoomDefaultMeeting(
room && !room.ics_enabled && !pageMeetingId ? roomName : null,
);
const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
const meeting = explicitMeeting.data || defaultMeeting.response;
const isLoading =
status === "loading" ||
roomQuery.isLoading ||
defaultMeeting?.loading ||
explicitMeeting.isLoading ||
createMeetingMutation.isPending;
const errors = [
explicitMeeting.error,
defaultMeeting.error,
roomQuery.error,
createMeetingMutation.error,
].filter(Boolean);
const isOwner =
isAuthenticated && room ? auth.user?.id === room.user_id : false;
const handleMeetingSelect = (selectedMeeting: Meeting) => {
router.push(
roomMeetingUrl(
roomName,
parseNonEmptyString(
selectedMeeting.id,
true,
"panic! selectedMeeting.id is required",
),
),
);
};
const handleCreateUnscheduled = async () => {
try {
const newMeeting = await createMeetingMutation.mutateAsync({
params: {
path: { room_name: roomName },
},
body: {
allow_duplicated: room ? room.ics_enabled : false,
},
});
handleMeetingSelect(newMeeting);
} catch (err) {
console.error("Failed to create meeting:", err);
}
};
if (isLoading) {
return <LoadingSpinner />;
}
if (!room) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Room not found</Text>
</Box>
);
}
if (room.ics_enabled && !params.meetingId) {
return (
<MeetingSelection
roomName={roomName}
isOwner={isOwner}
isSharedRoom={room?.is_shared || false}
authLoading={["loading", "refreshing"].includes(auth.status)}
onMeetingSelect={handleMeetingSelect}
onCreateUnscheduled={handleCreateUnscheduled}
isCreatingMeeting={createMeetingMutation.isPending}
/>
);
}
if (errors.length > 0) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
{errors.map((error, i) => (
<Text key={i} fontSize="lg">
{printApiError(error)}
</Text>
))}
</Box>
);
}
if (!meeting) {
return <LoadingSpinner />;
}
const platform = meeting.platform;
if (!platform) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Meeting platform not configured</Text>
</Box>
);
}
switch (platform) {
case "daily":
return <DailyRoom meeting={meeting} />;
case "whereby":
return <WherebyRoom meeting={meeting} />;
default: {
const _exhaustive: never = platform;
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
height="100vh"
bg="gray.50"
p={4}
>
<Text fontSize="lg">Unknown platform: {platform}</Text>
</Box>
);
}
}
}

View File

@@ -0,0 +1,101 @@
"use client";
import { useCallback, useEffect, useRef, RefObject } from "react";
import { useRouter } from "next/navigation";
import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient";
import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils";
import {
ConsentDialogButton as BaseConsentDialogButton,
useConsentDialog,
recordingTypeRequiresConsent,
} from "../../lib/consent";
type Meeting = components["schemas"]["Meeting"];
interface WherebyRoomProps {
meeting: Meeting;
}
function WherebyConsentDialogButton({
meetingId,
wherebyRef,
}: {
meetingId: NonEmptyString;
wherebyRef: React.RefObject<HTMLElement>;
}) {
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
const element = wherebyRef.current;
if (!element) return;
const handleWherebyReady = () => {
previousFocusRef.current = document.activeElement as HTMLElement;
};
element.addEventListener("ready", handleWherebyReady);
return () => {
element.removeEventListener("ready", handleWherebyReady);
if (previousFocusRef.current && document.activeElement === element) {
previousFocusRef.current.focus();
}
};
}, [wherebyRef]);
return <BaseConsentDialogButton meetingId={meetingId} />;
}
export default function WherebyRoom({ meeting }: WherebyRoomProps) {
const wherebyLoaded = useWhereby();
const wherebyRef = useRef<HTMLElement>(null);
const router = useRouter();
const auth = useAuth();
const status = auth.status;
const isAuthenticated = status === "authenticated";
const wherebyRoomUrl = getWherebyUrl(meeting);
const recordingType = meeting.recording_type;
const meetingId = meeting.id;
const isLoading = status === "loading";
const handleLeave = useCallback(() => {
router.push("/browse");
}, [router]);
useEffect(() => {
if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded)
return;
wherebyRef.current?.addEventListener("leave", handleLeave);
return () => {
wherebyRef.current?.removeEventListener("leave", handleLeave);
};
}, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]);
if (!wherebyRoomUrl || !wherebyLoaded) {
return null;
}
return (
<>
<whereby-embed
ref={wherebyRef}
room={wherebyRoomUrl}
style={{ width: "100vw", height: "100vh" }}
/>
{recordingType &&
recordingTypeRequiresConsent(recordingType) &&
meetingId && (
<WherebyConsentDialogButton
meetingId={assertExistsAndNonEmptyString(meetingId)}
wherebyRef={wherebyRef}
/>
)}
</>
);
}

View File

@@ -1,3 +1,3 @@
import Room from "./room";
import RoomContainer from "./components/RoomContainer";
export default Room;
export default RoomContainer;

View File

@@ -0,0 +1,36 @@
"use client";
import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react";
import { CONSENT_DIALOG_TEXT } from "./constants";
interface ConsentDialogProps {
onAccept: () => void;
onReject: () => void;
}
export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
return (
<Box
p={6}
bg="rgba(255, 255, 255, 0.7)"
borderRadius="lg"
boxShadow="lg"
maxW="md"
mx="auto"
>
<VStack gap={4} alignItems="center">
<Text fontSize="md" textAlign="center" fontWeight="medium">
{CONSENT_DIALOG_TEXT.question}
</Text>
<HStack gap={4} justifyContent="center">
<Button variant="ghost" size="sm" onClick={onReject}>
{CONSENT_DIALOG_TEXT.rejectButton}
</Button>
<Button colorPalette="primary" size="sm" onClick={onAccept}>
{CONSENT_DIALOG_TEXT.acceptButton}
</Button>
</HStack>
</VStack>
</Box>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { Button, Icon } from "@chakra-ui/react";
import { FaBars } from "react-icons/fa6";
import { useConsentDialog } from "./useConsentDialog";
import {
CONSENT_BUTTON_TOP_OFFSET,
CONSENT_BUTTON_LEFT_OFFSET,
CONSENT_BUTTON_Z_INDEX,
CONSENT_DIALOG_TEXT,
} from "./constants";
interface ConsentDialogButtonProps {
meetingId: string;
}
export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) {
const { showConsentModal, consentState, hasConsent, consentLoading } =
useConsentDialog(meetingId);
if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
return null;
}
return (
<Button
position="absolute"
top={CONSENT_BUTTON_TOP_OFFSET}
left={CONSENT_BUTTON_LEFT_OFFSET}
zIndex={CONSENT_BUTTON_Z_INDEX}
colorPalette="blue"
size="sm"
onClick={showConsentModal}
>
{CONSENT_DIALOG_TEXT.triggerButton}
<Icon as={FaBars} ml={2} />
</Button>
);
}

View File

@@ -0,0 +1,12 @@
export const CONSENT_BUTTON_TOP_OFFSET = "56px";
export const CONSENT_BUTTON_LEFT_OFFSET = "8px";
export const CONSENT_BUTTON_Z_INDEX = 1000;
export const TOAST_CHECK_INTERVAL_MS = 100;
export const CONSENT_DIALOG_TEXT = {
question:
"Can we have your permission to store this meeting's audio recording on our servers?",
acceptButton: "Yes, store the audio",
rejectButton: "No, delete after transcription",
triggerButton: "Meeting is being recorded",
} as const;

View File

@@ -0,0 +1,8 @@
"use client";
export { ConsentDialogButton } from "./ConsentDialogButton";
export { ConsentDialog } from "./ConsentDialog";
export { useConsentDialog } from "./useConsentDialog";
export { recordingTypeRequiresConsent } from "./utils";
export * from "./constants";
export * from "./types";

View File

@@ -0,0 +1,9 @@
export interface ConsentDialogResult {
showConsentModal: () => void;
consentState: {
ready: boolean;
consentAnsweredForMeetings?: Set<string>;
};
hasConsent: (meetingId: string) => boolean;
consentLoading: boolean;
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useCallback, useState, useEffect, useRef } from "react";
import { toaster } from "../../components/ui/toaster";
import { useRecordingConsent } from "../../recordingConsentContext";
import { useMeetingAudioConsent } from "../apiHooks";
import { ConsentDialog } from "./ConsentDialog";
import { TOAST_CHECK_INTERVAL_MS } from "./constants";
import type { ConsentDialogResult } from "./types";
export function useConsentDialog(meetingId: string): ConsentDialogResult {
const { state: consentState, touch, hasConsent } = useRecordingConsent();
const [modalOpen, setModalOpen] = useState(false);
const audioConsentMutation = useMeetingAudioConsent();
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const keydownHandlerRef = useRef<((event: KeyboardEvent) => void) | null>(
null,
);
useEffect(() => {
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (keydownHandlerRef.current) {
document.removeEventListener("keydown", keydownHandlerRef.current);
keydownHandlerRef.current = null;
}
};
}, []);
const handleConsent = useCallback(
async (given: boolean) => {
try {
await audioConsentMutation.mutateAsync({
params: {
path: { meeting_id: meetingId },
},
body: {
consent_given: given,
},
});
touch(meetingId);
} catch (error) {
console.error("Error submitting consent:", error);
}
},
[audioConsentMutation, touch, meetingId],
);
const showConsentModal = useCallback(() => {
if (modalOpen) return;
setModalOpen(true);
const toastId = toaster.create({
placement: "top",
duration: null,
render: ({ dismiss }) => (
<ConsentDialog
onAccept={() => {
handleConsent(true);
dismiss();
}}
onReject={() => {
handleConsent(false);
dismiss();
}}
/>
),
});
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
toastId.then((id) => toaster.dismiss(id));
}
};
keydownHandlerRef.current = handleKeyDown;
document.addEventListener("keydown", handleKeyDown);
toastId.then((id) => {
intervalRef.current = setInterval(() => {
if (!toaster.isActive(id)) {
setModalOpen(false);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (keydownHandlerRef.current) {
document.removeEventListener("keydown", keydownHandlerRef.current);
keydownHandlerRef.current = null;
}
}
}, TOAST_CHECK_INTERVAL_MS);
});
}, [handleConsent, modalOpen]);
return {
showConsentModal,
consentState,
hasConsent,
consentLoading: audioConsentMutation.isPending,
};
}

View File

@@ -0,0 +1,13 @@
import type { components } from "../../reflector-api";
type Meeting = components["schemas"]["Meeting"];
/**
* Determines if a meeting's recording type requires user consent.
* Currently only "cloud" recordings require consent.
*/
export function recordingTypeRequiresConsent(
recordingType: Meeting["recording_type"],
): boolean {
return recordingType === "cloud";
}

View File

@@ -3,6 +3,7 @@ import { PROTECTED_PAGES } from "./auth";
import { usePathname } from "next/navigation";
import { useAuth } from "./AuthProvider";
import { useEffect } from "react";
import { featureEnabled } from "./features";
const HOME = "/" as const;
@@ -13,7 +14,9 @@ export const useLoginRequiredPages = () => {
const isNotLoggedIn = auth.status === "unauthenticated";
// safety
const isLastDestination = pathname === HOME;
const shouldRedirect = isNotLoggedIn && isProtected && !isLastDestination;
const requireLogin = featureEnabled("requireLogin");
const shouldRedirect =
requireLogin && isNotLoggedIn && isProtected && !isLastDestination;
useEffect(() => {
if (!shouldRedirect) return;
// on the backend, the redirect goes straight to the auth provider, but we don't have it because it's hidden inside next-auth middleware

View File

@@ -696,6 +696,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/webhook": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Webhook
* @description Handle Daily webhook events.
*/
post: operations["v1_webhook"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -852,6 +872,8 @@ export interface components {
* @default false
*/
ics_enabled: boolean;
/** Platform */
platform?: ("whereby" | "daily") | null;
};
/** CreateRoomMeeting */
CreateRoomMeeting: {
@@ -877,6 +899,22 @@ export interface components {
target_language: string;
source_kind?: components["schemas"]["SourceKind"] | null;
};
/**
* DailyWebhookEvent
* @description Daily webhook event structure.
*/
DailyWebhookEvent: {
/** Type */
type: string;
/** Id */
id: string;
/** Ts */
ts: number;
/** Data */
data: {
[key: string]: unknown;
};
};
/** DeletionStatus */
DeletionStatus: {
/** Status */
@@ -1193,6 +1231,12 @@ export interface components {
calendar_metadata?: {
[key: string]: unknown;
} | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
};
/** MeetingConsentRequest */
MeetingConsentRequest: {
@@ -1279,6 +1323,12 @@ export interface components {
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
};
/** RoomDetails */
RoomDetails: {
@@ -1325,6 +1375,12 @@ export interface components {
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
/**
* Platform
* @default whereby
* @enum {string}
*/
platform: "whereby" | "daily";
/** Webhook Url */
webhook_url: string | null;
/** Webhook Secret */
@@ -1505,6 +1561,8 @@ export interface components {
ics_fetch_interval?: number | null;
/** Ics Enabled */
ics_enabled?: boolean | null;
/** Platform */
platform?: ("whereby" | "daily") | null;
};
/** UpdateTranscript */
UpdateTranscript: {
@@ -3191,4 +3249,37 @@ export interface operations {
};
};
};
v1_webhook: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["DailyWebhookEvent"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}