diff --git a/.env.example b/.env.example index 350c86e9c1..e1995a4cc5 100644 --- a/.env.example +++ b/.env.example @@ -520,4 +520,4 @@ LINKEDIN_ADS_ENABLED=1 # To enable LinkedIn Ads tracking (li_fat_id) B2_APPLICATION_KEY_ID= B2_APPLICATION_KEY= B2_BUCKET_ID= -B2_BUCKET_NAME= +B2_BUCKET_NAME= \ No newline at end of file diff --git a/apps/web/components/apps/AppSetupPage.tsx b/apps/web/components/apps/AppSetupPage.tsx index 09aa21f05b..88acf8c89b 100644 --- a/apps/web/components/apps/AppSetupPage.tsx +++ b/apps/web/components/apps/AppSetupPage.tsx @@ -1,6 +1,5 @@ -import dynamic from "next/dynamic"; - import { DynamicComponent } from "@calcom/app-store/_components/DynamicComponent"; +import dynamic from "next/dynamic"; export const AppSetupMap = { alby: dynamic(() => import("@calcom/web/components/apps/alby/Setup")), diff --git a/packages/app-store/apps.keys-schemas.generated.ts b/packages/app-store/apps.keys-schemas.generated.ts index edc85d04d1..5df66d19f7 100644 --- a/packages/app-store/apps.keys-schemas.generated.ts +++ b/packages/app-store/apps.keys-schemas.generated.ts @@ -23,6 +23,7 @@ import { appKeysSchema as intercom_zod_ts } from "./intercom/zod"; import { appKeysSchema as jelly_zod_ts } from "./jelly/zod"; import { appKeysSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; import { appKeysSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; +import { appKeysSchema as lyra_zod_ts } from "./lyra/zod"; import { appKeysSchema as make_zod_ts } from "./make/zod"; import { appKeysSchema as matomo_zod_ts } from "./matomo/zod"; import { appKeysSchema as metapixel_zod_ts } from "./metapixel/zod"; @@ -74,6 +75,7 @@ export const appKeysSchemas = { jelly: jelly_zod_ts, jitsivideo: jitsivideo_zod_ts, larkcalendar: larkcalendar_zod_ts, + lyra: lyra_zod_ts, make: make_zod_ts, matomo: matomo_zod_ts, metapixel: metapixel_zod_ts, diff --git a/packages/app-store/apps.metadata.generated.ts b/packages/app-store/apps.metadata.generated.ts index be4cfb6cb9..4857b87fda 100644 --- a/packages/app-store/apps.metadata.generated.ts +++ b/packages/app-store/apps.metadata.generated.ts @@ -55,6 +55,7 @@ import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; import { metadata as larkcalendar__metadata_ts } from "./larkcalendar/_metadata"; import lindy_config_json from "./lindy/config.json"; import linear_config_json from "./linear/config.json"; +import lyra_config_json from "./lyra/config.json"; import make_config_json from "./make/config.json"; import matomo_config_json from "./matomo/config.json"; import metapixel_config_json from "./metapixel/config.json"; @@ -166,6 +167,7 @@ export const appStoreMetadata = { larkcalendar: larkcalendar__metadata_ts, lindy: lindy_config_json, linear: linear_config_json, + lyra: lyra_config_json, make: make_config_json, matomo: matomo_config_json, metapixel: metapixel_config_json, diff --git a/packages/app-store/apps.schemas.generated.ts b/packages/app-store/apps.schemas.generated.ts index 28310ace22..f4017ac588 100644 --- a/packages/app-store/apps.schemas.generated.ts +++ b/packages/app-store/apps.schemas.generated.ts @@ -23,6 +23,7 @@ import { appDataSchema as intercom_zod_ts } from "./intercom/zod"; import { appDataSchema as jelly_zod_ts } from "./jelly/zod"; import { appDataSchema as jitsivideo_zod_ts } from "./jitsivideo/zod"; import { appDataSchema as larkcalendar_zod_ts } from "./larkcalendar/zod"; +import { appDataSchema as lyra_zod_ts } from "./lyra/zod"; import { appDataSchema as make_zod_ts } from "./make/zod"; import { appDataSchema as matomo_zod_ts } from "./matomo/zod"; import { appDataSchema as metapixel_zod_ts } from "./metapixel/zod"; @@ -74,6 +75,7 @@ export const appDataSchemas = { jelly: jelly_zod_ts, jitsivideo: jitsivideo_zod_ts, larkcalendar: larkcalendar_zod_ts, + lyra: lyra_zod_ts, make: make_zod_ts, matomo: matomo_zod_ts, metapixel: metapixel_zod_ts, diff --git a/packages/app-store/apps.server.generated.ts b/packages/app-store/apps.server.generated.ts index 61349f361c..495e07786d 100644 --- a/packages/app-store/apps.server.generated.ts +++ b/packages/app-store/apps.server.generated.ts @@ -40,6 +40,7 @@ export const apiHandlers = { jitsivideo: import("./jitsivideo/api"), larkcalendar: import("./larkcalendar/api"), linear: import("./linear/api"), + lyra: import("./lyra/api"), make: import("./make/api"), matomo: import("./matomo/api"), metapixel: import("./metapixel/api"), diff --git a/packages/app-store/bookerApps.metadata.generated.ts b/packages/app-store/bookerApps.metadata.generated.ts index 9c1623fe6b..16d9e3f77e 100644 --- a/packages/app-store/bookerApps.metadata.generated.ts +++ b/packages/app-store/bookerApps.metadata.generated.ts @@ -20,6 +20,7 @@ import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadat import insihts_config_json from "./insihts/config.json"; import jelly_config_json from "./jelly/config.json"; import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata"; +import lyra_config_json from "./lyra/config.json"; import matomo_config_json from "./matomo/config.json"; import metapixel_config_json from "./metapixel/config.json"; import mirotalk_config_json from "./mirotalk/config.json"; @@ -65,6 +66,7 @@ export const appStoreMetadata = { insihts: insihts_config_json, jelly: jelly_config_json, jitsivideo: jitsivideo__metadata_ts, + lyra: lyra_config_json, matomo: matomo_config_json, metapixel: metapixel_config_json, mirotalk: mirotalk_config_json, diff --git a/packages/app-store/lyra/DESCRIPTION.md b/packages/app-store/lyra/DESCRIPTION.md new file mode 100644 index 0000000000..d278a09e0d --- /dev/null +++ b/packages/app-store/lyra/DESCRIPTION.md @@ -0,0 +1,23 @@ +The meeting platform built for pros. One-click join, no downloads, and no visible bots watching your guests. While your competition switches between five tools, everything happens in Lyra. + +## Your Live AI Teammate + +Ask questions during the call and generate proposals before you hang up. No more copying transcripts into other AI platforms or waiting for recordings to process. + +## Zero-Touch Admin + +Follow-ups write themselves and next steps are captured instantly. Every meeting becomes searchable so you stop wasting your week on manual data entry. + +## Why Teams Switch to Lyra + +- **Stop wasting time on admin** — Proposals, next steps, and follow-ups are ready before you hang up +- **Faster rep ramp-up** — New reps lean on Lyra for live product questions and context, selling faster without months of training +- **Deals don't disappear** — Every conversation lives in one place. Ask Lyra to catch you up on deal history instantly +- **Accurate forecasting** — Deal reality is captured on every call. Know exactly where deals stand without guessing + +## How It Works + +1. Install Lyra from the Cal.com App Store +2. Sign in with your Lyra account to authorize Cal.com +3. Select Lyra as your conferencing option for event types +4. When bookings are made, Lyra meeting links are automatically generated diff --git a/packages/app-store/lyra/api/add.ts b/packages/app-store/lyra/api/add.ts new file mode 100644 index 0000000000..8d6822930f --- /dev/null +++ b/packages/app-store/lyra/api/add.ts @@ -0,0 +1,37 @@ +import { stringify } from "node:querystring"; +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; +import { defaultHandler } from "@calcom/lib/server/defaultHandler"; +import { defaultResponder } from "@calcom/lib/server/defaultResponder"; +import prisma from "@calcom/prisma"; +import type { NextApiRequest } from "next"; +import { encodeOAuthState } from "../../_utils/oauth/encodeOAuthState"; +import { getLyraAppKeys, LYRA_API_URL } from "../lib"; + +async function handler(req: NextApiRequest) { + await prisma.user.findFirstOrThrow({ + where: { + id: req.session?.user?.id, + }, + select: { + id: true, + }, + }); + + const { client_id } = await getLyraAppKeys(); + const state = encodeOAuthState(req); + + const params = { + response_type: "code", + client_id, + redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/lyra/callback`, + scope: "meeting.create", + state, + }; + const query = stringify(params); + const url = `${LYRA_API_URL}/oauth/authorize?${query}`; + return { url }; +} + +export default defaultHandler({ + GET: Promise.resolve({ default: defaultResponder(handler) }), +}); diff --git a/packages/app-store/lyra/api/callback.ts b/packages/app-store/lyra/api/callback.ts new file mode 100644 index 0000000000..1ed1d9f9fb --- /dev/null +++ b/packages/app-store/lyra/api/callback.ts @@ -0,0 +1,80 @@ +import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants"; +import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; +import prisma from "@calcom/prisma"; +import type { NextApiRequest, NextApiResponse } from "next"; +import getInstalledAppPath from "../../_utils/getInstalledAppPath"; +import createOAuthAppCredential from "../../_utils/oauth/createOAuthAppCredential"; +import { decodeOAuthState } from "../../_utils/oauth/decodeOAuthState"; +import { getLyraAppKeys, LYRA_API_URL } from "../lib"; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const state = decodeOAuthState(req); + + // Handle user denial or other OAuth errors gracefully + if (req.query.error) { + const errorReturnTo = getSafeRedirectUrl(state?.onErrorReturnTo) ?? "/apps/installed/conferencing"; + res.redirect(errorReturnTo); + return; + } + + const userId = req.session?.user.id; + if (!userId) { + return res.status(404).json({ message: "No user found" }); + } + + const { code } = req.query; + const { client_id, client_secret } = await getLyraAppKeys(); + + const redirectUri = `${WEBAPP_URL_FOR_OAUTH}/api/integrations/lyra/callback`; + + // Exchange authorization code for access token + const result = await fetch(`${LYRA_API_URL}/api/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "authorization_code", + code, + client_id, + client_secret, + redirect_uri: redirectUri, + }), + }); + + if (result.status !== 200) { + let errorMessage = "Something went wrong with the Lyra OAuth flow"; + try { + const responseBody = await result.json(); + errorMessage = responseBody.error || errorMessage; + } catch (_e) { + errorMessage = await result.clone().text(); + } + + res.status(400).json({ message: errorMessage }); + return; + } + + const responseBody = await result.json(); + + if (responseBody.error) { + res.status(400).json({ message: responseBody.error }); + return; + } + + // Convert expires_in (seconds) to expiry_date (absolute ms timestamp) + // This follows Cal.com's universal OAuth token schema + responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000); + delete responseBody.expires_in; + + // Remove any existing lyra_video credentials for this user to avoid duplicates + await prisma.credential.deleteMany({ + where: { type: "lyra_video", userId, appId: "lyra" }, + }); + + await createOAuthAppCredential({ appId: "lyra", type: "lyra_video" }, responseBody, req); + + res.redirect( + getSafeRedirectUrl(state?.returnTo) ?? getInstalledAppPath({ variant: "conferencing", slug: "lyra" }) + ); +} diff --git a/packages/app-store/lyra/api/index.ts b/packages/app-store/lyra/api/index.ts new file mode 100644 index 0000000000..eb12c1b4ed --- /dev/null +++ b/packages/app-store/lyra/api/index.ts @@ -0,0 +1,2 @@ +export { default as add } from "./add"; +export { default as callback } from "./callback"; diff --git a/packages/app-store/lyra/config.json b/packages/app-store/lyra/config.json new file mode 100644 index 0000000000..e5c54290c9 --- /dev/null +++ b/packages/app-store/lyra/config.json @@ -0,0 +1,22 @@ +{ + "/*": "Don't modify slug - If required, do it using cli edit command", + "name": "Lyra", + "title": "Lyra", + "slug": "lyra", + "type": "lyra_video", + "logo": "icon.svg", + "url": "https://lyra.so/", + "variant": "conferencing", + "categories": ["conferencing"], + "publisher": "Lyra", + "email": "support@lyra.so", + "description": "The meeting platform built for pros.", + "isOAuth": true, + "appData": { + "location": { + "linkType": "dynamic", + "type": "integrations:lyra", + "label": "Lyra Meeting" + } + } +} diff --git a/packages/app-store/lyra/index.ts b/packages/app-store/lyra/index.ts new file mode 100644 index 0000000000..0a0fcad49b --- /dev/null +++ b/packages/app-store/lyra/index.ts @@ -0,0 +1 @@ +export * as lib from "./lib"; diff --git a/packages/app-store/lyra/lib/VideoApiAdapter.ts b/packages/app-store/lyra/lib/VideoApiAdapter.ts new file mode 100644 index 0000000000..08560e20d6 --- /dev/null +++ b/packages/app-store/lyra/lib/VideoApiAdapter.ts @@ -0,0 +1,164 @@ +import { + APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME, +} from "@calcom/lib/constants"; +import logger from "@calcom/lib/logger"; +import { safeStringify } from "@calcom/lib/safeStringify"; +import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import type { CalendarEvent } from "@calcom/types/Calendar"; +import type { CredentialPayload } from "@calcom/types/Credential"; +import type { PartialReference } from "@calcom/types/EventManager"; +import type { VideoApiAdapter, VideoCallData } from "@calcom/types/VideoApiAdapter"; + +import { invalidateCredential } from "../../_utils/invalidateCredential"; +import { OAuthManager } from "../../_utils/oauth/OAuthManager"; +import { getTokenObjectFromCredential } from "../../_utils/oauth/getTokenObjectFromCredential"; +import { markTokenAsExpired } from "../../_utils/oauth/markTokenAsExpired"; +import { LYRA_API_URL } from "./constants"; +import { getLyraAppKeys } from "./getLyraAppKeys"; + +const log = logger.getSubLogger({ prefix: ["[lyra]"] }); + +const LyraVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter => { + const tokenResponse = getTokenObjectFromCredential(credential); + + const fetchLyraApi = async (endpoint: string, options?: RequestInit) => { + const auth = new OAuthManager({ + credentialSyncVariables: { + APP_CREDENTIAL_SHARING_ENABLED, + CREDENTIAL_SYNC_ENDPOINT, + CREDENTIAL_SYNC_SECRET, + CREDENTIAL_SYNC_SECRET_HEADER_NAME, + }, + resourceOwner: { + type: "user", + id: credential.userId, + }, + appSlug: "lyra", + currentTokenObject: tokenResponse, + fetchNewTokenObject: async ({ refreshToken }: { refreshToken: string | null }) => { + if (!refreshToken) { + return null; + } + const { client_id, client_secret } = await getLyraAppKeys(); + return fetch(`${LYRA_API_URL}/api/oauth/token`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id, + client_secret, + }), + }); + }, + isTokenObjectUnusable: async (response) => { + if (!response.ok) { + const responseToUseInCaseOfError = response.clone(); + let responseBody: { error?: string }; + try { + responseBody = await response.json(); + } catch (_e) { + log.error( + "Lyra isTokenObjectUnusable: failed to parse response as JSON", + safeStringify({ + status: response.status, + body: await responseToUseInCaseOfError.text().catch(() => ""), + }) + ); + return null; + } + if (responseBody.error === "invalid_grant") { + return { reason: responseBody.error }; + } + } + return null; + }, + isAccessTokenUnusable: async (response) => { + if (response.status === 401) { + return { reason: "access_token_expired" }; + } + return null; + }, + invalidateTokenObject: () => invalidateCredential(credential.id), + expireAccessToken: () => markTokenAsExpired(credential), + updateTokenObject: async (newTokenObject) => { + await prisma.credential.update({ + where: { + id: credential.id, + }, + data: { + key: newTokenObject as unknown as Prisma.InputJsonValue, + }, + }); + }, + }); + + const { json } = await auth.request({ + url: `${LYRA_API_URL}${endpoint}`, + options: { + method: "GET", + ...options, + headers: { + ...options?.headers, + }, + }, + }); + + return json; + }; + + return { + getAvailability: async () => { + return []; + }, + createMeeting: async (event: CalendarEvent): Promise => { + log.debug("Creating Lyra meeting", { title: event.title }); + + try { + const data = (await fetchLyraApi("/api/v1/meeting", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: event.title, + start: event.startTime, + attendees: event.attendees.map((attendee) => attendee.email), + }), + })) as { id: string; url: string; password?: string }; + + log.debug("Lyra meeting created", { id: data.id, url: data.url }); + + return { + type: "lyra_video", + id: data.id, + password: data.password || "", + url: data.url, + }; + } catch (err) { + log.error("Lyra meeting creation failed", safeStringify(err)); + throw new Error("Failed to create Lyra meeting"); + } + }, + deleteMeeting: async (uid: string): Promise => { + log.debug("Delete meeting called (no-op)", { uid }); + }, + updateMeeting: async (bookingRef: PartialReference, _event: CalendarEvent): Promise => { + log.debug("Update meeting called (no-op)", { uid: bookingRef.uid }); + return { + type: "lyra_video", + id: bookingRef.meetingId as string, + password: bookingRef.meetingPassword as string, + url: bookingRef.meetingUrl as string, + }; + }, + }; +}; + +export default LyraVideoApiAdapter; diff --git a/packages/app-store/lyra/lib/constants.ts b/packages/app-store/lyra/lib/constants.ts new file mode 100644 index 0000000000..1d3ed2460c --- /dev/null +++ b/packages/app-store/lyra/lib/constants.ts @@ -0,0 +1 @@ +export const LYRA_API_URL = "https://app.lyra.so"; diff --git a/packages/app-store/lyra/lib/getLyraAppKeys.ts b/packages/app-store/lyra/lib/getLyraAppKeys.ts new file mode 100644 index 0000000000..e2dd702213 --- /dev/null +++ b/packages/app-store/lyra/lib/getLyraAppKeys.ts @@ -0,0 +1,7 @@ +import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug"; +import { appKeysSchema } from "../zod"; + +export const getLyraAppKeys = async () => { + const appKeys = await getAppKeysFromSlug("lyra"); + return appKeysSchema.parse(appKeys); +}; diff --git a/packages/app-store/lyra/lib/index.ts b/packages/app-store/lyra/lib/index.ts new file mode 100644 index 0000000000..710a9dc21e --- /dev/null +++ b/packages/app-store/lyra/lib/index.ts @@ -0,0 +1,3 @@ +export { LYRA_API_URL } from "./constants"; +export { getLyraAppKeys } from "./getLyraAppKeys"; +export { default as VideoApiAdapter } from "./VideoApiAdapter"; diff --git a/packages/app-store/lyra/package.json b/packages/app-store/lyra/package.json new file mode 100644 index 0000000000..299d19cb9e --- /dev/null +++ b/packages/app-store/lyra/package.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@calcom/lyra", + "version": "0.0.0", + "private": true, + "main": "./index.ts", + "description": "The meeting platform built for pros.", + "dependencies": { + "@calcom/lib": "*", + "@calcom/prisma": "*" + }, + "devDependencies": { + "@calcom/types": "*" + } +} diff --git a/packages/app-store/lyra/static/icon.svg b/packages/app-store/lyra/static/icon.svg new file mode 100644 index 0000000000..7989978890 --- /dev/null +++ b/packages/app-store/lyra/static/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app-store/lyra/zod.ts b/packages/app-store/lyra/zod.ts new file mode 100644 index 0000000000..fe378bc4bc --- /dev/null +++ b/packages/app-store/lyra/zod.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const appKeysSchema = z.object({ + client_id: z.string().min(1), + client_secret: z.string().min(1), +}); + +export const appDataSchema = z.object({}); diff --git a/packages/app-store/video.adapters.generated.ts b/packages/app-store/video.adapters.generated.ts index 4df261a206..f1e0f05ffe 100644 --- a/packages/app-store/video.adapters.generated.ts +++ b/packages/app-store/video.adapters.generated.ts @@ -10,6 +10,7 @@ export const VideoApiAdapterMap = huddle01video: import("./huddle01video/lib/VideoApiAdapter"), jelly: import("./jelly/lib/VideoApiAdapter"), jitsivideo: import("./jitsivideo/lib/VideoApiAdapter"), + lyra: import("./lyra/lib/VideoApiAdapter"), nextcloudtalk: import("./nextcloudtalk/lib/VideoApiAdapter"), office365video: import("./office365video/lib/VideoApiAdapter"), shimmervideo: import("./shimmervideo/lib/VideoApiAdapter"), diff --git a/yarn.lock b/yarn.lock index 22d5112629..79f63934b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2776,6 +2776,16 @@ __metadata: languageName: unknown linkType: soft +"@calcom/lyra@workspace:packages/app-store/lyra": + version: 0.0.0-use.local + resolution: "@calcom/lyra@workspace:packages/app-store/lyra" + dependencies: + "@calcom/lib": "workspace:*" + "@calcom/prisma": "workspace:*" + "@calcom/types": "workspace:*" + languageName: unknown + linkType: soft + "@calcom/make@workspace:packages/app-store/make": version: 0.0.0-use.local resolution: "@calcom/make@workspace:packages/app-store/make"