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

@@ -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