From 789fb6c67ab3159537a3d4665a48479e5fa320bc Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 16 Oct 2025 16:49:54 +0530 Subject: [PATCH 1/4] attendee code updated --- Bot/app/api/meetings.py | 2 +- Bot/meeting_scheduler.py | 6 +- app/migrations/0012_white_patch.sql | 8 + app/migrations/meta/0012_snapshot.json | 496 +++++++ app/migrations/meta/_journal.json | 7 + app/package-lock.json | 1668 +++++++++++------------ app/package.json | 10 +- app/src/app/api/auth/google/route.ts | 24 +- app/src/app/api/oauth2callback/route.ts | 21 +- requirements.txt | 3 + 10 files changed, 1384 insertions(+), 861 deletions(-) create mode 100644 app/migrations/0012_white_patch.sql create mode 100644 app/migrations/meta/0012_snapshot.json diff --git a/Bot/app/api/meetings.py b/Bot/app/api/meetings.py index d8f75f5..b15e91a 100644 --- a/Bot/app/api/meetings.py +++ b/Bot/app/api/meetings.py @@ -25,7 +25,7 @@ router = APIRouter(prefix="/meetings", tags=["Meetings"]) -LINGO_API_URL = "https://lingo.ai.joshsoftware.com" +LINGO_API_URL = "http://localhost:3000" class LingoRequest(BaseModel): key: str diff --git a/Bot/meeting_scheduler.py b/Bot/meeting_scheduler.py index 6cb0db2..62a1edb 100755 --- a/Bot/meeting_scheduler.py +++ b/Bot/meeting_scheduler.py @@ -8,11 +8,11 @@ import os # --- Configuration --- -PG_USER = "postgres" -PG_PASS = "postgres" +PG_USER = "lingo" +PG_PASS = "password" PG_HOST = "localhost" PG_PORT = "5432" -DB_NAME = "lingo_ai" +DB_NAME = "lingo_dev" API_URL = "http://localhost:8001/meetings/" SQL_QUERY = 'SELECT "accessToken", "refreshToken", "botName" FROM bot;' PG_CONN_STRING = f"postgresql://{PG_USER}:{PG_PASS}@{PG_HOST}:{PG_PORT}/{DB_NAME}" diff --git a/app/migrations/0012_white_patch.sql b/app/migrations/0012_white_patch.sql new file mode 100644 index 0000000..906c2cf --- /dev/null +++ b/app/migrations/0012_white_patch.sql @@ -0,0 +1,8 @@ +CREATE TABLE "password_reset_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "username" text NOT NULL, + "token" text NOT NULL, + "expires_at" timestamp NOT NULL, + "createdAt" timestamp DEFAULT now(), + CONSTRAINT "password_reset_tokens_username_unique" UNIQUE("username") +); diff --git a/app/migrations/meta/0012_snapshot.json b/app/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..8c798ba --- /dev/null +++ b/app/migrations/meta/0012_snapshot.json @@ -0,0 +1,496 @@ +{ + "id": "825294f3-eee2-4424-8194-85e84dcd9ad8", + "prevId": "51c1645a-26bf-4446-97a1-c2049cca0522", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.bot": { + "name": "bot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "botName": { + "name": "botName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "botEmail": { + "name": "botEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "botHd": { + "name": "botHd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "botPicture": { + "name": "botPicture", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_user_id_user_id_fk": { + "name": "bot_user_id_user_id_fk", + "tableFrom": "bot", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_tokens_username_unique": { + "name": "password_reset_tokens_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.registrations": { + "name": "registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userName": { + "name": "userName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userEmail": { + "name": "userEmail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recordingCount": { + "name": "recordingCount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fileSizeLimitMB": { + "name": "fileSizeLimitMB", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "durationDays": { + "name": "durationDays", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_name_unique": { + "name": "subscriptions_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transcriptions": { + "name": "transcriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "translation": { + "name": "translation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segments": { + "name": "segments", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "detected_language": { + "name": "detected_language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "documentUrl": { + "name": "documentUrl", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "documentName": { + "name": "documentName", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "audioDuration": { + "name": "audioDuration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "transcriptions_user_id_user_id_fk": { + "name": "transcriptions_user_id_user_id_fk", + "tableFrom": "transcriptions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contactNumber": { + "name": "contactNumber", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscriptionId": { + "name": "subscriptionId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_subscriptionId_subscriptions_id_fk": { + "name": "user_subscriptionId_subscriptions_id_fk", + "tableFrom": "user", + "tableTo": "subscriptions", + "columnsFrom": [ + "subscriptionId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/app/migrations/meta/_journal.json b/app/migrations/meta/_journal.json index 54d72fe..dfb94fb 100644 --- a/app/migrations/meta/_journal.json +++ b/app/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1754562354517, "tag": "0011_lucky_genesis", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1759909053669, + "tag": "0012_white_patch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 3f88bca..a567f45 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -29,19 +29,19 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "dotenv": "^16.4.5", - "drizzle-orm": "^0.33.0", + "drizzle-orm": "0.44.6", "googleapis": "^148.0.0", "js-cookie": "^3.0.5", "lucia": "^3.2.0", "lucide-react": "^0.437.0", - "next": "15.1.6", + "next": "^15.5.4", "next-client-cookies": "^2.0.1", "next-themes": "^0.3.0", "nodemailer": "^7.0.5", "pg": "^8.13.3", "postgres": "^3.4.4", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-hook-form": "^7.53.0", "react-markdown": "^9.0.1", @@ -58,7 +58,7 @@ "@types/pg": "^8.11.11", "@types/react": "19.0.8", "@types/react-dom": "19.0.3", - "drizzle-kit": "^0.24.2", + "drizzle-kit": "^0.31.5", "eslint": "^8", "eslint-config-next": "15.1.6", "postcss": "^8", @@ -1016,9 +1016,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", "cpu": [ "ppc64" ], @@ -1029,7 +1029,7 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { @@ -1304,6 +1304,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", @@ -1321,6 +1338,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", @@ -1338,6 +1372,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", @@ -1463,31 +1514,28 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", - "license": "MIT", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", "dependencies": { - "@floating-ui/utils": "^0.2.9" + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", - "license": "MIT", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.9" + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", - "license": "MIT", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", @@ -1495,10 +1543,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", - "license": "MIT" + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" }, "node_modules/@hookform/resolvers": { "version": "3.9.0", @@ -1543,10 +1590,20 @@ "deprecated": "Use @eslint/object-schema instead", "dev": true }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", "cpu": [ "arm64" ], @@ -1562,13 +1619,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", "cpu": [ "x64" ], @@ -1584,13 +1641,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.3" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", "cpu": [ "arm64" ], @@ -1604,9 +1661,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", "cpu": [ "x64" ], @@ -1620,9 +1677,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", "cpu": [ "arm" ], @@ -1636,9 +1693,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", "cpu": [ "arm64" ], @@ -1651,10 +1708,26 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", "cpu": [ "s390x" ], @@ -1668,9 +1741,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", "cpu": [ "x64" ], @@ -1684,9 +1757,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", "cpu": [ "arm64" ], @@ -1700,9 +1773,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", "cpu": [ "x64" ], @@ -1716,9 +1789,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", "cpu": [ "arm" ], @@ -1734,13 +1807,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", "cpu": [ "arm64" ], @@ -1756,13 +1829,35 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", "cpu": [ "s390x" ], @@ -1778,13 +1873,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", "cpu": [ "x64" ], @@ -1800,13 +1895,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", "cpu": [ "arm64" ], @@ -1822,13 +1917,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", "cpu": [ "x64" ], @@ -1844,20 +1939,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.5.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1867,19 +1962,38 @@ } }, "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", - "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", "cpu": [ "ia32" ], @@ -1896,9 +2010,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", "cpu": [ "x64" ], @@ -2047,9 +2161,9 @@ } }, "node_modules/@next/env": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.6.tgz", - "integrity": "sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz", + "integrity": "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2093,9 +2207,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz", - "integrity": "sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz", + "integrity": "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==", "cpu": [ "arm64" ], @@ -2109,9 +2223,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz", - "integrity": "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz", + "integrity": "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==", "cpu": [ "x64" ], @@ -2125,9 +2239,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz", - "integrity": "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz", + "integrity": "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==", "cpu": [ "arm64" ], @@ -2141,9 +2255,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz", - "integrity": "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz", + "integrity": "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==", "cpu": [ "arm64" ], @@ -2157,9 +2271,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz", - "integrity": "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz", + "integrity": "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==", "cpu": [ "x64" ], @@ -2173,9 +2287,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz", - "integrity": "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz", + "integrity": "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==", "cpu": [ "x64" ], @@ -2189,9 +2303,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz", - "integrity": "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz", + "integrity": "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==", "cpu": [ "arm64" ], @@ -2205,9 +2319,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz", - "integrity": "sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz", + "integrity": "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==", "cpu": [ "x64" ], @@ -2760,7 +2874,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", - "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.0" }, @@ -2783,7 +2896,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-context": "1.1.0", @@ -2868,31 +2980,6 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -2911,7 +2998,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz", "integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==", - "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", @@ -2980,7 +3066,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", - "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-primitive": "2.0.0", @@ -3052,7 +3137,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.1.tgz", "integrity": "sha512-oa3mXRRVjHi6DZu/ghuzdylyjaMXLymx83irM7hTxutQbD+7IhPKdMdRHD26Rm+kHRrWcrUkkRPv5pd47a2xFQ==", - "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-collection": "1.1.0", @@ -3088,36 +3172,10 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.0", @@ -3149,7 +3207,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz", "integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==", - "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -3173,7 +3230,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz", "integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==", - "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -3197,7 +3253,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.1.0" }, @@ -3243,7 +3298,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-collection": "1.1.0", @@ -3332,7 +3386,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", - "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", @@ -3374,7 +3427,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", - "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-use-layout-effect": "1.1.0" @@ -3476,7 +3528,6 @@ "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", @@ -3565,7 +3616,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", - "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" @@ -3589,7 +3639,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.2.3" }, @@ -3612,7 +3661,6 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", - "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", @@ -3661,7 +3709,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "license": "MIT", "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -3853,7 +3900,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "license": "MIT", "dependencies": { "@radix-ui/rect": "1.1.0" }, @@ -3871,7 +3917,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.0" }, @@ -3889,7 +3934,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", - "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.0" }, @@ -3911,8 +3955,7 @@ "node_modules/@radix-ui/rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", - "license": "MIT" + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" }, "node_modules/@rtsao/scc": { "version": "1.1.0", @@ -4657,12 +4700,6 @@ "node": ">=18.0.0" } }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -5391,13 +5428,12 @@ } }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", - "license": "MIT", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -5505,17 +5541,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -5728,20 +5753,6 @@ "node": ">=6" } }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5758,17 +5769,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -5988,9 +5988,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "optional": true, "engines": { @@ -6049,15 +6049,15 @@ } }, "node_modules/drizzle-kit": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.24.2.tgz", - "integrity": "sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ==", + "version": "0.31.5", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.5.tgz", + "integrity": "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==", "dev": true, "license": "MIT", "dependencies": { - "@drizzle-team/brocli": "^0.10.1", + "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.19.7", + "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { @@ -6065,9 +6065,9 @@ } }, "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", "cpu": [ "arm" ], @@ -6078,13 +6078,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", "cpu": [ "arm64" ], @@ -6095,13 +6095,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", "cpu": [ "x64" ], @@ -6112,13 +6112,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", "cpu": [ "arm64" ], @@ -6129,13 +6129,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", "cpu": [ "x64" ], @@ -6146,13 +6146,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", "cpu": [ "arm64" ], @@ -6163,13 +6163,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", "cpu": [ "x64" ], @@ -6180,13 +6180,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", "cpu": [ "arm" ], @@ -6197,13 +6197,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", "cpu": [ "arm64" ], @@ -6214,13 +6214,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", "cpu": [ "ia32" ], @@ -6231,13 +6231,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", "cpu": [ "loong64" ], @@ -6248,13 +6248,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", "cpu": [ "mips64el" ], @@ -6265,13 +6265,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", "cpu": [ "ppc64" ], @@ -6282,13 +6282,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", "cpu": [ "riscv64" ], @@ -6299,13 +6299,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", "cpu": [ "s390x" ], @@ -6316,13 +6316,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", "cpu": [ "x64" ], @@ -6333,13 +6333,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", "cpu": [ "x64" ], @@ -6350,13 +6350,13 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", "cpu": [ "x64" ], @@ -6367,13 +6367,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", "cpu": [ "x64" ], @@ -6384,13 +6384,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", "cpu": [ "arm64" ], @@ -6401,13 +6401,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", "cpu": [ "ia32" ], @@ -6418,13 +6418,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", "cpu": [ "x64" ], @@ -6435,13 +6435,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/drizzle-kit/node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -6449,65 +6449,69 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" } }, "node_modules/drizzle-orm": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.33.0.tgz", - "integrity": "sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA==", + "version": "0.44.6", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.6.tgz", + "integrity": "sha512-uy6uarrrEOc9K1u5/uhBFJbdF5VJ5xQ/Yzbecw3eAYOunv5FDeYkR2m8iitocdHBOHbvorviKOW5GVw0U1j4LQ==", "license": "Apache-2.0", "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=3", - "@electric-sql/pglite": ">=0.1.1", - "@libsql/client": "*", - "@neondatabase/serverless": ">=0.1", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1", + "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", - "@types/react": ">=18", "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", - "expo-sqlite": ">=13.2.0", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", - "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, @@ -6524,6 +6528,9 @@ "@libsql/client": { "optional": true }, + "@libsql/client-wasm": { + "optional": true + }, "@neondatabase/serverless": { "optional": true }, @@ -6548,10 +6555,10 @@ "@types/pg": { "optional": true }, - "@types/react": { + "@types/sql.js": { "optional": true }, - "@types/sql.js": { + "@upstash/redis": { "optional": true }, "@vercel/postgres": { @@ -6569,6 +6576,9 @@ "expo-sqlite": { "optional": true }, + "gel": { + "optional": true + }, "knex": { "optional": true }, @@ -6587,9 +6597,6 @@ "prisma": { "optional": true }, - "react": { - "optional": true - }, "sql.js": { "optional": true }, @@ -6770,7 +6777,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7513,12 +7519,14 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -7965,7 +7973,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -8158,13 +8165,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "license": "MIT", - "optional": true - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -9572,15 +9572,13 @@ "dev": true }, "node_modules/next": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.1.6.tgz", - "integrity": "sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz", + "integrity": "sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==", "license": "MIT", "dependencies": { - "@next/env": "15.1.6", - "@swc/counter": "0.1.3", + "@next/env": "15.5.4", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -9592,19 +9590,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.1.6", - "@next/swc-darwin-x64": "15.1.6", - "@next/swc-linux-arm64-gnu": "15.1.6", - "@next/swc-linux-arm64-musl": "15.1.6", - "@next/swc-linux-x64-gnu": "15.1.6", - "@next/swc-linux-x64-musl": "15.1.6", - "@next/swc-win32-arm64-msvc": "15.1.6", - "@next/swc-win32-x64-msvc": "15.1.6", - "sharp": "^0.33.5" + "@next/swc-darwin-arm64": "15.5.4", + "@next/swc-darwin-x64": "15.5.4", + "@next/swc-linux-arm64-gnu": "15.5.4", + "@next/swc-linux-arm64-musl": "15.5.4", + "@next/swc-linux-x64-gnu": "15.5.4", + "@next/swc-linux-x64-musl": "15.5.4", + "@next/swc-win32-arm64-msvc": "15.5.4", + "@next/swc-win32-x64-msvc": "15.5.4", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -9695,10 +9693,9 @@ } }, "node_modules/nodemailer": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", - "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", - "license": "MIT-0", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==", "engines": { "node": ">=6.0.0" } @@ -10720,24 +10717,26 @@ ] }, "node_modules/react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", - "license": "MIT", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", - "license": "MIT", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dependencies": { - "scheduler": "^0.25.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^19.0.0" + "react": "^18.3.1" } }, "node_modules/react-dropzone": { @@ -10801,6 +10800,30 @@ "react": ">=18" } }, + "node_modules/react-remove-scroll": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", + "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.4", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-remove-scroll-bar": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", @@ -11117,10 +11140,12 @@ } }, "node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } }, "node_modules/semver": { "version": "7.7.2", @@ -11183,16 +11208,16 @@ } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -11201,25 +11226,28 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" } }, "node_modules/shebang-command": { @@ -11324,16 +11352,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/sonner": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz", @@ -11388,14 +11406,6 @@ "node": ">= 10.x" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -13227,9 +13237,9 @@ } }, "@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", "dev": true, "optional": true }, @@ -13345,6 +13355,13 @@ "dev": true, "optional": true }, + "@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "dev": true, + "optional": true + }, "@esbuild/netbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", @@ -13352,6 +13369,13 @@ "dev": true, "optional": true }, + "@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "dev": true, + "optional": true + }, "@esbuild/openbsd-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", @@ -13359,6 +13383,13 @@ "dev": true, "optional": true }, + "@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "dev": true, + "optional": true + }, "@esbuild/sunos-x64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", @@ -13426,34 +13457,34 @@ "dev": true }, "@floating-ui/core": { - "version": "1.6.9", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz", - "integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==", + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", "requires": { - "@floating-ui/utils": "^0.2.9" + "@floating-ui/utils": "^0.2.10" } }, "@floating-ui/dom": { - "version": "1.6.13", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz", - "integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "requires": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.9" + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" } }, "@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", "requires": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.4" } }, "@floating-ui/utils": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", - "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==" + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" }, "@hookform/resolvers": { "version": "3.9.0", @@ -13484,139 +13515,160 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, + "@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "optional": true + }, "@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", "optional": true, "requires": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.3" } }, "@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", "optional": true, "requires": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.3" } }, "@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", "optional": true }, "@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", "optional": true }, "@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", "optional": true }, "@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "optional": true + }, + "@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", "optional": true }, "@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", "optional": true }, "@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", "optional": true }, "@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", "optional": true }, "@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", "optional": true }, "@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", "optional": true, "requires": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.3" } }, "@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", "optional": true, "requires": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "optional": true, + "requires": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" } }, "@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", "optional": true, "requires": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.3" } }, "@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", "optional": true, "requires": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.3" } }, "@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", "optional": true, "requires": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" } }, "@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", "optional": true, "requires": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" } }, "@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", "optional": true, "requires": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.5.0" }, "dependencies": { "@emnapi/runtime": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", - "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", "optional": true, "requires": { "tslib": "^2.4.0" @@ -13624,16 +13676,22 @@ } } }, + "@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "optional": true + }, "@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", "optional": true }, "@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", "optional": true }, "@isaacs/cliui": { @@ -13746,9 +13804,9 @@ } }, "@next/env": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.6.tgz", - "integrity": "sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==" + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz", + "integrity": "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==" }, "@next/eslint-plugin-next": { "version": "15.1.6", @@ -13784,51 +13842,51 @@ } }, "@next/swc-darwin-arm64": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz", - "integrity": "sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz", + "integrity": "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==", "optional": true }, "@next/swc-darwin-x64": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz", - "integrity": "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz", + "integrity": "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==", "optional": true }, "@next/swc-linux-arm64-gnu": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz", - "integrity": "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz", + "integrity": "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==", "optional": true }, "@next/swc-linux-arm64-musl": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz", - "integrity": "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz", + "integrity": "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==", "optional": true }, "@next/swc-linux-x64-gnu": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz", - "integrity": "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz", + "integrity": "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==", "optional": true }, "@next/swc-linux-x64-musl": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz", - "integrity": "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz", + "integrity": "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==", "optional": true }, "@next/swc-win32-arm64-msvc": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz", - "integrity": "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz", + "integrity": "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==", "optional": true }, "@next/swc-win32-x64-msvc": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz", - "integrity": "sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz", + "integrity": "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==", "optional": true }, "@node-rs/argon2": { @@ -14145,20 +14203,6 @@ "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", "react-remove-scroll": "2.5.7" - }, - "dependencies": { - "react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "requires": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - } } }, "@radix-ui/react-direction": { @@ -14254,20 +14298,6 @@ "@radix-ui/react-use-callback-ref": "1.1.0", "aria-hidden": "^1.1.1", "react-remove-scroll": "2.5.7" - }, - "dependencies": { - "react-remove-scroll": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", - "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", - "requires": { - "react-remove-scroll-bar": "^2.3.4", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - } - } } }, "@radix-ui/react-popper": { @@ -15176,11 +15206,6 @@ "tslib": "^2.6.2" } }, - "@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==" - }, "@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -15680,12 +15705,12 @@ "dev": true }, "axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "requires": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -15754,14 +15779,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, - "busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "requires": { - "streamsearch": "^1.1.0" - } - }, "call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -15893,16 +15910,6 @@ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" }, - "color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "optional": true, - "requires": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - } - }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -15916,16 +15923,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, - "color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "optional": true, - "requires": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, "combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -16069,9 +16066,9 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" }, "detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "optional": true }, "detect-node-es": { @@ -16112,208 +16109,211 @@ "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==" }, "drizzle-kit": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.24.2.tgz", - "integrity": "sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ==", + "version": "0.31.5", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.5.tgz", + "integrity": "sha512-+CHgPFzuoTQTt7cOYCV6MOw2w8vqEn/ap1yv4bpZOWL03u7rlVRQhUY0WYT3rHsgVTXwYQDZaSUJSQrMBUKuWg==", "dev": true, "requires": { - "@drizzle-team/brocli": "^0.10.1", + "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.19.7", + "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "dependencies": { "@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", "dev": true, "optional": true }, "@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", "dev": true, "optional": true }, "@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", "dev": true, "optional": true }, "@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", "dev": true, "optional": true }, "@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", "dev": true, "optional": true }, "@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", "dev": true, "optional": true }, "@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", "dev": true, "optional": true }, "@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", "dev": true, "optional": true }, "@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", "dev": true, "optional": true }, "@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", "dev": true, "optional": true }, "@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", "dev": true, "optional": true }, "@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", "dev": true, "optional": true }, "@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", "dev": true, "optional": true }, "@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", "dev": true, "optional": true }, "@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", "dev": true, "optional": true }, "@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", "dev": true, "optional": true }, "@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", "dev": true, "optional": true }, "@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", "dev": true, "optional": true }, "@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", "dev": true, "optional": true }, "@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", "dev": true, "optional": true }, "@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", "dev": true, "optional": true }, "@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", "dev": true, "optional": true }, "esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "dev": true, "requires": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" } } } }, "drizzle-orm": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.33.0.tgz", - "integrity": "sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA==", + "version": "0.44.6", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.44.6.tgz", + "integrity": "sha512-uy6uarrrEOc9K1u5/uhBFJbdF5VJ5xQ/Yzbecw3eAYOunv5FDeYkR2m8iitocdHBOHbvorviKOW5GVw0U1j4LQ==", "requires": {} }, "dunder-proto": { @@ -16459,7 +16459,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "requires": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", @@ -16990,12 +16989,14 @@ } }, "form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "requires": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, @@ -17291,7 +17292,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "requires": { "has-symbols": "^1.0.3" } @@ -17427,12 +17427,6 @@ "get-intrinsic": "^1.2.6" } }, - "is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", - "optional": true - }, "is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -18320,25 +18314,23 @@ "dev": true }, "next": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.1.6.tgz", - "integrity": "sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==", - "requires": { - "@next/env": "15.1.6", - "@next/swc-darwin-arm64": "15.1.6", - "@next/swc-darwin-x64": "15.1.6", - "@next/swc-linux-arm64-gnu": "15.1.6", - "@next/swc-linux-arm64-musl": "15.1.6", - "@next/swc-linux-x64-gnu": "15.1.6", - "@next/swc-linux-x64-musl": "15.1.6", - "@next/swc-win32-arm64-msvc": "15.1.6", - "@next/swc-win32-x64-msvc": "15.1.6", - "@swc/counter": "0.1.3", + "version": "15.5.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz", + "integrity": "sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==", + "requires": { + "@next/env": "15.5.4", + "@next/swc-darwin-arm64": "15.5.4", + "@next/swc-darwin-x64": "15.5.4", + "@next/swc-linux-arm64-gnu": "15.5.4", + "@next/swc-linux-arm64-musl": "15.5.4", + "@next/swc-linux-x64-gnu": "15.5.4", + "@next/swc-linux-x64-musl": "15.5.4", + "@next/swc-win32-arm64-msvc": "15.5.4", + "@next/swc-win32-x64-msvc": "15.5.4", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", - "sharp": "^0.33.5", + "sharp": "^0.34.3", "styled-jsx": "5.1.6" }, "dependencies": { @@ -18377,9 +18369,9 @@ } }, "nodemailer": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", - "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==" + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==" }, "normalize-path": { "version": "3.0.0", @@ -18991,16 +18983,20 @@ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, "react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "requires": { + "loose-envify": "^1.1.0" + } }, "react-dom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "requires": { - "scheduler": "^0.25.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" } }, "react-dropzone": { @@ -19041,6 +19037,18 @@ "vfile": "^6.0.0" } }, + "react-remove-scroll": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz", + "integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==", + "requires": { + "react-remove-scroll-bar": "^2.3.4", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + } + }, "react-remove-scroll-bar": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", @@ -19228,9 +19236,12 @@ } }, "scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "requires": { + "loose-envify": "^1.1.0" + } }, "semver": { "version": "7.7.2", @@ -19276,33 +19287,36 @@ } }, "sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", "optional": true, "requires": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5", - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" } }, "shebang-command": { @@ -19367,15 +19381,6 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" }, - "simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "optional": true, - "requires": { - "is-arrayish": "^0.3.1" - } - }, "sonner": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz", @@ -19413,11 +19418,6 @@ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" }, - "streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" - }, "string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/app/package.json b/app/package.json index 721bae2..7f41524 100644 --- a/app/package.json +++ b/app/package.json @@ -30,19 +30,19 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "dotenv": "^16.4.5", - "drizzle-orm": "^0.33.0", + "drizzle-orm": "0.44.6", "googleapis": "^148.0.0", "js-cookie": "^3.0.5", "lucia": "^3.2.0", "lucide-react": "^0.437.0", - "next": "15.1.6", + "next": "^15.5.4", "next-client-cookies": "^2.0.1", "next-themes": "^0.3.0", "nodemailer": "^7.0.5", "pg": "^8.13.3", "postgres": "^3.4.4", - "react": "19.0.0", - "react-dom": "19.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-dropzone": "^14.2.3", "react-hook-form": "^7.53.0", "react-markdown": "^9.0.1", @@ -59,7 +59,7 @@ "@types/pg": "^8.11.11", "@types/react": "19.0.8", "@types/react-dom": "19.0.3", - "drizzle-kit": "^0.24.2", + "drizzle-kit": "^0.31.5", "eslint": "^8", "eslint-config-next": "15.1.6", "postcss": "^8", diff --git a/app/src/app/api/auth/google/route.ts b/app/src/app/api/auth/google/route.ts index 3b92b91..c1eb6a3 100644 --- a/app/src/app/api/auth/google/route.ts +++ b/app/src/app/api/auth/google/route.ts @@ -1,24 +1,28 @@ // src/app/api/auth/google/route.ts + import { google } from 'googleapis'; import { NextRequest } from 'next/server'; -// Create OAuth client -const oauth2Client = new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID, - process.env.GOOGLE_CLIENT_SECRET, - process.env.GOOGLE_REDIRECT_URI // Should be http://localhost:3000/api/oauth2callback -); - export async function GET(request: NextRequest) { - // Get the origin for CORS - const origin = request.headers.get('origin') || '*'; + // Compute origin for CORS and redirect URI + const origin = request.headers.get('origin') || request.nextUrl.origin; + + // Build redirect URI dynamically to ensure it's always provided and matches the request origin + const redirectUri = `${request.nextUrl.origin}/api/oauth2callback`; + + // Create OAuth client per-request so redirect_uri is set correctly + const oauth2Client = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + redirectUri + ); const authUrl = oauth2Client.generateAuthUrl({ access_type: 'offline', scope: [ 'https://www.googleapis.com/auth/calendar.readonly', 'https://www.googleapis.com/auth/userinfo.profile', - 'https://www.googleapis.com/auth/userinfo.email' // Add this scope to get email + 'https://www.googleapis.com/auth/userinfo.email' // Add this scope to get email ], prompt: 'consent', }); diff --git a/app/src/app/api/oauth2callback/route.ts b/app/src/app/api/oauth2callback/route.ts index 0b07f18..0a6b358 100644 --- a/app/src/app/api/oauth2callback/route.ts +++ b/app/src/app/api/oauth2callback/route.ts @@ -10,13 +10,18 @@ import { botTable } from "@/db/schema"; import { eq } from "drizzle-orm"; import { lucia } from "@/auth"; -const oauth2Client = new google.auth.OAuth2( - process.env.GOOGLE_CLIENT_ID, - process.env.GOOGLE_CLIENT_SECRET, - process.env.GOOGLE_REDIRECT_URI -); - export async function GET(request: Request) { + // Build redirect URI from request origin to match the one used when generating the auth URL + const url = new URL(request.url); + const redirectUri = `${url.origin}/api/oauth2callback`; + + const oauth2Client = new google.auth.OAuth2( + process.env.GOOGLE_CLIENT_ID, + process.env.GOOGLE_CLIENT_SECRET, + redirectUri + ); + + // Continue handling the request // Get the code from the query parameters const { searchParams } = new URL(request.url); const code = searchParams.get("code"); @@ -29,8 +34,8 @@ export async function GET(request: Request) { } try { - // Exchange the code for tokens - const { tokens } = await oauth2Client.getToken(code); + // Exchange the code for tokens + const { tokens } = await oauth2Client.getToken(code); // Set the credentials on the OAuth client oauth2Client.setCredentials(tokens); diff --git a/requirements.txt b/requirements.txt index ab32383..bfc4573 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,6 @@ python-dotenv whisper-timestamped silero-vad fastapi_versionizer +sarvamai +dateparser +psycopg2-binary \ No newline at end of file From 0de6de707f6a5f7103c7e522b322f654dfc50897 Mon Sep 17 00:00:00 2001 From: Utkarsh Mukul Date: Mon, 3 Nov 2025 12:59:42 +0530 Subject: [PATCH 2/4] Dockerize lingo-ai setup --- app/Dockerfile | 10 ++++++++-- docker-compose.yml | 29 ++++++++++++++++++++--------- service/Dockerfile | 5 +++-- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/app/Dockerfile b/app/Dockerfile index 2686495..848fa23 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -2,12 +2,18 @@ FROM node:22-bullseye-slim WORKDIR /lingo/app -COPY package.json package-lock.json* ./ +# Copy package files first +COPY package*.json ./ +# Ensure we install dev dependencies +ENV NODE_ENV=development RUN npm install --force +# Copy rest of the app + COPY . . EXPOSE 3000 -CMD [ "npm", "run", "dev" ] +CMD ["npm", "run", "dev"] + diff --git a/docker-compose.yml b/docker-compose.yml index 3221d5c..70ae207 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: - nextjs: - container_name: app + lingo-ui: + container_name: lingo-ui build: context: ./app dockerfile: Dockerfile @@ -8,15 +8,15 @@ services: - ./app/.env ports: - "3000:3000" + networks: + - lingo_network depends_on: - fastapi - - postgres - volumes: - - ./app:/lingo/app + #- postgres - fastapi: - container_name: service + lingo-ai: + container_name: lingo-ai build: context: ./service dockerfile: Dockerfile @@ -26,10 +26,12 @@ services: - ollama ports: - "8000:8000" + networks: + - lingo_network volumes: - ./service:/lingo/service - postgres: - container_name: lingo-db + postgres-lingo-db: + container_name: postgres-lingo-db image: postgres:16 restart: always environment: @@ -38,6 +40,8 @@ services: POSTGRES_DB_FILE: /run/secrets/db_name ports: - "5432:5432" + networks: + - lingo_network volumes: - postgres_data:/var/lib/postgresql/data secrets: @@ -51,6 +55,8 @@ services: restart: always ports: - "11434:11434" + networks: + - lingo_network volumes: - ollama_data:/root/.ollama @@ -62,6 +68,11 @@ secrets: db_password: file: ./secrets/pg/db_password.txt +networks: + lingo_network: + name: lingo_network + driver: bridge + volumes: postgres_data: ollama_data: diff --git a/service/Dockerfile b/service/Dockerfile index 1de7a4c..616b04d 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.9-slim WORKDIR /lingo/service RUN apt-get update && \ - apt-get install -y --no-install-recommends ffmpeg gcc build-essential && \ + apt-get install -y --no-install-recommends ffmpeg gcc build-essential redis-server && \ rm -rf /var/lib/apt/lists/* COPY requirements.txt . @@ -15,4 +15,5 @@ COPY . . EXPOSE 8000 -CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD service redis-server start && uvicorn main:app --reload --host 0.0.0.0 --port 8000 +#CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"] From 0a1773462b65d866cb065f03762c1948f4e95b54 Mon Sep 17 00:00:00 2001 From: Utkarsh Mukul Date: Mon, 3 Nov 2025 15:31:13 +0530 Subject: [PATCH 3/4] Replaced fastapi with lingo-ai --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 70ae207..cc93250 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - lingo_network depends_on: - - fastapi + - lingo-ai #- postgres lingo-ai: From 3bede0a6ff9af4d554c6f2a8edb94b43860c5648 Mon Sep 17 00:00:00 2001 From: Yash Maheshwari Date: Tue, 4 Nov 2025 16:39:53 +0530 Subject: [PATCH 4/4] removed attendee, lingo bot and lingo-ui code as it is moved to separate repository --- Bot/.gitignore | 176 - Bot/Dockerfile | 20 - Bot/Makefile | 16 - Bot/README.md | 1 - Bot/app.py | 267 - Bot/app/__init__.py | 3 - Bot/app/api/auth.py | 32 - Bot/app/api/meetings.py | 234 - Bot/app/api/scheduler.py | 86 - Bot/app/core/config.py | 15 - Bot/app/core/scheduler.py | 4 - Bot/app/helper/bot_actions.py | 43 - Bot/app/helper/generate_presigned_url.py | 45 - Bot/app/helper/save_transaction.py | 30 - Bot/app/log_config.py | 35 - Bot/app/main.py | 31 - Bot/app/models/schemas.py | 10 - Bot/docker-compose.yml | 30 - Bot/meeting_scheduler.py | 80 - Bot/requirements.txt | 14 - Bot/run.py | 4 - attendee/.dockerignore | 92 - attendee/.github/workflows/ci.yml | 92 - attendee/.gitignore | 95 - attendee/.pre-commit-config.yaml | 9 - attendee/.python-version | 1 - attendee/CONTRIBUTING.md | 79 - attendee/Dockerfile | 94 - attendee/LICENSE | 44 - attendee/Procfile | 2 - attendee/README.md | 129 - attendee/accounts/__init__.py | 0 attendee/accounts/admin.py | 1 - attendee/accounts/apps.py | 6 - attendee/accounts/forms.py | 0 attendee/accounts/migrations/0001_initial.py | 55 - attendee/accounts/migrations/__init__.py | 0 attendee/accounts/models.py | 44 - .../templates/account/email_confirm.html | 81 - .../accounts/templates/account/login.html | 66 - .../partials/_google_signin_button.html | 139 - .../templates/account/password_reset.html | 61 - .../account/password_reset_done.html | 39 - .../account/password_reset_from_key.html | 83 - .../account/password_reset_from_key_done.html | 36 - .../accounts/templates/account/signup.html | 94 - .../templates/account/verification_sent.html | 39 - .../socialaccount/login_cancelled.html | 45 - attendee/accounts/tests.py | 1 - attendee/accounts/views.py | 19 - attendee/attendee/__init__.py | 3 - attendee/attendee/asgi.py | 16 - attendee/attendee/celery.py | 26 - attendee/attendee/settings/__init__.py | 0 attendee/attendee/settings/base.py | 204 - attendee/attendee/settings/development.py | 39 - attendee/attendee/settings/production-gke.py | 69 - attendee/attendee/settings/production.py | 43 - attendee/attendee/settings/staging-gke.py | 59 - attendee/attendee/settings/test.py | 17 - attendee/attendee/urls.py | 59 - attendee/attendee/wsgi.py | 16 - attendee/bots/__init__.py | 0 attendee/bots/admin.py | 60 - attendee/bots/apps.py | 6 - attendee/bots/authentication.py | 30 - attendee/bots/bot_adapter.py | 19 - attendee/bots/bot_controller/__init__.py | 3 - .../bot_controller/audio_output_manager.py | 91 - .../automatic_leave_configuration.py | 15 - .../bots/bot_controller/bot_controller.py | 735 -- .../bot_controller/closed_caption_manager.py | 77 - attendee/bots/bot_controller/file_uploader.py | 64 - .../bots/bot_controller/gstreamer_pipeline.py | 318 - .../individual_audio_input_manager.py | 110 - .../bot_controller/pipeline_configuration.py | 64 - attendee/bots/bot_controller/rtmp_client.py | 98 - .../bots/bot_controller/streaming_uploader.py | 97 - .../bots/bot_controller/text_to_speech.py | 69 - attendee/bots/bot_pod_creator/__init__.py | 3 - .../bots/bot_pod_creator/bot_pod_creator.py | 145 - attendee/bots/bots_api_urls.py | 45 - attendee/bots/bots_api_views.py | 668 -- .../bots/google_meet_bot_adapter/__init__.py | 3 - .../google_meet_bot_adapter.py | 12 - .../google_meet_chromedriver_payload.js | 1125 --- .../google_meet_ui_methods.py | 211 - .../commands/clean_up_completed_bot_pods.py | 45 - .../bots/management/commands/launch_bot.py | 60 - attendee/bots/management/commands/run_bot.py | 23 - .../bots/management/commands/setup_test_db.py | 99 - .../terminate_bots_with_heartbeat_timeout.py | 82 - attendee/bots/migrations/0001_initial.py | 151 - ...0002_mediablob_botmediarequest_and_more.py | 47 - ...id_state_substate_combinations_and_more.py | 74 - ...pe_event_sub_type_combinations_and_more.py | 26 - ...e_source_utterance_source_uuid_and_more.py | 28 - attendee/bots/migrations/0006_bot_settings.py | 18 - ...pe_event_sub_type_combinations_and_more.py | 26 - ...pe_event_sub_type_combinations_and_more.py | 26 - ...pe_event_sub_type_combinations_and_more.py | 26 - ..._botmediarequest_text_to_speak_and_more.py | 29 - .../0011_alter_credentials_credential_type.py | 18 - .../0012_botdebugscreenshot_and_more.py | 52 - ...pe_event_sub_type_combinations_and_more.py | 26 - .../migrations/0014_utterance_sample_rate.py | 18 - ...tate_alter_botevent_event_type_and_more.py | 33 - ...pe_event_sub_type_combinations_and_more.py | 36 - attendee/bots/migrations/__init__.py | 0 attendee/bots/models.py | 1042 --- attendee/bots/projects_urls.py | 59 - attendee/bots/projects_views.py | 220 - attendee/bots/serializers.py | 387 - attendee/bots/tasks/__init__.py | 8 - attendee/bots/tasks/process_utterance_task.py | 64 - attendee/bots/tasks/run_bot_task.py | 37 - attendee/bots/teams_bot_adapter/__init__.py | 3 - .../teams_bot_adapter/teams_bot_adapter.py | 12 - .../teams_chromedriver_payload.js | 1462 ---- .../teams_bot_adapter/teams_ui_methods.py | 149 - .../partials/api_key_created_modal.html | 58 - .../partials/deepgram_credentials.html | 79 - .../partials/google_tts_credentials.html | 87 - .../partials/video_tutorial_modal.html | 33 - .../projects/partials/zoom_credentials.html | 90 - .../templates/projects/project_api_keys.html | 124 - .../projects/project_bot_detail.html | 300 - .../bots/templates/projects/project_bots.html | 69 - .../templates/projects/project_dashboard.html | 75 - .../templates/projects/project_settings.html | 16 - attendee/bots/templates/projects/sidebar.html | 116 - attendee/bots/templatetags/__init__.py | 0 attendee/bots/templatetags/bot_filters.py | 59 - attendee/bots/tests/__init__.py | 0 attendee/bots/tests/mock_data.py | 79 - attendee/bots/tests/test_can_open_chrome.py | 43 - attendee/bots/tests/test_google_meet_bot.py | 361 - attendee/bots/tests/test_zoom_bot.py | 1965 ----- attendee/bots/utils.py | 290 - attendee/bots/web_bot_adapter/__init__.py | 3 - attendee/bots/web_bot_adapter/ui_methods.py | 26 - .../bots/web_bot_adapter/web_bot_adapter.py | 556 -- attendee/bots/zoom_bot_adapter/__init__.py | 3 - .../zoom_bot_adapter/video_input_manager.py | 302 - .../bots/zoom_bot_adapter/zoom_bot_adapter.py | 652 -- attendee/dev.docker-compose.yaml | 58 - attendee/docs/openapi.yml | 641 -- attendee/heroku.yml | 11 - attendee/init_env.py | 25 - attendee/manage.py | 19 - attendee/pyproject.toml | 55 - attendee/requirements.txt | 89 - attendee/scalar.config.json | 11 - attendee/static/images/favicon_white.png | Bin 589 -> 0 bytes attendee/static/images/logo.svg | 13 - attendee/static/images/logo_with_text.svg | 14 - attendee/templates/base.html | 63 - cli/app.py | 112 - cli/config.py | 9 - cli/download_model.py | 25 - cli/download_whisper.py | 9 - cli/extract_entities.py | 84 - cli/load_model.py | 18 - cli/logger.py | 16 - cli/logging.connf | 27 - lingo-ui/README.md | 73 - lingo-ui/bun.lockb | Bin 198351 -> 0 bytes lingo-ui/components.json | 20 - lingo-ui/eslint.config.js | 29 - lingo-ui/index.html | 24 - lingo-ui/package-lock.json | 7108 ----------------- lingo-ui/package.json | 83 - lingo-ui/postcss.config.js | 6 - lingo-ui/public/favicon.ico | Bin 7645 -> 0 bytes lingo-ui/public/placeholder.svg | 1 - lingo-ui/public/robots.txt | 14 - lingo-ui/src/App.css | 42 - lingo-ui/src/App.tsx | 27 - lingo-ui/src/components/CallToAction.tsx | 47 - lingo-ui/src/components/Features.tsx | 74 - lingo-ui/src/components/Footer.tsx | 52 - lingo-ui/src/components/Hero.tsx | 61 - lingo-ui/src/components/Navigation.tsx | 40 - lingo-ui/src/components/UseCases.tsx | 97 - lingo-ui/src/components/ui/accordion.tsx | 56 - lingo-ui/src/components/ui/alert-dialog.tsx | 139 - lingo-ui/src/components/ui/alert.tsx | 59 - lingo-ui/src/components/ui/aspect-ratio.tsx | 5 - lingo-ui/src/components/ui/avatar.tsx | 48 - lingo-ui/src/components/ui/badge.tsx | 36 - lingo-ui/src/components/ui/breadcrumb.tsx | 115 - lingo-ui/src/components/ui/button.tsx | 56 - lingo-ui/src/components/ui/calendar.tsx | 64 - lingo-ui/src/components/ui/card.tsx | 79 - lingo-ui/src/components/ui/carousel.tsx | 260 - lingo-ui/src/components/ui/chart.tsx | 363 - lingo-ui/src/components/ui/checkbox.tsx | 28 - lingo-ui/src/components/ui/collapsible.tsx | 9 - lingo-ui/src/components/ui/command.tsx | 153 - lingo-ui/src/components/ui/context-menu.tsx | 198 - lingo-ui/src/components/ui/dialog.tsx | 120 - lingo-ui/src/components/ui/drawer.tsx | 116 - lingo-ui/src/components/ui/dropdown-menu.tsx | 198 - lingo-ui/src/components/ui/form.tsx | 176 - lingo-ui/src/components/ui/hover-card.tsx | 27 - lingo-ui/src/components/ui/input-otp.tsx | 69 - lingo-ui/src/components/ui/input.tsx | 22 - lingo-ui/src/components/ui/label.tsx | 24 - lingo-ui/src/components/ui/menubar.tsx | 234 - .../src/components/ui/navigation-menu.tsx | 128 - lingo-ui/src/components/ui/pagination.tsx | 117 - lingo-ui/src/components/ui/popover.tsx | 29 - lingo-ui/src/components/ui/progress.tsx | 26 - lingo-ui/src/components/ui/radio-group.tsx | 42 - lingo-ui/src/components/ui/resizable.tsx | 43 - lingo-ui/src/components/ui/scroll-area.tsx | 46 - lingo-ui/src/components/ui/select.tsx | 158 - lingo-ui/src/components/ui/separator.tsx | 29 - lingo-ui/src/components/ui/sheet.tsx | 131 - lingo-ui/src/components/ui/sidebar.tsx | 761 -- lingo-ui/src/components/ui/skeleton.tsx | 15 - lingo-ui/src/components/ui/slider.tsx | 26 - lingo-ui/src/components/ui/sonner.tsx | 29 - lingo-ui/src/components/ui/switch.tsx | 27 - lingo-ui/src/components/ui/table.tsx | 117 - lingo-ui/src/components/ui/tabs.tsx | 53 - lingo-ui/src/components/ui/textarea.tsx | 24 - lingo-ui/src/components/ui/toast.tsx | 127 - lingo-ui/src/components/ui/toaster.tsx | 33 - lingo-ui/src/components/ui/toggle-group.tsx | 59 - lingo-ui/src/components/ui/toggle.tsx | 43 - lingo-ui/src/components/ui/tooltip.tsx | 28 - lingo-ui/src/components/ui/use-toast.ts | 3 - lingo-ui/src/hooks/use-mobile.tsx | 19 - lingo-ui/src/hooks/use-toast.ts | 191 - lingo-ui/src/index.css | 80 - lingo-ui/src/lib/utils.ts | 6 - lingo-ui/src/main.tsx | 5 - lingo-ui/src/pages/Index.tsx | 26 - lingo-ui/src/pages/NotFound.tsx | 27 - lingo-ui/src/vite-env.d.ts | 1 - lingo-ui/tailwind.config.ts | 96 - lingo-ui/tsconfig.app.json | 30 - lingo-ui/tsconfig.json | 19 - lingo-ui/tsconfig.node.json | 22 - lingo-ui/vite.config.ts | 22 - 246 files changed, 31402 deletions(-) delete mode 100644 Bot/.gitignore delete mode 100644 Bot/Dockerfile delete mode 100644 Bot/Makefile delete mode 100644 Bot/README.md delete mode 100644 Bot/app.py delete mode 100644 Bot/app/__init__.py delete mode 100644 Bot/app/api/auth.py delete mode 100644 Bot/app/api/meetings.py delete mode 100644 Bot/app/api/scheduler.py delete mode 100644 Bot/app/core/config.py delete mode 100644 Bot/app/core/scheduler.py delete mode 100644 Bot/app/helper/bot_actions.py delete mode 100644 Bot/app/helper/generate_presigned_url.py delete mode 100644 Bot/app/helper/save_transaction.py delete mode 100644 Bot/app/log_config.py delete mode 100644 Bot/app/main.py delete mode 100644 Bot/app/models/schemas.py delete mode 100644 Bot/docker-compose.yml delete mode 100755 Bot/meeting_scheduler.py delete mode 100644 Bot/requirements.txt delete mode 100644 Bot/run.py delete mode 100644 attendee/.dockerignore delete mode 100644 attendee/.github/workflows/ci.yml delete mode 100644 attendee/.gitignore delete mode 100644 attendee/.pre-commit-config.yaml delete mode 100644 attendee/.python-version delete mode 100644 attendee/CONTRIBUTING.md delete mode 100644 attendee/Dockerfile delete mode 100644 attendee/LICENSE delete mode 100644 attendee/Procfile delete mode 100644 attendee/README.md delete mode 100644 attendee/accounts/__init__.py delete mode 100644 attendee/accounts/admin.py delete mode 100644 attendee/accounts/apps.py delete mode 100644 attendee/accounts/forms.py delete mode 100644 attendee/accounts/migrations/0001_initial.py delete mode 100644 attendee/accounts/migrations/__init__.py delete mode 100644 attendee/accounts/models.py delete mode 100644 attendee/accounts/templates/account/email_confirm.html delete mode 100644 attendee/accounts/templates/account/login.html delete mode 100644 attendee/accounts/templates/account/partials/_google_signin_button.html delete mode 100644 attendee/accounts/templates/account/password_reset.html delete mode 100644 attendee/accounts/templates/account/password_reset_done.html delete mode 100644 attendee/accounts/templates/account/password_reset_from_key.html delete mode 100644 attendee/accounts/templates/account/password_reset_from_key_done.html delete mode 100644 attendee/accounts/templates/account/signup.html delete mode 100644 attendee/accounts/templates/account/verification_sent.html delete mode 100644 attendee/accounts/templates/socialaccount/login_cancelled.html delete mode 100644 attendee/accounts/tests.py delete mode 100644 attendee/accounts/views.py delete mode 100644 attendee/attendee/__init__.py delete mode 100644 attendee/attendee/asgi.py delete mode 100644 attendee/attendee/celery.py delete mode 100644 attendee/attendee/settings/__init__.py delete mode 100644 attendee/attendee/settings/base.py delete mode 100644 attendee/attendee/settings/development.py delete mode 100644 attendee/attendee/settings/production-gke.py delete mode 100644 attendee/attendee/settings/production.py delete mode 100644 attendee/attendee/settings/staging-gke.py delete mode 100644 attendee/attendee/settings/test.py delete mode 100644 attendee/attendee/urls.py delete mode 100644 attendee/attendee/wsgi.py delete mode 100644 attendee/bots/__init__.py delete mode 100644 attendee/bots/admin.py delete mode 100644 attendee/bots/apps.py delete mode 100644 attendee/bots/authentication.py delete mode 100644 attendee/bots/bot_adapter.py delete mode 100644 attendee/bots/bot_controller/__init__.py delete mode 100644 attendee/bots/bot_controller/audio_output_manager.py delete mode 100644 attendee/bots/bot_controller/automatic_leave_configuration.py delete mode 100644 attendee/bots/bot_controller/bot_controller.py delete mode 100644 attendee/bots/bot_controller/closed_caption_manager.py delete mode 100644 attendee/bots/bot_controller/file_uploader.py delete mode 100644 attendee/bots/bot_controller/gstreamer_pipeline.py delete mode 100644 attendee/bots/bot_controller/individual_audio_input_manager.py delete mode 100644 attendee/bots/bot_controller/pipeline_configuration.py delete mode 100644 attendee/bots/bot_controller/rtmp_client.py delete mode 100644 attendee/bots/bot_controller/streaming_uploader.py delete mode 100644 attendee/bots/bot_controller/text_to_speech.py delete mode 100644 attendee/bots/bot_pod_creator/__init__.py delete mode 100644 attendee/bots/bot_pod_creator/bot_pod_creator.py delete mode 100644 attendee/bots/bots_api_urls.py delete mode 100644 attendee/bots/bots_api_views.py delete mode 100644 attendee/bots/google_meet_bot_adapter/__init__.py delete mode 100644 attendee/bots/google_meet_bot_adapter/google_meet_bot_adapter.py delete mode 100644 attendee/bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js delete mode 100644 attendee/bots/google_meet_bot_adapter/google_meet_ui_methods.py delete mode 100644 attendee/bots/management/commands/clean_up_completed_bot_pods.py delete mode 100644 attendee/bots/management/commands/launch_bot.py delete mode 100644 attendee/bots/management/commands/run_bot.py delete mode 100644 attendee/bots/management/commands/setup_test_db.py delete mode 100644 attendee/bots/management/commands/terminate_bots_with_heartbeat_timeout.py delete mode 100644 attendee/bots/migrations/0001_initial.py delete mode 100644 attendee/bots/migrations/0002_mediablob_botmediarequest_and_more.py delete mode 100644 attendee/bots/migrations/0003_remove_bot_valid_state_substate_combinations_and_more.py delete mode 100644 attendee/bots/migrations/0004_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py delete mode 100644 attendee/bots/migrations/0005_utterance_source_utterance_source_uuid_and_more.py delete mode 100644 attendee/bots/migrations/0006_bot_settings.py delete mode 100644 attendee/bots/migrations/0007_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py delete mode 100644 attendee/bots/migrations/0008_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py delete mode 100644 attendee/bots/migrations/0009_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py delete mode 100644 attendee/bots/migrations/0010_botmediarequest_text_to_speak_and_more.py delete mode 100644 attendee/bots/migrations/0011_alter_credentials_credential_type.py delete mode 100644 attendee/bots/migrations/0012_botdebugscreenshot_and_more.py delete mode 100644 attendee/bots/migrations/0013_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py delete mode 100644 attendee/bots/migrations/0014_utterance_sample_rate.py delete mode 100644 attendee/bots/migrations/0015_alter_bot_state_alter_botevent_event_type_and_more.py delete mode 100644 attendee/bots/migrations/0016_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py delete mode 100644 attendee/bots/migrations/__init__.py delete mode 100644 attendee/bots/models.py delete mode 100644 attendee/bots/projects_urls.py delete mode 100644 attendee/bots/projects_views.py delete mode 100644 attendee/bots/serializers.py delete mode 100644 attendee/bots/tasks/__init__.py delete mode 100644 attendee/bots/tasks/process_utterance_task.py delete mode 100644 attendee/bots/tasks/run_bot_task.py delete mode 100644 attendee/bots/teams_bot_adapter/__init__.py delete mode 100644 attendee/bots/teams_bot_adapter/teams_bot_adapter.py delete mode 100644 attendee/bots/teams_bot_adapter/teams_chromedriver_payload.js delete mode 100644 attendee/bots/teams_bot_adapter/teams_ui_methods.py delete mode 100644 attendee/bots/templates/projects/partials/api_key_created_modal.html delete mode 100644 attendee/bots/templates/projects/partials/deepgram_credentials.html delete mode 100644 attendee/bots/templates/projects/partials/google_tts_credentials.html delete mode 100644 attendee/bots/templates/projects/partials/video_tutorial_modal.html delete mode 100644 attendee/bots/templates/projects/partials/zoom_credentials.html delete mode 100644 attendee/bots/templates/projects/project_api_keys.html delete mode 100644 attendee/bots/templates/projects/project_bot_detail.html delete mode 100644 attendee/bots/templates/projects/project_bots.html delete mode 100644 attendee/bots/templates/projects/project_dashboard.html delete mode 100644 attendee/bots/templates/projects/project_settings.html delete mode 100644 attendee/bots/templates/projects/sidebar.html delete mode 100644 attendee/bots/templatetags/__init__.py delete mode 100644 attendee/bots/templatetags/bot_filters.py delete mode 100644 attendee/bots/tests/__init__.py delete mode 100644 attendee/bots/tests/mock_data.py delete mode 100644 attendee/bots/tests/test_can_open_chrome.py delete mode 100644 attendee/bots/tests/test_google_meet_bot.py delete mode 100644 attendee/bots/tests/test_zoom_bot.py delete mode 100644 attendee/bots/utils.py delete mode 100644 attendee/bots/web_bot_adapter/__init__.py delete mode 100644 attendee/bots/web_bot_adapter/ui_methods.py delete mode 100644 attendee/bots/web_bot_adapter/web_bot_adapter.py delete mode 100644 attendee/bots/zoom_bot_adapter/__init__.py delete mode 100644 attendee/bots/zoom_bot_adapter/video_input_manager.py delete mode 100644 attendee/bots/zoom_bot_adapter/zoom_bot_adapter.py delete mode 100644 attendee/dev.docker-compose.yaml delete mode 100644 attendee/docs/openapi.yml delete mode 100644 attendee/heroku.yml delete mode 100644 attendee/init_env.py delete mode 100755 attendee/manage.py delete mode 100644 attendee/pyproject.toml delete mode 100644 attendee/requirements.txt delete mode 100644 attendee/scalar.config.json delete mode 100644 attendee/static/images/favicon_white.png delete mode 100644 attendee/static/images/logo.svg delete mode 100644 attendee/static/images/logo_with_text.svg delete mode 100644 attendee/templates/base.html delete mode 100644 cli/app.py delete mode 100644 cli/config.py delete mode 100644 cli/download_model.py delete mode 100644 cli/download_whisper.py delete mode 100644 cli/extract_entities.py delete mode 100644 cli/load_model.py delete mode 100644 cli/logger.py delete mode 100644 cli/logging.connf delete mode 100644 lingo-ui/README.md delete mode 100644 lingo-ui/bun.lockb delete mode 100644 lingo-ui/components.json delete mode 100644 lingo-ui/eslint.config.js delete mode 100644 lingo-ui/index.html delete mode 100644 lingo-ui/package-lock.json delete mode 100644 lingo-ui/package.json delete mode 100644 lingo-ui/postcss.config.js delete mode 100644 lingo-ui/public/favicon.ico delete mode 100644 lingo-ui/public/placeholder.svg delete mode 100644 lingo-ui/public/robots.txt delete mode 100644 lingo-ui/src/App.css delete mode 100644 lingo-ui/src/App.tsx delete mode 100644 lingo-ui/src/components/CallToAction.tsx delete mode 100644 lingo-ui/src/components/Features.tsx delete mode 100644 lingo-ui/src/components/Footer.tsx delete mode 100644 lingo-ui/src/components/Hero.tsx delete mode 100644 lingo-ui/src/components/Navigation.tsx delete mode 100644 lingo-ui/src/components/UseCases.tsx delete mode 100644 lingo-ui/src/components/ui/accordion.tsx delete mode 100644 lingo-ui/src/components/ui/alert-dialog.tsx delete mode 100644 lingo-ui/src/components/ui/alert.tsx delete mode 100644 lingo-ui/src/components/ui/aspect-ratio.tsx delete mode 100644 lingo-ui/src/components/ui/avatar.tsx delete mode 100644 lingo-ui/src/components/ui/badge.tsx delete mode 100644 lingo-ui/src/components/ui/breadcrumb.tsx delete mode 100644 lingo-ui/src/components/ui/button.tsx delete mode 100644 lingo-ui/src/components/ui/calendar.tsx delete mode 100644 lingo-ui/src/components/ui/card.tsx delete mode 100644 lingo-ui/src/components/ui/carousel.tsx delete mode 100644 lingo-ui/src/components/ui/chart.tsx delete mode 100644 lingo-ui/src/components/ui/checkbox.tsx delete mode 100644 lingo-ui/src/components/ui/collapsible.tsx delete mode 100644 lingo-ui/src/components/ui/command.tsx delete mode 100644 lingo-ui/src/components/ui/context-menu.tsx delete mode 100644 lingo-ui/src/components/ui/dialog.tsx delete mode 100644 lingo-ui/src/components/ui/drawer.tsx delete mode 100644 lingo-ui/src/components/ui/dropdown-menu.tsx delete mode 100644 lingo-ui/src/components/ui/form.tsx delete mode 100644 lingo-ui/src/components/ui/hover-card.tsx delete mode 100644 lingo-ui/src/components/ui/input-otp.tsx delete mode 100644 lingo-ui/src/components/ui/input.tsx delete mode 100644 lingo-ui/src/components/ui/label.tsx delete mode 100644 lingo-ui/src/components/ui/menubar.tsx delete mode 100644 lingo-ui/src/components/ui/navigation-menu.tsx delete mode 100644 lingo-ui/src/components/ui/pagination.tsx delete mode 100644 lingo-ui/src/components/ui/popover.tsx delete mode 100644 lingo-ui/src/components/ui/progress.tsx delete mode 100644 lingo-ui/src/components/ui/radio-group.tsx delete mode 100644 lingo-ui/src/components/ui/resizable.tsx delete mode 100644 lingo-ui/src/components/ui/scroll-area.tsx delete mode 100644 lingo-ui/src/components/ui/select.tsx delete mode 100644 lingo-ui/src/components/ui/separator.tsx delete mode 100644 lingo-ui/src/components/ui/sheet.tsx delete mode 100644 lingo-ui/src/components/ui/sidebar.tsx delete mode 100644 lingo-ui/src/components/ui/skeleton.tsx delete mode 100644 lingo-ui/src/components/ui/slider.tsx delete mode 100644 lingo-ui/src/components/ui/sonner.tsx delete mode 100644 lingo-ui/src/components/ui/switch.tsx delete mode 100644 lingo-ui/src/components/ui/table.tsx delete mode 100644 lingo-ui/src/components/ui/tabs.tsx delete mode 100644 lingo-ui/src/components/ui/textarea.tsx delete mode 100644 lingo-ui/src/components/ui/toast.tsx delete mode 100644 lingo-ui/src/components/ui/toaster.tsx delete mode 100644 lingo-ui/src/components/ui/toggle-group.tsx delete mode 100644 lingo-ui/src/components/ui/toggle.tsx delete mode 100644 lingo-ui/src/components/ui/tooltip.tsx delete mode 100644 lingo-ui/src/components/ui/use-toast.ts delete mode 100644 lingo-ui/src/hooks/use-mobile.tsx delete mode 100644 lingo-ui/src/hooks/use-toast.ts delete mode 100644 lingo-ui/src/index.css delete mode 100644 lingo-ui/src/lib/utils.ts delete mode 100644 lingo-ui/src/main.tsx delete mode 100644 lingo-ui/src/pages/Index.tsx delete mode 100644 lingo-ui/src/pages/NotFound.tsx delete mode 100644 lingo-ui/src/vite-env.d.ts delete mode 100644 lingo-ui/tailwind.config.ts delete mode 100644 lingo-ui/tsconfig.app.json delete mode 100644 lingo-ui/tsconfig.json delete mode 100644 lingo-ui/tsconfig.node.json delete mode 100644 lingo-ui/vite.config.ts diff --git a/Bot/.gitignore b/Bot/.gitignore deleted file mode 100644 index 9064c07..0000000 --- a/Bot/.gitignore +++ /dev/null @@ -1,176 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# PyPI configuration file -.pypirc - - -credentials.json -config.py -credentials.json diff --git a/Bot/Dockerfile b/Bot/Dockerfile deleted file mode 100644 index 1a48749..0000000 --- a/Bot/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Dockerfile -FROM python:3.10-slim - -WORKDIR /app - -# Copy requirements -COPY requirements.txt . - -# Install dependencies -RUN pip install -r requirements.txt - -# Copy the project files -COPY . . - -# Expose the API port -EXPOSE 8001 - -# Run FastAPI app -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001", "--workers", "4"] - diff --git a/Bot/Makefile b/Bot/Makefile deleted file mode 100644 index 6c07edb..0000000 --- a/Bot/Makefile +++ /dev/null @@ -1,16 +0,0 @@ -IMAGE_NAME = lingo-bot -CONTAINER_NAME = lingo-bot -NETWORK_NAME = attendee_attendee_network -PORT = 8001 -TZ = Asia/Kolkata - -.PHONY: build run logs - -build: - docker build -t $(IMAGE_NAME) . - -run: - docker run -p $(PORT):$(PORT) --name $(CONTAINER_NAME) --network $(NETWORK_NAME) -e TZ=$(TZ) $(IMAGE_NAME) - -logs: - docker logs -f $(CONTAINER_NAME) diff --git a/Bot/README.md b/Bot/README.md deleted file mode 100644 index 1ef438b..0000000 --- a/Bot/README.md +++ /dev/null @@ -1 +0,0 @@ -# LingoBot \ No newline at end of file diff --git a/Bot/app.py b/Bot/app.py deleted file mode 100644 index fb692f3..0000000 --- a/Bot/app.py +++ /dev/null @@ -1,267 +0,0 @@ -from fastapi import FastAPI, Depends, HTTPException -from apscheduler.schedulers.background import BackgroundScheduler -import requests -import datetime -import threading -from google_auth_oauthlib.flow import Flow -from google.auth.transport.requests import Request as GoogleRequest -from google.oauth2.credentials import Credentials -from fastapi.security import OAuth2AuthorizationCodeBearer -from google.oauth2.credentials import Credentials -from google_auth_oauthlib.flow import InstalledAppFlow -from googleapiclient.discovery import build -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.chrome.service import Service as ChromeService -from webdriver_manager.chrome import ChromeDriverManager -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from selenium.common.exceptions import TimeoutException -import datetime -from pydantic import BaseModel -from helper import monitor_meeting, google_login -from config import config -from fastapi import FastAPI, Depends, HTTPException, Request -from fastapi.security import OAuth2AuthorizationCodeBearer -from google.auth.transport.requests import Request as GoogleRequest -from google.oauth2.credentials import Credentials -from starlette.middleware.cors import CORSMiddleware -from google_auth_oauthlib.flow import Flow -from googleapiclient.discovery import build -import datetime -import os -import time -from app.log_config import logger - -# app = FastAPI() -# SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] -# OAUTH2_SCHEME = OAuth2AuthorizationCodeBearer( -# tokenUrl="/auth/google", -# authorizationUrl="https://accounts.google.com/o/oauth2/auth" # Add this URL -# ) - -# @app.get("/auth/google") -# def authenticate_google(): -# flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES) -# creds = flow.run_local_server(port=0) -# return {"access_token": creds.token, "refresh_token": creds.refresh_token} - -# @app.get("/meetings") -# def get_meetings(token: str = Depends(OAUTH2_SCHEME)): -# creds = Credentials(token=token) -# service = build('calendar', 'v3', credentials=creds) - -# now = datetime.datetime.utcnow().isoformat() + 'Z' -# one_week_later = (datetime.datetime.utcnow() + datetime.timedelta(days=7)).isoformat() + 'Z' - -# events_result = service.events().list( -# calendarId='primary', -# timeMin=now, -# timeMax=one_week_later, -# maxResults=10, -# singleEvents=True, -# orderBy='startTime' -# ).execute() - -# events = events_result.get('items', []) -# meetings = [] - -# for event in events: -# meeting_url = event.get('hangoutLink') -# if meeting_url: -# meetings.append({"title": event['summary'], "url": meeting_url}) - -# return meetings - -class MeetingRequest(BaseModel): - meeting_url: str - -class ScheduleBotRequest(BaseModel): - meeting_url: str - bot_name: str - meeting_time: str - meeting_end_time: str - -app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins - allow_credentials=True, - allow_methods=["*"], # Allows all HTTP methods - allow_headers=["*"], # Allows all headers -) - -SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] -CLIENT_SECRETS_FILE = 'credentials.json' -REDIRECT_URI = "http://localhost:8000/auth/google/callback" - -# OAuth setup -OAUTH2_SCHEME = OAuth2AuthorizationCodeBearer( - tokenUrl="", - authorizationUrl="https://accounts.google.com/o/oauth2/auth" -) - -scheduler = BackgroundScheduler() -scheduler.start() - -# Step 1: Redirect user to Google for authentication -@app.get("/auth/google") -def authenticate_google(): - flow = Flow.from_client_secrets_file( - CLIENT_SECRETS_FILE, - scopes=SCOPES, - redirect_uri=REDIRECT_URI - ) - auth_url, _ = flow.authorization_url(prompt='consent', access_type='offline') - return {"auth_url": auth_url} - -# Step 2: Handle the callback and exchange code for tokens -@app.get("/auth/google/callback") -def google_callback(code: str): - flow = Flow.from_client_secrets_file( - CLIENT_SECRETS_FILE, - scopes=SCOPES, - redirect_uri=REDIRECT_URI - ) - flow.fetch_token(code=code) - creds = flow.credentials - - return { - "access_token": creds.token, - "refresh_token": creds.refresh_token, - "expiry": creds.expiry - } - -# Step 3: Fetch meetings using the access token -@app.post("/schedule-all-meetings") -def schedule_all_meetings(token: str = Depends(OAUTH2_SCHEME)): - creds = Credentials(token=token) - if not creds.valid and creds.expired and creds.refresh_token: - creds.refresh(GoogleRequest()) - - service = build('calendar', 'v3', credentials=creds) - - now = datetime.datetime.utcnow().isoformat() + 'Z' - one_week_later = (datetime.datetime.utcnow() + datetime.timedelta(days=7)).isoformat() + 'Z' - - events_result = service.events().list( - calendarId='primary', - timeMin=now, - timeMax=one_week_later, - maxResults=10, - singleEvents=True, - orderBy='startTime' - ).execute() - - events = events_result.get('items', []) - if not events: - return {"message": "No upcoming meetings found"} - - scheduled_meetings = [] - - for event in events: - meeting_url = event.get('hangoutLink') - if meeting_url: - # Extract meeting details - title = event.get('summary', 'Unnamed Meeting') - start_time = event['start'].get('dateTime') - end_time = event['end'].get('dateTime') - if not start_time or not end_time: - continue # Skip all-day events - - # Convert time to the expected format - meeting_time = datetime.datetime.fromisoformat(start_time).strftime('%Y-%m-%dT%H:%M:%S') - meeting_end_time = datetime.datetime.fromisoformat(end_time).strftime('%Y-%m-%dT%H:%M:%S') - - # Schedule the bot by calling the existing API - response = requests.post( - "http://localhost:8001/schedule-join-bot", - headers={"Content-Type": "application/json"}, - json={ - "meeting_url": meeting_url, - "bot_name": "My Bot", - "meeting_time": meeting_time, - "meeting_end_time": meeting_end_time - } - ) - scheduled_meetings.append({ - "title": title, - "meeting_url": meeting_url, - "status": response.json().get("message", "Failed") - }) - - return {"scheduled_meetings": scheduled_meetings} - -@app.on_event("shutdown") -def shutdown_event(): - scheduler.shutdown() - -@app.get("/scheduled-jobs") -def get_scheduled_jobs(): - jobs = scheduler.get_jobs() - return [job.id for job in jobs] - -@app.delete("/stop-all-jobs") -def stop_all_jobs(): - jobs = scheduler.get_jobs() - for job in jobs: - scheduler.remove_job(job.id) - return {"message": "All jobs stopped."} - -@app.post("/schedule-join-bot") -def schedule_join_bot(request: ScheduleBotRequest): - meeting_url = request.meeting_url - bot_name = request.bot_name - meeting_time = request.meeting_time - meeting_end_time = request.meeting_end_time - - def join_meeting_with_retry(): - attendee_api_key = os.getenv("ATTENDEE_API_KEY") - headers={ - "Authorization": f"Token {attendee_api_key}", - "Content-Type": "application/json" - } - while True: - logger.info(f"Joining meeting: {meeting_url} with bot: {bot_name}") - response = requests.post( - "http://localhost:8000/api/v1/bots", - headers=headers, - json={"meeting_url": meeting_url, "bot_name": bot_name} - ) - logger.info(f"Join bot response: {response.status_code}, {response.text}") - - if response.status_code == 201: - bot_id = response.json().get("id") - logger.info(f"Bot created with ID: {bot_id}") - - # Check bot status until success or meeting ends - while True: - status_response = requests.get( - f"http://localhost:8000/api/v1/bots/{bot_id}", - headers=headers - ) - status_data = status_response.json() - logger.info(f"Bot status: {status_data}") - - if status_data.get("state") in ["joined_recording", "joined"]: - logger.info("Bot joined successfully") - return - elif status_data.get("state") == "fatal_error": - logger.error("Bot failed to join. Retrying...") - break - - logger.info("Retrying bot status check in 30 seconds...") - time.sleep(30) - - logger.info("Retrying bot join in 30 seconds...") - time.sleep(30) - - - # Schedule the job at the meeting time and keep retrying until meeting ends - scheduler.add_job(join_meeting_with_retry, 'date', run_date=meeting_time, id=meeting_url, replace_existing=True) - return {"message": "Job scheduled", "meeting_url": meeting_url, "meeting_time": meeting_time, "meeting_end_time": meeting_end_time} - - -if __name__ == '__main__': - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/Bot/app/__init__.py b/Bot/app/__init__.py deleted file mode 100644 index 9060de5..0000000 --- a/Bot/app/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from dotenv import load_dotenv - -load_dotenv() # loads from .env diff --git a/Bot/app/api/auth.py b/Bot/app/api/auth.py deleted file mode 100644 index 67cdd55..0000000 --- a/Bot/app/api/auth.py +++ /dev/null @@ -1,32 +0,0 @@ -from fastapi import APIRouter -from google_auth_oauthlib.flow import Flow -from app.core.config import CLIENT_SECRETS_FILE, SCOPES, REDIRECT_URI - -router = APIRouter(prefix="/auth", tags=["Auth"]) - -@router.get("/google") -def authenticate_google(): - flow = Flow.from_client_secrets_file( - CLIENT_SECRETS_FILE, - scopes=SCOPES, - redirect_uri=REDIRECT_URI - ) - auth_url, _ = flow.authorization_url(prompt='consent', access_type='offline') - return {"auth_url": auth_url} - - -@router.get("/google/callback") -def google_callback(code: str): - flow = Flow.from_client_secrets_file( - CLIENT_SECRETS_FILE, - scopes=SCOPES, - redirect_uri=REDIRECT_URI - ) - flow.fetch_token(code=code) - creds = flow.credentials - - return { - "access_token": creds.token, - "refresh_token": creds.refresh_token, - "expiry": creds.expiry - } diff --git a/Bot/app/api/meetings.py b/Bot/app/api/meetings.py deleted file mode 100644 index b15e91a..0000000 --- a/Bot/app/api/meetings.py +++ /dev/null @@ -1,234 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, Header, Request, Body -from fastapi.responses import JSONResponse -from pydantic import BaseModel -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import build -from app.core.config import OAUTH2_SCHEME -import datetime -import requests -from app.helper.generate_presigned_url import generate_presigned_url, extract_file_url -from app.helper.save_transaction import save_transcription -from app.log_config import logger -import redis -from uuid import uuid4 -import json -from app.core import config -import os - -# Now you can access the values like this: -CLIENT_ID = os.getenv("CLIENT_ID") -CLIENT_SECRET = os.getenv("CLIENT_SECRET") -TOKEN_URI = os.getenv("TOKEN_URI") -REDIRECT_URIS = os.getenv("REDIRECT_URIS") - -redis_client = redis.Redis(host='redis', port=6379, db=0, decode_responses=True) - -router = APIRouter(prefix="/meetings", tags=["Meetings"]) - -LINGO_API_URL = "http://localhost:3000" - -class LingoRequest(BaseModel): - key: str - -class ScheduleMeeting(BaseModel): - refresh_token: str - bot_name: str - - -@router.get("/") -def get_meetings(body: ScheduleMeeting, token: str = Depends(OAUTH2_SCHEME)): - logger.info("Received request to fetch and schedule meetings") - creds = Credentials( - token=token, - refresh_token=body.refresh_token, - token_uri=TOKEN_URI, - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET - ) - - logger.info(creds) - service = build('calendar', 'v3', credentials=creds) - - now = datetime.datetime.utcnow().isoformat() + 'Z' - one_week_later = (datetime.datetime.utcnow() + datetime.timedelta(days=7)).isoformat() + 'Z' - - events_result = service.events().list( - calendarId='primary', - timeMin=now, - timeMax=one_week_later, - maxResults=10, - singleEvents=True, - orderBy='startTime' - ).execute() - logger.info(f"{events_result}") - - events = events_result.get('items', []) - logger.info(f"events: {events}") - scheduled_meetings = [] - - for event in events: - meeting_url = event.get('hangoutLink') - if meeting_url: - title = event.get('summary', 'Unnamed Meeting') - start_time = event['start'].get('dateTime') - end_time = event['end'].get('dateTime') - if not start_time or not end_time: - continue # Skip all-day events - - # Convert time to the expected format - meeting_time = datetime.datetime.fromisoformat(start_time).strftime('%Y-%m-%dT%H:%M:%S') - meeting_end_time = datetime.datetime.fromisoformat(end_time).strftime('%Y-%m-%dT%H:%M:%S') - - logger.info(f"Scheduling bot for meeting '{title}' at {meeting_time}") - - # Schedule the bot by calling the existing API - try: - response = requests.post( - "http://localhost:8001/scheduler/schedule-join-bot", - headers={"Content-Type": "application/json"}, - json={ - "meeting_url": meeting_url, - "bot_name": body.bot_name, - "meeting_time": meeting_time, - "meeting_end_time": meeting_end_time - } - ) - message = response.json().get("message", "Failed") - except Exception as e: - logger.error(f"Failed to schedule bot for '{title}': {e}") - message = "Failed due to exception" - - scheduled_meetings.append({ - "title": title, - "meeting_url": meeting_url, - "status": message - }) - - logger.info("Completed scheduling of all meetings") - return {"scheduled_meetings": scheduled_meetings} - - - - -@router.post("/call-to-lingo") -def call_to_lingo(request: LingoRequest): - # import pdb; pdb.set_trace() - logger.info(f"Call Recieved for {request.key}") - presigned_url = generate_presigned_url(request.key) - - if not presigned_url: - raise HTTPException(status_code=500, detail="Failed to generate presigned URL") - - - - file_url = extract_file_url(presigned_url) - if not file_url: - raise HTTPException(status_code=500, detail="Failed to extract file URL") - - # Step 3: Call the Lingo API - payload = { - "documentUrl": file_url, - "documentName": "testing" - } - - logger.info("Call to /api/transcribe lingo api") - response = requests.post(f"{LINGO_API_URL}/api/transcribe", json=payload) - transcribe_response = response.json() - - logger.info("Call to save transcription lingo api") - save_transcription_response = save_transcription(response.json(), file_url, "testing") - - if not save_transcription_response: - raise HTTPException(status_code=500, detail="Failed to save transcription") - - logger.info("Done!") - return { - "message": "Callback received", - "file_url": file_url, - "lingo_response": transcribe_response, - "transcription_response": save_transcription_response - } - - -@router.post("/watch-calendar") -def watch_calendar(token: str = Depends(OAUTH2_SCHEME), refresh_token: str = Body(..., embed=True)): - creds = Credentials( - token=token, - refresh_token=refresh_token, - token_uri=TOKEN_URI, - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET -) - service = build('calendar', 'v3', credentials=creds) - # Unique channel ID for this watch session - channel_id = str(uuid4()) - # Expire after 7 days (Google's max for watch) - expiration_time = int((datetime.datetime.utcnow() + datetime.timedelta(days=7)).timestamp() * 1000) - - body = { - "id": channel_id, - "type": "web_hook", - "address": config.WEBHOOK_ADDR, # your webhook receiver - "params": { - "ttl": "604800" - }, - "expiration": expiration_time - } - response = service.events().watch(calendarId='primary', body=body).execute() - try: - redis_client.set(channel_id, token) - - except Exception as e: - logger.info(e) - return { - "message": "Calendar watch started", - "channel_id": response.get("id"), - "resource_id": response.get("resourceId"), - "expiration": response.get("expiration") - } - - -@router.post("/webhook/calendar") -async def calendar_webhook( - request: Request, - x_goog_channel_id: str = Header(None), - x_goog_resource_state: str = Header(None), - x_goog_resource_id: str = Header(None), - x_goog_message_number: str = Header(None), -): - body = await request.body() - - # Log or process headers and body - logger.info(f"Received Calendar Notification") - logger.info(f"Channel ID: {x_goog_channel_id}") - logger.info(f"Resource ID: {x_goog_resource_id}") - ttl = redis_client.ttl(x_goog_channel_id) - logger.info(f"Redis DB info: {redis_client.info('keyspace')}") - - - for i in range(20): - token = redis_client.get(str(x_goog_channel_id)) - if token: break - - if token: - logger.info("Calling /meetings/ endpoint via requests") - try: - response = requests.get( - "http://localhost:8001/meetings/", - headers={"Authorization": f"Bearer {token}"} - ) - - if response.status_code == 200: - scheduled = response.json().get("scheduled_meetings", []) - logger.info(f"Meetings scheduled: {len(scheduled)}") - return {"message": "Webhook received and meetings processed", "scheduled": scheduled} - else: - logger.error(f"Failed to fetch meetings. Status: {response.status_code}, Details: {response.text}") - return JSONResponse(status_code=response.status_code, content=response.json()) - except Exception as e: - logger.error(f"Error while calling meetings API: {e}") - return JSONResponse(status_code=500, content={"message": "Internal error", "details": str(e)}) - else: - logger.warning("Token not found for channel_id.") - return JSONResponse(status_code=404, content={"message": "Token not found"}) - diff --git a/Bot/app/api/scheduler.py b/Bot/app/api/scheduler.py deleted file mode 100644 index 5ec609b..0000000 --- a/Bot/app/api/scheduler.py +++ /dev/null @@ -1,86 +0,0 @@ -from fastapi import APIRouter -from app.core.scheduler import scheduler -from app.helper.bot_actions import join_meeting_with_retry -from app.models.schemas import ScheduleBotRequest -import requests -import time -import threading -from app.log_config import logger -import os - -router = APIRouter(prefix="/scheduler", tags=["Scheduler"]) - - -def background_join_meeting(meeting_url, bot_name): - """Runs join logic in a separate thread.""" - thread = threading.Thread(target=join_meeting_with_retry, args=(meeting_url, bot_name)) - thread.start() - -def join_meeting_with_retry(meeting_url, bot_name): - attendee_api_key = os.getenv("ATTENDEE_API_KEY") - headers={ - "Authorization": f"Token {attendee_api_key}", - "Content-Type": "application/json" - } - while True: - logger.info(f"Joining meeting: {meeting_url} with bot: {bot_name}") - response = requests.post( - "http://attendee-attendee-app-local-1:8000/api/v1/bots", - headers=headers, - json={"meeting_url": meeting_url, "bot_name": bot_name} - ) - logger.info(f"Join bot response: {response.status_code}, {response.text}") - - if response.status_code == 201: - bot_id = response.json().get("id") - logger.info(f"Bot created with ID: {bot_id}") - - # Check bot status until success or meeting ends - while True: - status_response = requests.get( - f"http://attendee-attendee-app-local-1:8000/api/v1/bots/{bot_id}", - headers=headers - ) - status_data = status_response.json() - logger.info(f"Bot status: {status_data}") - - if status_data.get("state") in ["joined_recording", "joined"]: - logger.info("Bot joined successfully") - return - elif status_data.get("state") == "fatal_error": - logger.info("Bot failed to join. Retrying...") - break - - logger.info("Retrying bot status check in 30 seconds...") - time.sleep(30) - - logger.info("Retrying bot join in 30 seconds...") - time.sleep(30) - -@router.post("/schedule-join-bot") -async def schedule_join_bot(request: ScheduleBotRequest): - meeting_url = request.meeting_url - bot_name = request.bot_name - meeting_time = request.meeting_time - meeting_end_time = request.meeting_end_time - - - # Schedule the job at the meeting time and keep retrying until meeting ends - scheduler.add_job(background_join_meeting, 'date', run_date=meeting_time, id=meeting_url, replace_existing=True, kwargs={"meeting_url": meeting_url, "bot_name": bot_name}) - return {"message": "Job scheduled", "meeting_url": meeting_url, "meeting_time": meeting_time, "meeting_end_time": meeting_end_time} - - -@router.get("/scheduled-jobs") -def get_scheduled_jobs(): - jobs = scheduler.get_jobs() - return [job.id for job in jobs] - - -@router.delete("/stop-all-jobs") -def stop_all_jobs(): - jobs = scheduler.get_jobs() - for job in jobs: - scheduler.remove_job(job.id) - return {"message": "All jobs stopped."} - - diff --git a/Bot/app/core/config.py b/Bot/app/core/config.py deleted file mode 100644 index 1da5322..0000000 --- a/Bot/app/core/config.py +++ /dev/null @@ -1,15 +0,0 @@ -from fastapi.security import OAuth2AuthorizationCodeBearer -import os - - -SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'] -base_dir = os.path.dirname(os.path.abspath(__file__)) -CLIENT_SECRETS_FILE = os.path.join(base_dir, "credentials.json") -# CLIENT_SECRETS_FILE = 'credentials.json' -REDIRECT_URI = "http://localhost:8001/auth/google/callback" -OAUTH2_SCHEME = OAuth2AuthorizationCodeBearer( - tokenUrl="", - authorizationUrl="https://accounts.google.com/o/oauth2/auth" -) -USER_ID = "huvcypmasa5xwgyf" -WEBHOOK_ADDR = "https://7d7d-202-149-221-42.ngrok-free.app/meetings/webhook/calendar" \ No newline at end of file diff --git a/Bot/app/core/scheduler.py b/Bot/app/core/scheduler.py deleted file mode 100644 index ba88f4d..0000000 --- a/Bot/app/core/scheduler.py +++ /dev/null @@ -1,4 +0,0 @@ -from apscheduler.schedulers.background import BackgroundScheduler - -scheduler = BackgroundScheduler() -scheduler.start() diff --git a/Bot/app/helper/bot_actions.py b/Bot/app/helper/bot_actions.py deleted file mode 100644 index 5e4be6e..0000000 --- a/Bot/app/helper/bot_actions.py +++ /dev/null @@ -1,43 +0,0 @@ -import requests -import time -import os - -def join_meeting_with_retry(meeting_url: str, bot_name: str): - attendee_api_key = os.getenv("ATTENDEE_API_KEY") - headers={ - "Authorization": f"Token {attendee_api_key}", - "Content-Type": "application/json" - } - while True: - print(f"Joining meeting: {meeting_url} with bot: {bot_name}") - response = requests.post( - "http://localhost:8000/api/v1/bots", - headers=headers, - json={"meeting_url": meeting_url, "bot_name": bot_name} - ) - print(f"Join bot response: {response.status_code}, {response.text}") - - if response.status_code == 201: - bot_id = response.json().get("id") - print(f"Bot created with ID: {bot_id}") - - while True: - status_response = requests.get( - f"http://localhost:8000/api/v1/bots/{bot_id}", - headers=headers - ) - status_data = status_response.json() - print(f"Bot status: {status_data}") - - if status_data.get("state") in ["joined_recording", "joined"]: - print("Bot joined successfully") - return - elif status_data.get("state") == "fatal_error": - print("Bot failed to join. Retrying...") - break - - print("Retrying bot status check in 30 seconds...") - time.sleep(30) - - print("Retrying bot join in 30 seconds...") - time.sleep(30) diff --git a/Bot/app/helper/generate_presigned_url.py b/Bot/app/helper/generate_presigned_url.py deleted file mode 100644 index 600d996..0000000 --- a/Bot/app/helper/generate_presigned_url.py +++ /dev/null @@ -1,45 +0,0 @@ -import os -import boto3 -from botocore.exceptions import NoCredentialsError -from app.log_config import logger - - -AWS_RECORDING_STORAGE_BUCKET_NAME = os.getenv("AWS_RECORDING_STORAGE_BUCKET_NAME") -AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") -AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") - -def generate_presigned_url(file_key, expiration=3600): - try: - s3_client = boto3.client( - "s3", - aws_access_key_id=AWS_ACCESS_KEY_ID, - aws_secret_access_key=AWS_SECRET_ACCESS_KEY, - ) - - presigned_url = s3_client.generate_presigned_url( - "get_object", - Params={"Bucket": AWS_RECORDING_STORAGE_BUCKET_NAME, "Key": file_key}, - ExpiresIn=expiration, - ) - - - return presigned_url - - except NoCredentialsError: - logger.error("Credentials not available.") - return None - - -def extract_file_url(presigned_url): - from urllib.parse import urlparse, unquote - """ Extract the URL up to the file extension and remove 's3://' if present """ - # Decode URL to handle cases like 's3%3A//' - decoded_url = unquote(presigned_url) - - # Remove query parameters if any - base_url = decoded_url.split('?')[0] - - # Remove 's3://bucket-name/' pattern if present - clean_url = base_url.replace(f"s3://{AWS_RECORDING_STORAGE_BUCKET_NAME}/", "") - - return clean_url diff --git a/Bot/app/helper/save_transaction.py b/Bot/app/helper/save_transaction.py deleted file mode 100644 index 3507a9d..0000000 --- a/Bot/app/helper/save_transaction.py +++ /dev/null @@ -1,30 +0,0 @@ -import requests -import app.core.config as config - -def save_transcription(response, document_url, document_name): - # Prepare the payload - # import pdb; pdb.set_trace() - payload = { - "documentUrl": document_url, - "userID": config.USER_ID, - "documentName": document_name, - "summary": response["summary"], - "translation": response["translation"], - "audioDuration": response.get("audioDuration", 0), # Assuming you might add this later - "segments": response["segments"] - } - - # API endpoint - endpoint = "https://lingo.ai.joshsoftware.com/api/transcribe/save" - - # Send the POST request - try: - res = requests.post(endpoint, json=payload) - res.raise_for_status() # Raise an exception for HTTP errors (4xx, 5xx) - return res.json() - except requests.exceptions.RequestException as e: - print(f"Error: {e}") - return None - - - diff --git a/Bot/app/log_config.py b/Bot/app/log_config.py deleted file mode 100644 index 7fc6ad0..0000000 --- a/Bot/app/log_config.py +++ /dev/null @@ -1,35 +0,0 @@ -import logging -from logging.handlers import RotatingFileHandler - -class LoggerManager: - @staticmethod - def get_logger(name): - """ - Creates and returns a logger instance with log rotation. - - :param name: Name of the logger. - :return: Configured logger instance. - """ - logger = logging.getLogger(name) - if not logger.hasHandlers(): - logger.setLevel(logging.DEBUG) - formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') - - # Rotating File Handler - # file_handler = RotatingFileHandler( - # settings.LOG_DIRECTORY, - # maxBytes=10 * 1024 * 1024, # 5 MB - # backupCount=5 # Keep last 5 log files - # ) - # file_handler.setFormatter(formatter) - # logger.addHandler(file_handler) - - # Stream Handler (for console output) - stream_handler = logging.StreamHandler() - stream_handler.setFormatter(formatter) - logger.addHandler(stream_handler) - - return logger - -# Usage in the current file -logger = LoggerManager.get_logger(__name__) \ No newline at end of file diff --git a/Bot/app/main.py b/Bot/app/main.py deleted file mode 100644 index f4ce715..0000000 --- a/Bot/app/main.py +++ /dev/null @@ -1,31 +0,0 @@ -from fastapi import FastAPI -from app.api import auth, meetings, scheduler -from app.core.scheduler import scheduler as apscheduler -from starlette.middleware.cors import CORSMiddleware -from app.log_config import logger - -app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allows all origins - allow_credentials=True, - allow_methods=["*"], # Allows all HTTP methods - allow_headers=["*"], # Allows all headers -) - -@app.on_event("startup") -def startup_event(): - if not apscheduler.running: - logger.info("Starting scheduler...") - apscheduler.start() - else: - logger.info("Scheduler already running.") - -@app.on_event("shutdown") -def shutdown_event(): - logger.info("Shutting down scheduler...") - apscheduler.shutdown() - -app.include_router(auth.router) -app.include_router(meetings.router) -app.include_router(scheduler.router) diff --git a/Bot/app/models/schemas.py b/Bot/app/models/schemas.py deleted file mode 100644 index 4655344..0000000 --- a/Bot/app/models/schemas.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel - -class MeetingRequest(BaseModel): - meeting_url: str - -class ScheduleBotRequest(BaseModel): - meeting_url: str - bot_name: str - meeting_time: str - meeting_end_time: str diff --git a/Bot/docker-compose.yml b/Bot/docker-compose.yml deleted file mode 100644 index 20d7dae..0000000 --- a/Bot/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -version: '3.8' - -services: - redis: - image: redis:latest - container_name: redis - ports: - - "6379:6379" - networks: - - attendee_attendee_network - - lingo-bot: - build: - context: . - container_name: fastapi-app - ports: - - "8001:8001" - depends_on: - - redis - environment: - - TZ=Asia/Kolkata - volumes: - - /etc/timezone:/etc/timezone:ro - - /etc/localtime:/etc/localtime:ro - networks: - - attendee_attendee_network - -networks: - attendee_attendee_network: - external: true diff --git a/Bot/meeting_scheduler.py b/Bot/meeting_scheduler.py deleted file mode 100755 index 62a1edb..0000000 --- a/Bot/meeting_scheduler.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 - -import asyncio -import asyncpg -import aiohttp -import logging -from logging.handlers import RotatingFileHandler -import os - -# --- Configuration --- -PG_USER = "lingo" -PG_PASS = "password" -PG_HOST = "localhost" -PG_PORT = "5432" -DB_NAME = "lingo_dev" -API_URL = "http://localhost:8001/meetings/" -SQL_QUERY = 'SELECT "accessToken", "refreshToken", "botName" FROM bot;' -PG_CONN_STRING = f"postgresql://{PG_USER}:{PG_PASS}@{PG_HOST}:{PG_PORT}/{DB_NAME}" - -os.makedirs('logs', exist_ok=True) -logger = logging.getLogger('my_app_logger') -logger.setLevel(logging.INFO) # Or DEBUG, WARNING, ERROR -log_file = 'logs/app.log' -handler = RotatingFileHandler( - log_file, - maxBytes=5 * 1024 * 1024, # 5 MB - backupCount=3 # Keep last 3 log files -) -formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') -handler.setFormatter(formatter) -logger.addHandler(handler) - -# Fetch access tokens asynchronously -async def fetch_data(): - try: - conn = await asyncpg.connect(PG_CONN_STRING) - rows = await conn.fetch(SQL_QUERY) - await conn.close() - return [ - { - "access_token": row["accessToken"], - "refresh_token": row["refreshToken"], - "bot_name": row["botName"], - } - for row in rows - ] - except Exception as e: - logger.error(f"Database error: {e}") - return [] - - -# Make one request -async def make_request(session, data): - headers = { - "Authorization": f"Bearer {data['access_token']}", - "Content-Type": "application/json", - } - body = {"refresh_token": data["refresh_token"], "bot_name": data["bot_name"]} - try: - async with session.get( - API_URL, headers=headers, json=body, timeout=10 - ) as response: - status = response.status - # text = await response.text() - logger.info(f"Bot Name: {data['bot_name']} -> Status: {status}") - except Exception as e: - logger.error(f"Bot Name: {data['bot_name']}: failed: {e}") - - -# Main async workflow -async def main(): - tokens = await fetch_data() - async with aiohttp.ClientSession() as session: - tasks = [make_request(session, token) for token in tokens] - await asyncio.gather(*tasks) - - -# Entry point -if __name__ == "__main__": - asyncio.run(main()) diff --git a/Bot/requirements.txt b/Bot/requirements.txt deleted file mode 100644 index 67274af..0000000 --- a/Bot/requirements.txt +++ /dev/null @@ -1,14 +0,0 @@ -fastapi==0.109.0 -uvicorn==0.27.0 -selenium==4.15.2 -google-api-python-client==2.130.0 -google-auth==2.27.0 -google-auth-oauthlib==1.2.0 -requests==2.31.0 -webdriver-manager==3.8.6 -google-auth-oauthlib -apscheduler -requests -google-api-python-client -boto3 -redis \ No newline at end of file diff --git a/Bot/run.py b/Bot/run.py deleted file mode 100644 index b587f7a..0000000 --- a/Bot/run.py +++ /dev/null @@ -1,4 +0,0 @@ -import uvicorn - -if __name__ == "__main__": - uvicorn.run("app.main:app", host="0.0.0.0", port=8001, reload=True) diff --git a/attendee/.dockerignore b/attendee/.dockerignore deleted file mode 100644 index 60f4a9d..0000000 --- a/attendee/.dockerignore +++ /dev/null @@ -1,92 +0,0 @@ -# Created by https://www.gitignore.io - -venv -env.sh - -### OSX ### -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear on external disk -.Spotlight-V100 -.Trashes - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.cache -nosetests.xml -coverage.xml - -# Translations -*.mo -*.pot - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - - -### Django ### -*.log -*.pot -*.pyc -__pycache__/ -local_settings.py - -.env -db.sqlite3 diff --git a/attendee/.github/workflows/ci.yml b/attendee/.github/workflows/ci.yml deleted file mode 100644 index 4be1c03..0000000 --- a/attendee/.github/workflows/ci.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test: - name: Run test suite - runs-on: ubuntu-latest - timeout-minutes: 10 - - # Add service containers - services: - postgres: - image: postgres:15.3-alpine - env: - POSTGRES_DB: attendee_test - POSTGRES_USER: attendee_test_user - POSTGRES_PASSWORD: attendee_test_user - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - redis: - image: redis:7-alpine - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - with: - driver-opts: | - image=moby/buildkit:master - network=host - - - name: Build and cache Docker image - uses: docker/build-push-action@v5 - with: - context: . - push: false - load: true - tags: attendee-test:latest - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Run build script in Docker container - env: - POSTGRES_HOST: localhost - REDIS_URL: redis://localhost:6379/5 - DJANGO_SETTINGS_MODULE: attendee.settings.test - run: | - docker run --rm \ - --network host \ - -e POSTGRES_HOST \ - -e REDIS_URL \ - -e DJANGO_SETTINGS_MODULE \ - attendee-test:latest bash -c "python init_env.py > .env && python manage.py test --keepdb bots.tests" - - lint: - name: Run linting - runs-on: ubuntu-latest - needs: test - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.10' - cache: 'pip' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Run Ruff - run: | - ruff check . - ruff format --check . \ No newline at end of file diff --git a/attendee/.gitignore b/attendee/.gitignore deleted file mode 100644 index a5272e1..0000000 --- a/attendee/.gitignore +++ /dev/null @@ -1,95 +0,0 @@ -# Created by https://www.gitignore.io -compose.yaml - -py-zoom-meeting-sdk/ - -venv -env.sh - -### OSX ### -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - - -# Thumbnails -._* - -# Files that might appear on external disk -.Spotlight-V100 -.Trashes - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.cache -nosetests.xml -coverage.xml - -# Translations -*.mo -*.pot - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - - -### Django ### -*.log -*.pot -*.pyc -__pycache__/ -local_settings.py - -.env -db.sqlite3 diff --git a/attendee/.pre-commit-config.yaml b/attendee/.pre-commit-config.yaml deleted file mode 100644 index 84877f7..0000000 --- a/attendee/.pre-commit-config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.3.0 - hooks: - # Run the linter - - id: ruff - args: [ --fix ] - # Run the formatter - - id: ruff-format \ No newline at end of file diff --git a/attendee/.python-version b/attendee/.python-version deleted file mode 100644 index c8cfe39..0000000 --- a/attendee/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 diff --git a/attendee/CONTRIBUTING.md b/attendee/CONTRIBUTING.md deleted file mode 100644 index 2887d99..0000000 --- a/attendee/CONTRIBUTING.md +++ /dev/null @@ -1,79 +0,0 @@ -# Contributing to Attendee - -Thank you for your interest in contributing to Attendee! This document provides guidelines and instructions for contributing to the project. - -## Getting Started - -1. Fork the repository and clone it locally -2. Set up the development environment: - ```bash - # Build the Docker image (takes ~5 minutes) - docker compose -f dev.docker-compose.yaml build - - # Create local environment variables - docker compose -f dev.docker-compose.yaml run --rm attendee-app-local python init_env.py > .env - - # Edit .env and add your AWS credentials - - # Start all services - docker compose -f dev.docker-compose.yaml up - - # In a separate terminal, run migrations - docker compose -f dev.docker-compose.yaml exec attendee-app-local python manage.py migrate - ``` - -3. Create a new branch for your changes: - ```bash - git switch -c feature/your-feature-name - ``` - -## Development Guidelines - -### Code Style - -We use Ruff for both linting and formatting. The configuration can be found in `pyproject.toml`. To ensure your code meets our style guidelines: - -1. Install pre-commit hooks: - ```bash - pip install pre-commit - pre-commit install - ``` - -2. The pre-commit hooks will automatically: - - Run the Ruff linter with auto-fixing enabled - - Run the Ruff formatter - - Check for common issues - - -### Documentation - -Contributing to documentation means modifying the files in the `docs` directory. - -- Update the API documentation in `docs/openapi.yml` for any API changes -- Update the README.md if necessary -- For other types documentation, see the related *.md file in the `docs` directory - -## Pull Request Process - -1. Create a Pull Request with a clear title and description -2. Update the documentation as needed -3. Reference any related issues in your PR description -4. Wait for review from maintainers - -## Reporting Issues - -When reporting issues, please include: - -- A clear description of the problem -- Steps to reproduce -- Expected vs actual behavior -- Relevant logs or screenshots - -## Community - -- Join our [Slack Community](https://join.slack.com/t/attendeecommu-rff8300/shared_invite/zt-2uhpam6p2-ZzLAoVrljbL2UEjqdSHrgQ) for discussions -- Star the repository if you find it useful - -## License - -By contributing to Attendee, you agree that your contributions will be licensed under the same license as the project. \ No newline at end of file diff --git a/attendee/Dockerfile b/attendee/Dockerfile deleted file mode 100644 index 5aa16f4..0000000 --- a/attendee/Dockerfile +++ /dev/null @@ -1,94 +0,0 @@ -FROM --platform=linux/amd64 ubuntu:22.04 AS base - -SHELL ["/bin/bash", "-c"] - -ENV project=attendee -ENV cwd=/$project - -WORKDIR $cwd - -ARG DEBIAN_FRONTEND=noninteractive - -# Install Dependencies -RUN apt-get update \ - && apt-get install -y \ - build-essential \ - ca-certificates \ - cmake \ - curl \ - gdb \ - git \ - gfortran \ - libopencv-dev \ - libdbus-1-3 \ - libgbm1 \ - libgl1-mesa-glx \ - libglib2.0-0 \ - libglib2.0-dev \ - libssl-dev \ - libx11-dev \ - libx11-xcb1 \ - libxcb-image0 \ - libxcb-keysyms1 \ - libxcb-randr0 \ - libxcb-shape0 \ - libxcb-shm0 \ - libxcb-xfixes0 \ - libxcb-xtest0 \ - libgl1-mesa-dri \ - libxfixes3 \ - linux-libc-dev \ - pkgconf \ - python3-pip \ - tar \ - unzip \ - zip \ - vim \ - libpq-dev - -# Install Chrome dependencies -RUN apt-get install -y xvfb x11-xkb-utils xfonts-100dpi xfonts-75dpi xfonts-scalable xfonts-cyrillic x11-apps libvulkan1 fonts-liberation xdg-utils wget -# Install a specific version of Chrome. -RUN wget -q http://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_133.0.6943.98-1_amd64.deb -RUN apt-get install -y ./google-chrome-stable_133.0.6943.98-1_amd64.deb - -# Install ALSA -RUN apt-get update && apt-get install -y libasound2 libasound2-plugins alsa alsa-utils alsa-oss - -# Install Pulseaudio -RUN apt-get install -y pulseaudio pulseaudio-utils ffmpeg - -# Install Linux Kernel Dev -RUN apt-get update && apt-get install -y linux-libc-dev - -# Install Ctags -RUN apt-get update && apt-get install -y universal-ctags - -# Install python dependencies -RUN pip install pyjwt cython gdown deepgram-sdk python-dotenv - -# Install gstreamer -RUN apt-get install -y gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-libav libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libgirepository1.0-dev --fix-missing - -# Alias python3 to python -RUN ln -s /usr/bin/python3 /usr/bin/python - -FROM base AS deps - -# Copy only requirements.txt first to leverage Docker cache -COPY requirements.txt . -RUN pip install -r requirements.txt - -ENV TINI_VERSION v0.19.0 -ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini -RUN chmod +x /tini - -WORKDIR /opt - -FROM deps AS build - -WORKDIR $cwd -COPY . . - -CMD ["/bin/bash"] - diff --git a/attendee/LICENSE b/attendee/LICENSE deleted file mode 100644 index 718f1d9..0000000 --- a/attendee/LICENSE +++ /dev/null @@ -1,44 +0,0 @@ -Elastic License 2.0 (ELv2) - -**Acceptance** -By using the software, you agree to all of the terms and conditions below. - -**Copyright License** -The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject to the limitations and conditions below - -**Limitations** -You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software. - -You may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key. - -You may not alter, remove, or obscure any licensing, copyright, or other notices of the licensor in the software. Any use of the licensor’s trademarks is subject to applicable law. - -**Patents** -The licensor grants you a license, under any patent claims the licensor can license, or becomes able to license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case subject to the limitations and conditions in this license. This license does not cover any patent claims that you cause to be infringed by modifications or additions to the software. If you or your company make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. - -**Notices** -You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. - -If you modify the software, you must include in any modified copies of the software prominent notices stating that you have modified the software. - -**No Other Rights** -These terms do not imply any licenses other than those expressly granted in these terms. - -**Termination** -If you use the software in violation of these terms, such use is not licensed, and your licenses will automatically terminate. If the licensor provides you with a notice of your violation, and you cease all violation of this license no later than 30 days after you receive that notice, your licenses will be reinstated retroactively. However, if you violate these terms after such reinstatement, any additional violation of these terms will cause your licenses to terminate automatically and permanently. - -**No Liability** -As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim. - -**Definitions** -The _licensor_ is the entity offering these terms, and the _software_ is the software the licensor makes available under these terms, including any portion of it. - -_you_ refers to the individual or entity agreeing to these terms. - -_your company_ is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. _control_ means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. - -_your licenses_ are all the licenses granted to you for the software under these terms. - -_use_ means anything you do with the software requiring one of your licenses. - -_trademark_ means trademarks, service marks, and similar rights. \ No newline at end of file diff --git a/attendee/Procfile b/attendee/Procfile deleted file mode 100644 index a46ed58..0000000 --- a/attendee/Procfile +++ /dev/null @@ -1,2 +0,0 @@ -web: gunicorn attendee.wsgi -worker: celery -A attendee worker -l info \ No newline at end of file diff --git a/attendee/README.md b/attendee/README.md deleted file mode 100644 index ae0f3b6..0000000 --- a/attendee/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# Attendee: Meeting bots made easy - -Attendee is an open source API for managing meeting bots on platforms like Zoom or Google Meet. Bring meeting transcripts and recordings into your product in days instead of months. - -See a [quick demo of the API](https://www.loom.com/embed/b738d02aabf84f489f0bfbadf71605e3?sid=ea605ea9-8961-4cc3-9ba9-10b7dbbb8034), check out the [API reference](https://attendee.apidocumentation.com/) or join the [Slack Community](https://join.slack.com/t/attendeecommu-rff8300/shared_invite/zt-2uhpam6p2-ZzLAoVrljbL2UEjqdSHrgQ). - -## Getting started - -Sign up for free on our hosted instance [here](https://app.attendee.dev/accounts/signup/). - -## Self hosting - -Attendee is designed for convenient self-hosting. It runs as a Django app in a single Docker image. The only external services needed are Postgres and Redis. Directions for running locally in development mode [here](#running-in-development-mode). - -## Why use Attendee? - -Meeting bots are powerful because they have access to the same audio and video streams as human users of meeting software. They power software like Gong or Otter.ai. - -Building meeting bots is challenging across all platforms, though some have more support than others. Zoom provides a powerful [SDK](https://developers.zoom.us/docs/meeting-sdk/), but it is low-level and advanced features like per-participant audio streams are only available in the C++ variants of the SDK. Google Meet doesn't provide any support at all, so you need to run a full instance of Google Meet in Chrome. - -Attendee abstracts away this complexity into a single developer friendly REST API that manages the state and media streams from these bots. If you're a developer building functionality that requires meeting bots, Attendee can save you months of work vs building from scratch. - -## Calling the API - -Join a meeting with a POST request to `/bots`: -``` -curl -X POST https://app.attendee.dev/api/v1/bots \ --H 'Authorization: Token ' \ --H 'Content-Type: application/json' \ --d '{"meeting_url": "https://us05web.zoom.us/j/84315220467?pwd=9M1SQg2Pu2l0cB078uz6AHeWelSK19.1", "bot_name": "My Bot"}' -``` -Response: -```{"id":"bot_3hfP0PXEsNinIZmh","meeting_url":"https://us05web.zoom.us/j/4849920355?pwd=aTBpNz760UTEBwUT2mQFtdXbl3SS3i.1","state":"joining","transcription_state":"not_started"}``` - -The API will respond with an object that represents your bot's state in the meeting. - - - -Make a GET request to `/bots/` to poll the bot: -``` -curl -X GET https://app.attendee.dev/api/v1/bots/bot_3hfP0PXEsNinIZmh \ --H 'Authorization: Token ' \ --H 'Content-Type: application/json' -``` -Response: -```{"id":"bot_3hfP0PXEsNinIZmh","meeting_url":"https://us05web.zoom.us/j/88669088234?pwd=AheaMumvS4qxh6UuDtSOYTpnQ1ZbAS.1","state":"ended","transcription_state":"complete"}``` - -When the endpoint returns a state of `ended`, it means the meeting has ended. When the `transcription_state` is `complete` it means the meeting recording has been transcribed. - - -Once the meeting has ended and the transcript is ready make a GET request to `/bots//transcript` to retrieve the meeting transcripts: -``` -curl -X GET https://app.attendee.dev/api/v1/bots/bot_3hfP0PXEsNinIZmh/transcript \ --H 'Authorization: Token mpc67dedUlzEDXfNGZKyC30t6cA11TYh' \ --H 'Content-Type: application/json' -``` -Response: -``` -[{ -"speaker_name":"Noah Duncan", -"speaker_uuid":"16778240","speaker_user_uuid":"AAB6E21A-6B36-EA95-58EC-5AF42CD48AF8", -"timestamp_ms":1079,"duration_ms":7710, -"transcription":"You can totally record this, buddy. You can totally record this. Go for it, man." -},...] -``` -You can also query this endpoint while the meeting is happening to retrieve partial transcripts. - -## Prerequisites - -To call the API you need the following - -1. Attendee API Key - These are created in the Attendee UI by creating an account in your Attendee instance, signing in and navigating to the 'API Keys' section in the sidebar. - -2. Zoom OAuth Credentials - These are the Zoom app client id and secret that uniquely identify your bot. We need these to join meetings. Directions on obtaining them [here](#obtaining-zoom-oauth-credentials). - -3. Deepgram API Key - We use Deepgram to generate recording transcripts. You can sign up for a free account [here](https://console.deepgram.com/signup), no credit card required. - -The Zoom OAuth credentials and Deepgram API key are entered into the Attendee UI in the 'Settings' section in the sidebar. - -## Missing feature? - -Attendee is in very early beta, but functionality is being added rapidly. If the API is missing something you need, then open an issue in the repository. PRs are also welcome! - -## Obtaining Zoom OAuth Credentials - -- Navigate to [Zoom Marketplace](https://marketplace.zoom.us/) and register/log into your -developer account. -- Click the "Develop" button at the top-right, then click 'Build App' and choose "General App". -- Copy the Client ID and Client Secret from the 'App Credentials' section -- Go to the Embed tab on the left navigation bar under Features, then select the Meeting SDK toggle. - -For more details, follow [this guide](https://developers.zoom.us/docs/meeting-sdk/developer-accounts/) or watch this [video](https://www.loom.com/embed/7cbd3eab1bc4438fb1badcb3787996d6?sid=825a92b5-51ca-447c-86c1-c45f5294ec9d). - -## Running in development mode - -- Build the Docker image: `docker compose -f dev.docker-compose.yaml build` (Takes about 5 minutes) -- Create local environment variables: `docker compose -f dev.docker-compose.yaml run --rm attendee-app-local python init_env.py > .env` -- Edit the `.env` file and enter your AWS information. -- Start all the services: `docker compose -f dev.docker-compose.yaml up` -- After the services have started, run migrations in a separate terminal tab: `docker compose -f dev.docker-compose.yaml exec attendee-app-local python manage.py migrate` -- Goto localhost:8000 in your browser and create an account -- The confirmation link will be written to the server logs in the terminal where you ran `docker compose -f dev.docker-compose.yaml up`. Should look like `http://localhost:8000/accounts/confirm-email//`. -- Paste the link into your browser to confirm your account. -- You should now be able to log in, input your credentials and obtain an API key. API calls should be directed to http://localhost:8000 instead of https://app.attendee.dev. - - -## Contribute - -Attendee is open source. TThe best way to contribute is to open an issue or join the [Slack Community](https://join.slack.com/t/attendeecommu-rff8300/shared_invite/zt-2uhpam6p2-ZzLAoVrljbL2UEjqdSHrgQ) and let us know what you want to build. - -See CONTRIBUTING.md for detailed instructions on how to contribute to Attendee. - - -## Roadmap - -- [x] Join and leave Zoom meetings -- [x] Transcripts -- [x] API Reference -- [x] Audio input / output -- [x] Video input / output -- [x] Google Meet support -- [x] Speech support -- [ ] Automatically leave meetings -- [ ] [ZAK token](https://developers.zoom.us/docs/meeting-sdk/auth/#start-meetings-and-webinars-with-a-zoom-users-zak-token) and [Join token](https://developers.zoom.us/docs/api/meetings/#tag/meetings/GET/meetings/{meetingId}/jointoken/local_recording) support -- [ ] Scheduled meetings -- [ ] Webhooks for state changes -- [ ] Microsoft Teams support - -Have suggestions for the roadmap? Join the [Slack Community](https://join.slack.com/t/attendeecommu-rff8300/shared_invite/zt-2uhpam6p2-ZzLAoVrljbL2UEjqdSHrgQ) or open an issue. diff --git a/attendee/accounts/__init__.py b/attendee/accounts/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/attendee/accounts/admin.py b/attendee/accounts/admin.py deleted file mode 100644 index 846f6b4..0000000 --- a/attendee/accounts/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/attendee/accounts/apps.py b/attendee/accounts/apps.py deleted file mode 100644 index 0cb51e6..0000000 --- a/attendee/accounts/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class AccountsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "accounts" diff --git a/attendee/accounts/forms.py b/attendee/accounts/forms.py deleted file mode 100644 index e69de29..0000000 diff --git a/attendee/accounts/migrations/0001_initial.py b/attendee/accounts/migrations/0001_initial.py deleted file mode 100644 index ec9aaef..0000000 --- a/attendee/accounts/migrations/0001_initial.py +++ /dev/null @@ -1,55 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-03 21:22 - -import django.contrib.auth.models -import django.contrib.auth.validators -import django.db.models.deletion -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='Organization', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - ), - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), - ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='users', to='accounts.organization')), - ], - options={ - 'verbose_name': 'user', - 'verbose_name_plural': 'users', - 'abstract': False, - }, - managers=[ - ('objects', django.contrib.auth.models.UserManager()), - ], - ), - ] diff --git a/attendee/accounts/migrations/__init__.py b/attendee/accounts/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/attendee/accounts/models.py b/attendee/accounts/models.py deleted file mode 100644 index 4852483..0000000 --- a/attendee/accounts/models.py +++ /dev/null @@ -1,44 +0,0 @@ -import uuid - -from django.contrib.auth.models import AbstractUser -from django.db import models - - -class Organization(models.Model): - name = models.CharField(max_length=255) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - def __str__(self): - return self.name - - -class User(AbstractUser): - organization = models.ForeignKey(Organization, on_delete=models.PROTECT, null=False, related_name="users") - - def __str__(self): - return self.email - - -# Only added this to create an org for the admin user -from django.db.models.signals import pre_save -from django.dispatch import receiver - - -@receiver(pre_save, sender=User) -def create_default_organization(sender, instance, **kwargs): - # Only run this for new users (not updates) - if not instance.pk and not instance.organization_id: - from bots.models import Project - - default_org = Organization.objects.create(name=f"{instance.email}'s organization") - - # Create default project for the organization - Project.objects.create(name=f"{instance.email}'s first project", organization=default_org) - - # There's some weird stuff going on with username field - # we don't need it for anything, so we'll just set it to a random uuid - # that will avoid violating the unique constraint - instance.username = str(uuid.uuid4()) - - instance.organization = default_org diff --git a/attendee/accounts/templates/account/email_confirm.html b/attendee/accounts/templates/account/email_confirm.html deleted file mode 100644 index 8374474..0000000 --- a/attendee/accounts/templates/account/email_confirm.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load account %} - -{% block body_content %} -
-
-
-
-
- -
-

- {% if confirmation %} - Confirm Email Address - {% else %} - Invalid Confirmation Link - {% endif %} -

-
- - {% if confirmation %} - {% user_display confirmation.email_address.user as user_display %} - {% if can_confirm %} - -
-
- -
-

- Please confirm that {{ confirmation.email_address.email }} is the email address for user {{ user_display }}. -

-
- - -
- {% csrf_token %} -
- -
-
- {% else %} - -
-
- -
-

- This email address is already confirmed by a different account. -

-
- {% endif %} - {% else %} - -
-
- -
-

- This email confirmation link has expired or is invalid. -

- -
- {% endif %} - - -
-

- Return to Login -

-
-
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/templates/account/login.html b/attendee/accounts/templates/account/login.html deleted file mode 100644 index 1e22e6c..0000000 --- a/attendee/accounts/templates/account/login.html +++ /dev/null @@ -1,66 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load socialaccount %} -{% load static %} - -{% block body_content %} -
-
-
-
-
- -
- Attendee Logo -
- - -
- {% include "account/partials/_google_signin_button.html" %} -
- - -
- {% csrf_token %} - - -
- - -
- - -
- - -
- - -
- -
- - - {% if form.errors %} -
- Please check your credentials and try again. -
- {% endif %} -
- - -
-

- Don't have an account? - Sign up -

-

- Forgot Password? -

-
-
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/templates/account/partials/_google_signin_button.html b/attendee/accounts/templates/account/partials/_google_signin_button.html deleted file mode 100644 index de9efbb..0000000 --- a/attendee/accounts/templates/account/partials/_google_signin_button.html +++ /dev/null @@ -1,139 +0,0 @@ -{% load socialaccount %} - - - - -{% get_providers as socialaccount_providers %} -{% for provider in socialaccount_providers %} -{% if provider.id == 'google' %} -
- {% csrf_token %} - -
-
-
-
- or -
-
-{% endif %} -{% endfor %} \ No newline at end of file diff --git a/attendee/accounts/templates/account/password_reset.html b/attendee/accounts/templates/account/password_reset.html deleted file mode 100644 index ad7a016..0000000 --- a/attendee/accounts/templates/account/password_reset.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block body_content %} -
-
-
-
-
- -
-

Reset Password

-
- - -
-

- Enter your email address below, and we'll send you instructions to reset your password. -

-
- - -
- {% csrf_token %} - - -
- - -
- - -
- -
- - - {% if form.errors %} -
- {% for field, errors in form.errors.items %} - {% for error in errors %} -

{{ error }}

- {% endfor %} - {% endfor %} -
- {% endif %} -
- - -
-

- Remember your password? - Sign In -

-
-
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/templates/account/password_reset_done.html b/attendee/accounts/templates/account/password_reset_done.html deleted file mode 100644 index ca8e426..0000000 --- a/attendee/accounts/templates/account/password_reset_done.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block body_content %} -
-
-
-
-
- -
-

Check Your Email

-
- - -
-
- -
-

- We have sent password reset instructions to your email address. -

-

- If you don't see the email in your inbox, please check your spam folder. -

-
- - - -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/templates/account/password_reset_from_key.html b/attendee/accounts/templates/account/password_reset_from_key.html deleted file mode 100644 index 5bfa5dc..0000000 --- a/attendee/accounts/templates/account/password_reset_from_key.html +++ /dev/null @@ -1,83 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block body_content %} -
-
-
-
-
- -
-

- {% if token_fail %} - Invalid Password Reset Link - {% else %} - Set New Password - {% endif %} -

-
- - {% if token_fail %} - -
-
- -
-

- This password reset link is invalid or has already been used. - Please request a new password reset. -

- -
- {% else %} - -
- {% csrf_token %} - - -
- - -
- - -
- - -
- - -
- -
- - - {% if form.errors %} -
- {% for field, errors in form.errors.items %} - {% for error in errors %} -

{{ error }}

- {% endfor %} - {% endfor %} -
- {% endif %} -
- {% endif %} - - -
-

- Return to Login -

-
-
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/templates/account/password_reset_from_key_done.html b/attendee/accounts/templates/account/password_reset_from_key_done.html deleted file mode 100644 index 3f941fa..0000000 --- a/attendee/accounts/templates/account/password_reset_from_key_done.html +++ /dev/null @@ -1,36 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block body_content %} -
-
-
-
-
- -
-

Password Changed

-
- - -
-
- -
-

- Your password has been successfully changed. -

-
- - - -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/templates/account/signup.html b/attendee/accounts/templates/account/signup.html deleted file mode 100644 index c60fb77..0000000 --- a/attendee/accounts/templates/account/signup.html +++ /dev/null @@ -1,94 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load socialaccount %} -{% load static %} - -{% block body_content %} -
-
-
-
-
- -
- Attendee Logo -
-
-

Create account

-
- - -
- {% include "account/partials/_google_signin_button.html" with label="Sign up with Google" %} -
- - -
- {% csrf_token %} - - -
- - -
- - -
- - -
- - -
- - -
- - -
- -
- - - {% if form.errors %} -
- {% for field, errors in form.errors.items %} - {% for error in errors %} -

{{ error }}

- {% endfor %} - {% endfor %} -
- {% endif %} -
- - -
-

- Already have an account? - Sign In -

-
-
-
-
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/templates/account/verification_sent.html b/attendee/accounts/templates/account/verification_sent.html deleted file mode 100644 index 5c1cf52..0000000 --- a/attendee/accounts/templates/account/verification_sent.html +++ /dev/null @@ -1,39 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} - -{% block body_content %} -
-
-
-
-
- -
-

Verify Your Email Address

-
- - -
-
- -
-

- We have sent an email to you for verification. Follow the link provided to finalize the signup process. -

-

- If you do not see the verification email in your main inbox, check your spam folder. -

-
- - - -
-
-
-
-
-{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/templates/socialaccount/login_cancelled.html b/attendee/accounts/templates/socialaccount/login_cancelled.html deleted file mode 100644 index 274ac0a..0000000 --- a/attendee/accounts/templates/socialaccount/login_cancelled.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends "base.html" %} -{% load i18n %} -{% load allauth %} - -{% block body_content %} -
-
-
-
-
- -
-

{% trans "Login Cancelled" %}

-
- - -
- {% url 'account_login' as login_url %} -

- {% blocktrans %}You cancelled the login process. If this was a mistake, please proceed to sign in.{% endblocktrans %} -

-

Redirecting to sign in page in 3 seconds...

-
-
-
-
-
-
- - -{% endblock %} \ No newline at end of file diff --git a/attendee/accounts/tests.py b/attendee/accounts/tests.py deleted file mode 100644 index a39b155..0000000 --- a/attendee/accounts/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/attendee/accounts/views.py b/attendee/accounts/views.py deleted file mode 100644 index a137f57..0000000 --- a/attendee/accounts/views.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.contrib.auth.decorators import login_required -from django.http import Http404 -from django.shortcuts import redirect - -from bots.models import Project - - -@login_required -def home(request): - # Get the first bot for the user's organization - project = Project.objects.filter(organization=request.user.organization).first() - if not project: - project = Project.objects.create( - name=f"{request.user.email}'s first project", - organization=request.user.organization, - ) - if project: - return redirect("projects:project-dashboard", object_id=project.object_id) - raise Http404("No projects found for this organization. You need to create a project first.") diff --git a/attendee/attendee/__init__.py b/attendee/attendee/__init__.py deleted file mode 100644 index 53f4ccb..0000000 --- a/attendee/attendee/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .celery import app as celery_app - -__all__ = ("celery_app",) diff --git a/attendee/attendee/asgi.py b/attendee/attendee/asgi.py deleted file mode 100644 index cd018ca..0000000 --- a/attendee/attendee/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for attendee project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "attendee.settings") - -application = get_asgi_application() diff --git a/attendee/attendee/celery.py b/attendee/attendee/celery.py deleted file mode 100644 index bfb466d..0000000 --- a/attendee/attendee/celery.py +++ /dev/null @@ -1,26 +0,0 @@ -import os -import ssl - -from celery import Celery - -# Set the default Django settings module -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "attendee.settings") - -# Create the Celery app -if os.getenv("DISABLE_REDIS_SSL"): - app = Celery( - "attendee", - broker_use_ssl={"ssl_cert_reqs": ssl.CERT_NONE}, - redis_backend_use_ssl={"ssl_cert_reqs": ssl.CERT_NONE}, - ) -else: - app = Celery("attendee") - - - - -# Load configuration from Django settings -app.config_from_object("django.conf:settings", namespace="CELERY") - -# Auto-discover tasks from all registered Django apps -app.autodiscover_tasks() diff --git a/attendee/attendee/settings/__init__.py b/attendee/attendee/settings/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/attendee/attendee/settings/base.py b/attendee/attendee/settings/base.py deleted file mode 100644 index d97d59e..0000000 --- a/attendee/attendee/settings/base.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -Django settings for attendee project. - -Generated by 'django-admin startproject' using Django 5.1.2. - -For more information on this file, see -https://docs.djangoproject.com/en/5.1/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.1/ref/settings/ -""" - -import os -from pathlib import Path - -from dotenv import load_dotenv - -load_dotenv() - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent.parent - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") - -# Application definition -INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "django.contrib.sites", - "allauth", - "allauth.account", - "allauth.socialaccount", - "accounts", - "bots", - "rest_framework", - "concurrency", - "allauth.socialaccount.providers.google", - "drf_spectacular", - "storages", - "django_extensions", -] - -CREDENTIALS_ENCRYPTION_KEY = os.getenv("CREDENTIALS_ENCRYPTION_KEY") - -AUTH_USER_MODEL = "accounts.User" - -AUTHENTICATION_BACKENDS = [ - "django.contrib.auth.backends.ModelBackend", - "allauth.account.auth_backends.AuthenticationBackend", -] - -# Django allauth config -SITE_ID = 1 -ACCOUNT_AUTHENTICATION_METHOD = "email" -ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_USERNAME_REQUIRED = False -ACCOUNT_EMAIL_VERIFICATION = "mandatory" -ACCOUNT_UNIQUE_EMAIL = True -LOGIN_REDIRECT_URL = "/" -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" -ACCOUNT_CONFIRM_EMAIL_ON_GET = True -ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True -ACCOUNT_LOGIN_ON_PASSWORD_RESET = True -ACCOUNT_USER_MODEL_USERNAME_FIELD = None -SOCIALACCOUNT_QUERY_EMAIL = True -SOCIALACCOUNT_ENABLED = True -SOCIALACCOUNT_EMAIL_AUTHENTICATION = True -SOCIALACCOUNT_LOGIN_ON_GET = False -SOCIALACCOUNT_EMAIL_REQUIRED = True - -STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") -STATIC_URL = "static/" -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, "static"), -] - -MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", - "allauth.account.middleware.AccountMiddleware", -] - -ROOT_URLCONF = "attendee.urls" - -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [ - os.path.join(BASE_DIR, "templates"), - os.path.join(BASE_DIR, "accounts", "templates"), - ], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, -] - -WSGI_APPLICATION = "attendee.wsgi.application" - -# Password validation -# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - # { - # 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - # }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/5.1/topics/i18n/ - -LANGUAGE_CODE = "en-us" - -TIME_ZONE = "UTC" - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.1/howto/static-files/ - -STATIC_URL = "static/" - -# Default primary key field type -# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" - -# Redis/Celery Configuration -if os.getenv("DISABLE_REDIS_SSL"): - REDIS_CELERY_URL = os.getenv("REDIS_URL") + "?ssl_cert_reqs=none" -else: - REDIS_CELERY_URL = os.getenv("REDIS_URL") - -CELERY_BROKER_URL = REDIS_CELERY_URL -CELERY_RESULT_BACKEND = REDIS_CELERY_URL -CELERY_ACCEPT_CONTENT = ["json"] -CELERY_TASK_SERIALIZER = "json" -CELERY_RESULT_SERIALIZER = "json" - -REST_FRAMEWORK = { - # YOUR SETTINGS - "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", -} - -SPECTACULAR_SETTINGS = { - "TITLE": "Attendee API", - "DESCRIPTION": "Meetings bots made easy", - "VERSION": "1.0.0", - "SERVE_INCLUDE_SCHEMA": False, - "COMPONENT_SPLIT_REQUEST": True, - "PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"], - "TAGS": [ - {"name": "Bots", "description": "Bot management endpoints"}, - ], - "SERVERS": [ - {"url": "https://app.attendee.dev", "description": "Production server"}, - ], -} -# publish with python manage.py spectacular --color --file docs/openapi.yml - -STORAGES = { - "default": { - "BACKEND": "storages.backends.s3.S3Storage", - "OPTIONS": { - "access_key": os.getenv("AWS_ACCESS_KEY_ID"), - "secret_key": os.getenv("AWS_SECRET_ACCESS_KEY"), - }, - }, - "staticfiles": { - "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", - }, -} -AWS_S3_SIGNATURE_VERSION = "s3v4" -AWS_RECORDING_STORAGE_BUCKET_NAME = os.getenv("AWS_RECORDING_STORAGE_BUCKET_NAME") diff --git a/attendee/attendee/settings/development.py b/attendee/attendee/settings/development.py deleted file mode 100644 index 34e3eae..0000000 --- a/attendee/attendee/settings/development.py +++ /dev/null @@ -1,39 +0,0 @@ -import os - -from .base import * - -DEBUG = True -ALLOWED_HOSTS = ["*"] - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "attendee_development", - "USER": "attendee_development_user", - "PASSWORD": "attendee_development_user", - "HOST": os.getenv("POSTGRES_HOST", "localhost"), - "PORT": "5432", - } -} - -# Log more stuff in development -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": { - "console": { - "class": "logging.StreamHandler", - }, - }, - "root": { - "handlers": ["console"], - "level": "INFO", - }, - "loggers": { - "django": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, - }, - }, -} diff --git a/attendee/attendee/settings/production-gke.py b/attendee/attendee/settings/production-gke.py deleted file mode 100644 index 930dd66..0000000 --- a/attendee/attendee/settings/production-gke.py +++ /dev/null @@ -1,69 +0,0 @@ -import os - -import dj_database_url - -from .base import * - -DEBUG = False -ALLOWED_HOSTS = ["*"] - -DATABASES = { - "default": dj_database_url.config( - env="DATABASE_URL", - conn_max_age=600, - conn_health_checks=True, - ssl_require=True, - ), -} - -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -# Disabling these because it's enforced at the ingress level on GKE -# SECURE_SSL_REDIRECT = True -# SECURE_HSTS_SECONDS = 60 -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True - -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.mailgun.org" -EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") -EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -DEFAULT_FROM_EMAIL = "noreply@mail.attendee.dev" - -ADMINS = [] - -if os.getenv("ERROR_REPORTS_RECEIVER_EMAIL_ADDRESS"): - ADMINS.append( - ( - "Attendee Error Reports Email Receiver", - os.getenv("ERROR_REPORTS_RECEIVER_EMAIL_ADDRESS"), - ) - ) - -SERVER_EMAIL = "noreply@mail.attendee.dev" - -# Needed on GKE -CSRF_TRUSTED_ORIGINS = ["https://*.attendee.dev"] - -# Log more stuff in staging -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": { - "console": { - "class": "logging.StreamHandler", - }, - }, - "root": { - "handlers": ["console"], - "level": "INFO", - }, - "loggers": { - "django": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, - }, - }, -} diff --git a/attendee/attendee/settings/production.py b/attendee/attendee/settings/production.py deleted file mode 100644 index cff3ab5..0000000 --- a/attendee/attendee/settings/production.py +++ /dev/null @@ -1,43 +0,0 @@ -import os - -import dj_database_url - -from .base import * - -DEBUG = False -ALLOWED_HOSTS = ["*"] - -DATABASES = { - "default": dj_database_url.config( - env="DATABASE_URL", - conn_max_age=600, - conn_health_checks=True, - ssl_require=True, - ), -} - -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -SECURE_SSL_REDIRECT = True -SECURE_HSTS_SECONDS = 60 -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True - -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.mailgun.org" -EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") -EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -DEFAULT_FROM_EMAIL = "noreply@mail.attendee.dev" - -ADMINS = [] - -if os.getenv("ERROR_REPORTS_RECEIVER_EMAIL_ADDRESS"): - ADMINS.append( - ( - "Attendee Error Reports Email Receiver", - os.getenv("ERROR_REPORTS_RECEIVER_EMAIL_ADDRESS"), - ) - ) - -SERVER_EMAIL = "noreply@mail.attendee.dev" diff --git a/attendee/attendee/settings/staging-gke.py b/attendee/attendee/settings/staging-gke.py deleted file mode 100644 index 5f40040..0000000 --- a/attendee/attendee/settings/staging-gke.py +++ /dev/null @@ -1,59 +0,0 @@ -import dj_database_url - -from .base import * - -DEBUG = False -ALLOWED_HOSTS = ["*"] - -DATABASES = { - "default": dj_database_url.config( - env="DATABASE_URL", - conn_max_age=600, - conn_health_checks=True, - ssl_require=True, - ), -} - -SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -# Disabling these because it's enforced at the ingress level on GKE -# SECURE_SSL_REDIRECT = True -# SECURE_HSTS_SECONDS = 60 -SESSION_COOKIE_SECURE = True -CSRF_COOKIE_SECURE = True - -# No email on staging -# EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -# EMAIL_HOST = "smtp.mailgun.org" -# EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") -# EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") -# EMAIL_PORT = 587 -# EMAIL_USE_TLS = True -# DEFAULT_FROM_EMAIL = "noreply@mail.attendee.dev" - -ADMINS = [] - -SERVER_EMAIL = "noreply@mail.attendee.dev" - -CSRF_TRUSTED_ORIGINS = ["https://*.attendee.dev"] - -# Log more stuff in staging -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "handlers": { - "console": { - "class": "logging.StreamHandler", - }, - }, - "root": { - "handlers": ["console"], - "level": "INFO", - }, - "loggers": { - "django": { - "handlers": ["console"], - "level": "INFO", - "propagate": False, - }, - }, -} diff --git a/attendee/attendee/settings/test.py b/attendee/attendee/settings/test.py deleted file mode 100644 index 088a02f..0000000 --- a/attendee/attendee/settings/test.py +++ /dev/null @@ -1,17 +0,0 @@ -import os - -from .base import * - -DEBUG = True -ALLOWED_HOSTS = ["*"] - -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "attendee_test", - "USER": "attendee_test_user", - "PASSWORD": "attendee_test_user", - "HOST": os.getenv("POSTGRES_HOST", "localhost"), - "PORT": "5432", - } -} diff --git a/attendee/attendee/urls.py b/attendee/attendee/urls.py deleted file mode 100644 index e1d016b..0000000 --- a/attendee/attendee/urls.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -URL configuration for attendee project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" - -from django.conf import settings -from django.contrib import admin -from django.http import HttpResponse -from django.urls import include, path -from drf_spectacular.views import ( - SpectacularAPIView, - SpectacularRedocView, - SpectacularSwaggerView, -) - -from accounts import views - - -def health_check(request): - return HttpResponse(status=200) - - -urlpatterns = [ - path("health/", health_check, name="health-check"), - path("admin/", admin.site.urls), - path("accounts/", include("allauth.urls")), - path("accounts/", include("allauth.socialaccount.urls")), - path("", views.home, name="home"), - path("projects/", include("bots.projects_urls", namespace="projects")), - path("api/v1/", include("bots.bots_api_urls")), -] - -if settings.DEBUG: - # API docs routes - only available in development - urlpatterns += [ - path("schema/", SpectacularAPIView.as_view(), name="schema"), - path( - "schema/swagger-ui/", - SpectacularSwaggerView.as_view(url_name="schema"), - name="swagger-ui", - ), - path( - "schema/redoc/", - SpectacularRedocView.as_view(url_name="schema"), - name="redoc", - ), - ] diff --git a/attendee/attendee/wsgi.py b/attendee/attendee/wsgi.py deleted file mode 100644 index 9a10111..0000000 --- a/attendee/attendee/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for attendee project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "attendee.settings") - -application = get_wsgi_application() diff --git a/attendee/bots/__init__.py b/attendee/bots/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/attendee/bots/admin.py b/attendee/bots/admin.py deleted file mode 100644 index dd6372e..0000000 --- a/attendee/bots/admin.py +++ /dev/null @@ -1,60 +0,0 @@ -import os - -from django.contrib import admin -from django.utils.html import format_html - -from .models import Bot, BotEvent - - -# Create an inline for BotEvent to show on the Bot admin page -class BotEventInline(admin.TabularInline): - model = BotEvent - extra = 0 - readonly_fields = ("created_at", "event_type", "event_sub_type", "old_state", "new_state", "metadata") - can_delete = False - max_num = 0 # Don't allow adding new events through admin - ordering = ("created_at",) # Show most recent events first - - def has_add_permission(self, request, obj=None): - return False - - -@admin.register(Bot) -class BotAdmin(admin.ModelAdmin): - actions = None - list_display = ("object_id", "name", "project", "state", "created_at", "updated_at", "view_logs_link") - list_filter = ("state", "project") - search_fields = ("object_id",) - readonly_fields = ("object_id", "created_at", "updated_at", "state", "view_logs_link") - inlines = [BotEventInline] # Add the inline to the admin - - def has_add_permission(self, request): - return False - - def has_change_permission(self, request, obj=None): - return False - - def has_delete_permission(self, request, obj=None): - return True - - def view_logs_link(self, obj): - pod_name = obj.k8s_pod_name() - link_formatting_str = os.getenv("CLOUD_LOGS_LINK_FORMATTING_STR") - if not link_formatting_str: - return None - try: - url = link_formatting_str.format(pod_name=pod_name) - return format_html('View Logs', url) - except Exception: - return None - - view_logs_link.short_description = "Cloud Logs" - - # Optional: if you want to organize the fields in the detail view - fieldsets = ( - ("Basic Information", {"fields": ("object_id", "name", "project")}), - ("Meeting Details", {"fields": ("meeting_url", "meeting_uuid")}), - ("Status", {"fields": ("state", "view_logs_link")}), - ("Settings", {"fields": ("settings",)}), - ("Metadata", {"fields": ("created_at", "updated_at", "version")}), - ) diff --git a/attendee/bots/apps.py b/attendee/bots/apps.py deleted file mode 100644 index cb5847b..0000000 --- a/attendee/bots/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class BotsConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "bots" diff --git a/attendee/bots/authentication.py b/attendee/bots/authentication.py deleted file mode 100644 index 1444e59..0000000 --- a/attendee/bots/authentication.py +++ /dev/null @@ -1,30 +0,0 @@ -import hashlib - -from rest_framework import authentication, exceptions - -from .models import ApiKey - - -class ApiKeyAuthentication(authentication.BaseAuthentication): - def authenticate_header(self, request): - return "Token" - - def authenticate(self, request): - if "Authorization" not in request.headers: - raise exceptions.AuthenticationFailed({"detail": "Missing Authorization header"}) - - auth_header = request.headers.get("Authorization", "").split() - - if not auth_header or len(auth_header) != 2 or auth_header[0].lower() != "token": - raise exceptions.AuthenticationFailed({"detail": "Invalid Authorization header. Should have this format: Token "}) - - api_key = auth_header[1] - - try: - key_hash = hashlib.sha256(api_key.encode()).hexdigest() - api_key_obj = ApiKey.objects.select_related("project").get(key_hash=key_hash, disabled_at__isnull=True) - except ApiKey.DoesNotExist: - raise exceptions.AuthenticationFailed({"detail": "Invalid or disabled API key"}) - - # Return (None, api_key_obj) instead of (user, auth) - return (None, api_key_obj) diff --git a/attendee/bots/bot_adapter.py b/attendee/bots/bot_adapter.py deleted file mode 100644 index a7ccfc6..0000000 --- a/attendee/bots/bot_adapter.py +++ /dev/null @@ -1,19 +0,0 @@ -class BotAdapter: - class Messages: - LEAVE_MEETING_WAITING_FOR_HOST = "Leave meeting because received waiting for host status" - ZOOM_AUTHORIZATION_FAILED = "Zoom authorization failed" - ZOOM_MEETING_STATUS_FAILED = "Zoom meeting status failed" - ZOOM_MEETING_STATUS_FAILED_UNABLE_TO_JOIN_EXTERNAL_MEETING = "Zoom meeting status failed - unable to join external meeting" - ZOOM_SDK_INTERNAL_ERROR = "Zoom SDK Internal Error" - BOT_PUT_IN_WAITING_ROOM = "Bot put in waiting room" - BOT_JOINED_MEETING = "Bot joined meeting" - BOT_RECORDING_PERMISSION_GRANTED = "Bot recording permission granted" - MEETING_ENDED = "Meeting ended" - NEW_UTTERANCE = "New utterance" - UI_ELEMENT_NOT_FOUND = "UI Element Not Found" - REQUEST_TO_JOIN_DENIED = "Request to join denied" - ADAPTER_REQUESTED_BOT_LEAVE_MEETING = "Adapter requested bot leave meeting" - - class LEAVE_REASON: - AUTO_LEAVE_SILENCE = "AUTO_LEAVE_SILENCE" - AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING = "AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING" diff --git a/attendee/bots/bot_controller/__init__.py b/attendee/bots/bot_controller/__init__.py deleted file mode 100644 index e3ffdab..0000000 --- a/attendee/bots/bot_controller/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .bot_controller import BotController - -__all__ = ["BotController"] diff --git a/attendee/bots/bot_controller/audio_output_manager.py b/attendee/bots/bot_controller/audio_output_manager.py deleted file mode 100644 index 524ba17..0000000 --- a/attendee/bots/bot_controller/audio_output_manager.py +++ /dev/null @@ -1,91 +0,0 @@ -import threading -import time - -from bots.utils import mp3_to_pcm - -from .text_to_speech import generate_audio_from_text - - -class AudioOutputManager: - SAMPLE_RATE = 44100 - - def __init__( - self, - currently_playing_audio_media_request_finished_callback, - play_raw_audio_callback, - ): - self.currently_playing_audio_media_request = None - self.currently_playing_audio_media_request_started_at = None - self.currently_playing_audio_media_request_duration_ms = None - self.currently_playing_audio_media_request_finished_callback = currently_playing_audio_media_request_finished_callback - self.play_raw_audio_callback = play_raw_audio_callback - self.currently_playing_audio_media_request_raw_audio_pcm_bytes = None - self.audio_thread = None - self.stop_audio_thread = False - - def _play_audio_chunks(self, audio_data, chunk_size): - for i in range(0, len(audio_data), chunk_size): - if self.stop_audio_thread: - break - chunk = audio_data[i : i + chunk_size] - self.play_raw_audio_callback(bytes=chunk, sample_rate=self.SAMPLE_RATE) - time.sleep(0.9) # the chunk size is a second's worth of audio so we'll sleep for almost that much - - def _stop_audio_thread(self): - """Stop the currently running audio thread if it exists.""" - self.stop_audio_thread = True - if self.audio_thread and self.audio_thread.is_alive(): - self.audio_thread.join() - self.stop_audio_thread = False - - def start_playing_audio_media_request(self, audio_media_request): - # Stop any existing audio playback - self._stop_audio_thread() - - if audio_media_request.media_blob: - # Handle raw audio blob case - self.currently_playing_audio_media_request_raw_audio_pcm_bytes = mp3_to_pcm(audio_media_request.media_blob.blob, sample_rate=self.SAMPLE_RATE) - self.currently_playing_audio_media_request_duration_ms = audio_media_request.media_blob.duration_ms - else: - # Handle text-to-speech case - audio_blob, duration_ms = generate_audio_from_text( - text=audio_media_request.text_to_speak, - settings=audio_media_request.text_to_speech_settings, - sample_rate=self.SAMPLE_RATE, - bot=audio_media_request.bot, - ) - self.currently_playing_audio_media_request_raw_audio_pcm_bytes = audio_blob - self.currently_playing_audio_media_request_duration_ms = duration_ms - - self.currently_playing_audio_media_request = audio_media_request - self.currently_playing_audio_media_request_started_at = time.time() - - bytes_per_sample = 2 - # Start audio playback in a new thread - self.audio_thread = threading.Thread( - target=self._play_audio_chunks, - args=( - self.currently_playing_audio_media_request_raw_audio_pcm_bytes, - self.SAMPLE_RATE * bytes_per_sample, - ), - ) - self.audio_thread.start() - - def currently_playing_audio_media_request_is_finished(self): - if not self.currently_playing_audio_media_request or not self.currently_playing_audio_media_request_started_at: - return False - elapsed_ms = (time.time() - self.currently_playing_audio_media_request_started_at) * 1000 - if elapsed_ms > self.currently_playing_audio_media_request_duration_ms: - return True - return False - - def clear_currently_playing_audio_media_request(self): - self._stop_audio_thread() - self.currently_playing_audio_media_request = None - self.currently_playing_audio_media_request_started_at = None - - def monitor_currently_playing_audio_media_request(self): - if self.currently_playing_audio_media_request_is_finished(): - temp_currently_playing_audio_media_request = self.currently_playing_audio_media_request - self.clear_currently_playing_audio_media_request() - self.currently_playing_audio_media_request_finished_callback(temp_currently_playing_audio_media_request) diff --git a/attendee/bots/bot_controller/automatic_leave_configuration.py b/attendee/bots/bot_controller/automatic_leave_configuration.py deleted file mode 100644 index 5c6f677..0000000 --- a/attendee/bots/bot_controller/automatic_leave_configuration.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass - - -@dataclass(frozen=True) -class AutomaticLeaveConfiguration: - """Specifies conditions under which the bot will automatically leave a meeting. - - Attributes: - silence_threshold_seconds: Number of seconds of continuous silence after which the bot should leave - only_participant_in_meeting_threshold_seconds: Number of seconds to wait before leaving if bot is the only participant - """ - - silence_threshold_seconds: int = 300 - only_participant_in_meeting_threshold_seconds: int = 60 - wait_for_host_to_start_meeting_timeout_seconds: int = 120 diff --git a/attendee/bots/bot_controller/bot_controller.py b/attendee/bots/bot_controller/bot_controller.py deleted file mode 100644 index c445599..0000000 --- a/attendee/bots/bot_controller/bot_controller.py +++ /dev/null @@ -1,735 +0,0 @@ -import hashlib -import json -import logging -import os -import signal -import traceback -import threading - -import gi -import redis -from django.core.files.base import ContentFile -from django.utils import timezone - -from bots.bot_adapter import BotAdapter -from bots.models import ( - Bot, - BotDebugScreenshot, - BotEventManager, - BotEventSubTypes, - BotEventTypes, - BotMediaRequestManager, - BotMediaRequestMediaTypes, - BotMediaRequestStates, - BotStates, - Credentials, - MeetingTypes, - Participant, - Recording, - RecordingFormats, - RecordingManager, - RecordingStates, - Utterance, -) -from bots.utils import meeting_type_from_url - -from .audio_output_manager import AudioOutputManager -from .automatic_leave_configuration import AutomaticLeaveConfiguration -from .closed_caption_manager import ClosedCaptionManager -from .file_uploader import FileUploader -from .gstreamer_pipeline import GstreamerPipeline -from .individual_audio_input_manager import IndividualAudioInputManager -from .pipeline_configuration import PipelineConfiguration -from .rtmp_client import RTMPClient -import requests - -gi.require_version("GLib", "2.0") -from gi.repository import GLib - -logger = logging.getLogger(__name__) - - -class BotController: - def call_lingo_callback(self, file_key): - url = "http://lingo-bot:8001/meetings/call-to-lingo" - logger.info(os.environ.get('AWS_RECORDING_STORAGE_BUCKET_NAME')) - payload = {"key": f"s3://{os.environ.get('AWS_RECORDING_STORAGE_BUCKET_NAME')}/{file_key}"} - - def send_request(): - try: - response = requests.post(url, json=payload) # Optional timeout - if response.status_code == 200: - logger.info("Callback received successfully.") - else: - logger.info(f"Failed to get callback. Status code: {response.status_code}, Response: {response.text}") - except Exception as e: - logger.info(f"Failed to send callback: {e}") - - # Fire and forget - threading.Thread(target=send_request, daemon=True).start() - - def get_google_meet_bot_adapter(self): - from bots.google_meet_bot_adapter import GoogleMeetBotAdapter - - return GoogleMeetBotAdapter( - display_name=self.bot_in_db.name, - send_message_callback=self.on_message_from_adapter, - meeting_url=self.bot_in_db.meeting_url, - add_video_frame_callback=self.gstreamer_pipeline.on_new_video_frame, - wants_any_video_frames_callback=self.gstreamer_pipeline.wants_any_video_frames, - add_mixed_audio_chunk_callback=self.gstreamer_pipeline.on_mixed_audio_raw_data_received_callback, - upsert_caption_callback=self.closed_caption_manager.upsert_caption, - automatic_leave_configuration=self.automatic_leave_configuration, - ) - - def get_teams_bot_adapter(self): - from bots.teams_bot_adapter import TeamsBotAdapter - - return TeamsBotAdapter( - display_name=self.bot_in_db.name, - send_message_callback=self.on_message_from_adapter, - meeting_url=self.bot_in_db.meeting_url, - add_video_frame_callback=self.gstreamer_pipeline.on_new_video_frame, - wants_any_video_frames_callback=self.gstreamer_pipeline.wants_any_video_frames, - add_mixed_audio_chunk_callback=self.gstreamer_pipeline.on_mixed_audio_raw_data_received_callback, - upsert_caption_callback=self.closed_caption_manager.upsert_caption, - automatic_leave_configuration=self.automatic_leave_configuration, - ) - - def get_zoom_bot_adapter(self): - from bots.zoom_bot_adapter import ZoomBotAdapter - - zoom_oauth_credentials_record = self.bot_in_db.project.credentials.filter(credential_type=Credentials.CredentialTypes.ZOOM_OAUTH).first() - if not zoom_oauth_credentials_record: - raise Exception("Zoom OAuth credentials not found") - - zoom_oauth_credentials = zoom_oauth_credentials_record.get_credentials() - if not zoom_oauth_credentials: - raise Exception("Zoom OAuth credentials data not found") - - return ZoomBotAdapter( - use_one_way_audio=self.pipeline_configuration.transcribe_audio, - use_mixed_audio=self.pipeline_configuration.record_audio or self.pipeline_configuration.rtmp_stream_audio, - use_video=self.pipeline_configuration.record_video or self.pipeline_configuration.rtmp_stream_video, - display_name=self.bot_in_db.name, - send_message_callback=self.on_message_from_adapter, - add_audio_chunk_callback=self.individual_audio_input_manager.add_chunk, - zoom_client_id=zoom_oauth_credentials["client_id"], - zoom_client_secret=zoom_oauth_credentials["client_secret"], - meeting_url=self.bot_in_db.meeting_url, - add_video_frame_callback=self.gstreamer_pipeline.on_new_video_frame, - wants_any_video_frames_callback=self.gstreamer_pipeline.wants_any_video_frames, - add_mixed_audio_chunk_callback=self.gstreamer_pipeline.on_mixed_audio_raw_data_received_callback, - automatic_leave_configuration=self.automatic_leave_configuration, - ) - - def get_meeting_type(self): - meeting_type = meeting_type_from_url(self.bot_in_db.meeting_url) - if meeting_type is None: - raise Exception(f"Could not determine meeting type for meeting url {self.bot_in_db.meeting_url}") - return meeting_type - - def get_audio_format(self): - meeting_type = self.get_meeting_type() - if meeting_type == MeetingTypes.ZOOM: - return GstreamerPipeline.AUDIO_FORMAT_PCM - elif meeting_type == MeetingTypes.GOOGLE_MEET: - return GstreamerPipeline.AUDIO_FORMAT_FLOAT - elif meeting_type == MeetingTypes.TEAMS: - return GstreamerPipeline.AUDIO_FORMAT_FLOAT - - def get_num_audio_sources(self): - meeting_type = self.get_meeting_type() - if meeting_type == MeetingTypes.ZOOM: - return 1 - elif meeting_type == MeetingTypes.GOOGLE_MEET: - return 3 - elif meeting_type == MeetingTypes.TEAMS: - return 1 - - def get_bot_adapter(self): - meeting_type = self.get_meeting_type() - if meeting_type == MeetingTypes.ZOOM: - return self.get_zoom_bot_adapter() - elif meeting_type == MeetingTypes.GOOGLE_MEET: - return self.get_google_meet_bot_adapter() - elif meeting_type == MeetingTypes.TEAMS: - return self.get_teams_bot_adapter() - - def get_first_buffer_timestamp_ms(self): - if self.gstreamer_pipeline.start_time_ns is None: - return None - return int(self.gstreamer_pipeline.start_time_ns / 1_000_000) + self.adapter.get_first_buffer_timestamp_ms_offset() - - def recording_file_saved(self, s3_storage_key): - recording = Recording.objects.get(bot=self.bot_in_db, is_default_recording=True) - recording.file = s3_storage_key - recording.first_buffer_timestamp_ms = self.get_first_buffer_timestamp_ms() - recording.save() - - def get_recording_filename(self): - recording = Recording.objects.get(bot=self.bot_in_db, is_default_recording=True) - return f"{hashlib.md5(recording.object_id.encode()).hexdigest()}.{self.bot_in_db.recording_format()}" - - def on_rtmp_connection_failed(self): - logger.info("RTMP connection failed") - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.FATAL_ERROR, - event_sub_type=BotEventSubTypes.FATAL_ERROR_RTMP_CONNECTION_FAILED, - event_metadata={"rtmp_destination_url": self.bot_in_db.rtmp_destination_url()}, - ) - self.cleanup() - - def on_new_sample_from_gstreamer_pipeline(self, data): - # For now, we'll assume that if rtmp streaming is enabled, we don't need to upload to s3 - if self.rtmp_client: - write_succeeded = self.rtmp_client.write_data(data) - if not write_succeeded: - GLib.idle_add(lambda: self.on_rtmp_connection_failed()) - else: - raise Exception("No rtmp client found") - - def cleanup(self): - if self.cleanup_called: - logger.info("Cleanup already called, exiting") - return - self.cleanup_called = True - - normal_quitting_process_worked = False - import threading - - def terminate_worker(): - import time - - time.sleep(20) - if normal_quitting_process_worked: - logger.info("Normal quitting process worked, not force terminating worker") - return - logger.info("Terminating worker with hard timeout...") - os.kill(os.getpid(), signal.SIGKILL) # Force terminate the worker process - - termination_thread = threading.Thread(target=terminate_worker, daemon=True) - termination_thread.start() - - if self.gstreamer_pipeline: - logger.info("Telling gstreamer pipeline to cleanup...") - self.gstreamer_pipeline.cleanup() - - if self.rtmp_client: - logger.info("Telling rtmp client to cleanup...") - self.rtmp_client.stop() - - if self.adapter: - logger.info("Telling adapter to leave meeting...") - self.adapter.leave() - logger.info("Telling adapter to cleanup...") - self.adapter.cleanup() - - if self.main_loop and self.main_loop.is_running(): - self.main_loop.quit() - - if self.get_gstreamer_file_location(): - logger.info("Telling file uploader to upload recording file...") - file_key = self.get_recording_filename() - file_uploader = FileUploader( - os.environ.get("AWS_RECORDING_STORAGE_BUCKET_NAME"), - file_key, - ) - file_uploader.upload_file(self.get_gstreamer_file_location()) - file_uploader.wait_for_upload() - logger.info("File uploader finished uploading file") - file_uploader.delete_file(self.get_gstreamer_file_location()) - logger.info("File uploader deleted file from local filesystem") - self.recording_file_saved(file_uploader.key) - self.call_lingo_callback(file_key) - - # url = "http://lingo-bot:8001/meetings/call-to-lingo" - # logger.info(os.environ.get('AWS_RECORDING_STORAGE_BUCKET_NAME')) - # payload = {"key": f"s3://{os.environ.get('AWS_RECORDING_STORAGE_BUCKET_NAME')}/{file_key}"} - - # response = requests.post(url, json=payload) - - # if response.status_code == 200: - # print("Callback received successfully.") - # else: - # print(f"Failed to get callback. Status code: {response.status_code}, Response: {response.text}") - - if self.bot_in_db.state == BotStates.POST_PROCESSING: - BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.POST_PROCESSING_COMPLETED) - - normal_quitting_process_worked = True - - def __init__(self, bot_id): - self.bot_in_db = Bot.objects.get(id=bot_id) - self.cleanup_called = False - self.run_called = False - - self.automatic_leave_configuration = AutomaticLeaveConfiguration() - - if self.bot_in_db.rtmp_destination_url(): - self.pipeline_configuration = PipelineConfiguration.rtmp_streaming_bot() - else: - self.pipeline_configuration = PipelineConfiguration.recorder_bot() - - def get_gstreamer_sink_type(self): - if self.pipeline_configuration.rtmp_stream_audio or self.pipeline_configuration.rtmp_stream_video: - return GstreamerPipeline.SINK_TYPE_APPSINK - else: - return GstreamerPipeline.SINK_TYPE_FILE - - def get_gstreamer_output_format(self): - if self.pipeline_configuration.rtmp_stream_audio or self.pipeline_configuration.rtmp_stream_video: - return GstreamerPipeline.OUTPUT_FORMAT_FLV - - if self.bot_in_db.recording_format() == RecordingFormats.WEBM: - return GstreamerPipeline.OUTPUT_FORMAT_WEBM - else: - return GstreamerPipeline.OUTPUT_FORMAT_MP4 - - def get_gstreamer_file_location(self): - if self.pipeline_configuration.rtmp_stream_audio or self.pipeline_configuration.rtmp_stream_video: - return None - else: - return os.path.join("/tmp", self.get_recording_filename()) - - def run(self): - if self.run_called: - raise Exception("Run already called, exiting") - self.run_called = True - - redis_url = os.getenv("REDIS_URL") + ("?ssl_cert_reqs=none" if os.getenv("DISABLE_REDIS_SSL") else "") - redis_client = redis.from_url(redis_url) - pubsub = redis_client.pubsub() - channel = f"bot_{self.bot_in_db.id}" - pubsub.subscribe(channel) - - # Initialize core objects - # Only used for adapters that can provide per-participant audio - self.individual_audio_input_manager = IndividualAudioInputManager( - save_utterance_callback=self.save_individual_audio_utterance, - get_participant_callback=self.get_participant, - ) - - # Only used for adapters that can provide closed captions - self.closed_caption_manager = ClosedCaptionManager( - save_utterance_callback=self.save_closed_caption_utterance, - get_participant_callback=self.get_participant, - ) - - self.rtmp_client = None - if self.pipeline_configuration.rtmp_stream_audio or self.pipeline_configuration.rtmp_stream_video: - self.rtmp_client = RTMPClient(rtmp_url=self.bot_in_db.rtmp_destination_url()) - self.rtmp_client.start() - - self.gstreamer_pipeline = GstreamerPipeline( - on_new_sample_callback=self.on_new_sample_from_gstreamer_pipeline, - video_frame_size=(1920, 1080), - audio_format=self.get_audio_format(), - output_format=self.get_gstreamer_output_format(), - num_audio_sources=self.get_num_audio_sources(), - sink_type=self.get_gstreamer_sink_type(), - file_location=self.get_gstreamer_file_location(), - ) - self.gstreamer_pipeline.setup() - - self.adapter = self.get_bot_adapter() - - self.audio_output_manager = AudioOutputManager( - currently_playing_audio_media_request_finished_callback=self.currently_playing_audio_media_request_finished, - play_raw_audio_callback=self.adapter.send_raw_audio, - ) - - # Create GLib main loop - self.main_loop = GLib.MainLoop() - - # Set up Redis listener in a separate thread - import threading - - def redis_listener(): - while True: - try: - message = pubsub.get_message(timeout=1.0) - if message: - # Schedule Redis message handling in the main GLib loop - GLib.idle_add(lambda: self.handle_redis_message(message)) - except Exception as e: - logger.info(f"Error in Redis listener: {e}") - break - - redis_thread = threading.Thread(target=redis_listener, daemon=True) - redis_thread.start() - - # Add timeout just for audio processing - self.first_timeout_call = True - GLib.timeout_add(100, self.on_main_loop_timeout) - - # Add signal handlers so that when we get a SIGTERM or SIGINT, we can clean up the bot - GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGTERM, self.handle_glib_shutdown) - GLib.unix_signal_add(GLib.PRIORITY_HIGH, signal.SIGINT, self.handle_glib_shutdown) - - # Run the main loop - try: - self.main_loop.run() - except Exception as e: - logger.info(f"Error in bot {self.bot_in_db.id}: {str(e)}") - self.cleanup() - finally: - # Clean up Redis subscription - pubsub.unsubscribe(channel) - pubsub.close() - - def take_action_based_on_bot_in_db(self): - if self.bot_in_db.state == BotStates.JOINING: - logger.info("take_action_based_on_bot_in_db - JOINING") - BotEventManager.set_requested_bot_action_taken_at(self.bot_in_db) - self.adapter.init() - if self.bot_in_db.state == BotStates.LEAVING: - logger.info("take_action_based_on_bot_in_db - LEAVING") - BotEventManager.set_requested_bot_action_taken_at(self.bot_in_db) - self.adapter.leave() - - def get_participant(self, participant_id): - return self.adapter.get_participant(participant_id) - - def currently_playing_audio_media_request_finished(self, audio_media_request): - logger.info("currently_playing_audio_media_request_finished called") - BotMediaRequestManager.set_media_request_finished(audio_media_request) - self.take_action_based_on_audio_media_requests_in_db() - - def take_action_based_on_audio_media_requests_in_db(self): - media_type = BotMediaRequestMediaTypes.AUDIO - oldest_enqueued_media_request = self.bot_in_db.media_requests.filter(state=BotMediaRequestStates.ENQUEUED, media_type=media_type).order_by("created_at").first() - if not oldest_enqueued_media_request: - return - currently_playing_media_request = self.bot_in_db.media_requests.filter(state=BotMediaRequestStates.PLAYING, media_type=media_type).first() - if currently_playing_media_request: - logger.info(f"Currently playing media request {currently_playing_media_request.id} so cannot play another media request") - return - - try: - BotMediaRequestManager.set_media_request_playing(oldest_enqueued_media_request) - self.audio_output_manager.start_playing_audio_media_request(oldest_enqueued_media_request) - except Exception as e: - logger.info(f"Error sending raw audio: {e}") - BotMediaRequestManager.set_media_request_failed_to_play(oldest_enqueued_media_request) - - def take_action_based_on_image_media_requests_in_db(self): - from bots.utils import png_to_yuv420_frame - - media_type = BotMediaRequestMediaTypes.IMAGE - - # Get all enqueued image media requests for this bot, ordered by creation time - enqueued_requests = self.bot_in_db.media_requests.filter(state=BotMediaRequestStates.ENQUEUED, media_type=media_type).order_by("created_at") - - if not enqueued_requests.exists(): - return - - # Get the most recently created request - most_recent_request = enqueued_requests.last() - - # Mark the most recent request as FINISHED - try: - BotMediaRequestManager.set_media_request_playing(most_recent_request) - self.adapter.send_raw_image(png_to_yuv420_frame(most_recent_request.media_blob.blob)) - BotMediaRequestManager.set_media_request_finished(most_recent_request) - except Exception as e: - logger.info(f"Error sending raw image: {e}") - BotMediaRequestManager.set_media_request_failed_to_play(most_recent_request) - - # Mark all other enqueued requests as DROPPED - for request in enqueued_requests.exclude(id=most_recent_request.id): - BotMediaRequestManager.set_media_request_dropped(request) - - def take_action_based_on_media_requests_in_db(self): - self.take_action_based_on_audio_media_requests_in_db() - self.take_action_based_on_image_media_requests_in_db() - - def handle_glib_shutdown(self): - logger.info("handle_glib_shutdown called") - - try: - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.FATAL_ERROR, - event_sub_type=BotEventSubTypes.FATAL_ERROR_PROCESS_TERMINATED, - ) - except Exception as e: - logger.info(f"Error creating FATAL_ERROR event: {e}") - - self.cleanup() - return False - - def handle_redis_message(self, message): - if message and message["type"] == "message": - data = json.loads(message["data"].decode("utf-8")) - command = data.get("command") - - if command == "sync": - logger.info(f"Syncing bot {self.bot_in_db.object_id}") - self.bot_in_db.refresh_from_db() - self.take_action_based_on_bot_in_db() - elif command == "sync_media_requests": - logger.info(f"Syncing media requests for bot {self.bot_in_db.object_id}") - self.bot_in_db.refresh_from_db() - self.take_action_based_on_media_requests_in_db() - else: - logger.info(f"Unknown command: {command}") - - def set_bot_heartbeat(self): - if self.bot_in_db.last_heartbeat_timestamp is None or self.bot_in_db.last_heartbeat_timestamp <= int(timezone.now().timestamp()) - 60: - self.bot_in_db.set_heartbeat() - - def on_main_loop_timeout(self): - try: - if self.first_timeout_call: - logger.info("First timeout call - taking initial action") - self.bot_in_db.refresh_from_db() - self.take_action_based_on_bot_in_db() - self.first_timeout_call = False - - # Set heartbeat - self.set_bot_heartbeat() - - # Process audio chunks - self.individual_audio_input_manager.process_chunks() - - # Process captions - self.closed_caption_manager.process_captions() - - # Check if auto-leave conditions are met - self.adapter.check_auto_leave_conditions() - - # Process audio output - self.audio_output_manager.monitor_currently_playing_audio_media_request() - return True - - except Exception as e: - logger.info(f"Error in timeout callback: {e}") - logger.info("Traceback:") - logger.info(traceback.format_exc()) - self.cleanup() - return False - - def get_recording_in_progress(self): - recordings_in_progress = Recording.objects.filter(bot=self.bot_in_db, state=RecordingStates.IN_PROGRESS) - if recordings_in_progress.count() == 0: - raise Exception("No recording in progress found") - if recordings_in_progress.count() > 1: - raise Exception(f"Expected at most one recording in progress for bot {self.bot_in_db.object_id}, but found {recordings_in_progress.count()}") - return recordings_in_progress.first() - - def save_closed_caption_utterance(self, message): - participant, _ = Participant.objects.get_or_create( - bot=self.bot_in_db, - uuid=message["participant_uuid"], - defaults={ - "user_uuid": message["participant_user_uuid"], - "full_name": message["participant_full_name"], - }, - ) - - # Create new utterance record - recording_in_progress = self.get_recording_in_progress() - source_uuid = f"{recording_in_progress.object_id}-{message['source_uuid_suffix']}" - utterance, _ = Utterance.objects.update_or_create( - recording=recording_in_progress, - source_uuid=source_uuid, - defaults={ - "source": Utterance.Sources.CLOSED_CAPTION_FROM_PLATFORM, - "participant": participant, - "transcription": {"transcript": message["text"]}, - "timestamp_ms": message["timestamp_ms"], - "duration_ms": message["duration_ms"], - "sample_rate": None, - }, - ) - - RecordingManager.set_recording_transcription_in_progress(recording_in_progress) - - def save_individual_audio_utterance(self, message): - from bots.tasks.process_utterance_task import process_utterance - - logger.info("Received message that new utterance was detected") - - # Create participant record if it doesn't exist - participant, _ = Participant.objects.get_or_create( - bot=self.bot_in_db, - uuid=message["participant_uuid"], - defaults={ - "user_uuid": message["participant_user_uuid"], - "full_name": message["participant_full_name"], - }, - ) - - # Create new utterance record - recording_in_progress = self.get_recording_in_progress() - utterance = Utterance.objects.create( - source=Utterance.Sources.PER_PARTICIPANT_AUDIO, - recording=recording_in_progress, - participant=participant, - audio_blob=message["audio_data"], - audio_format=Utterance.AudioFormat.PCM, - timestamp_ms=message["timestamp_ms"], - duration_ms=len(message["audio_data"]) / 64, - sample_rate=message["sample_rate"], - ) - - # Process the utterance immediately - process_utterance.delay(utterance.id) - return - - def on_message_from_adapter(self, message): - GLib.idle_add(lambda: self.take_action_based_on_message_from_adapter(message)) - - def flush_utterances(self): - if self.individual_audio_input_manager: - logger.info("Flushing utterances...") - self.individual_audio_input_manager.flush_utterances() - if self.closed_caption_manager: - logger.info("Flushing captions...") - self.closed_caption_manager.flush_captions() - - def take_action_based_on_message_from_adapter(self, message): - if message.get("message") == BotAdapter.Messages.REQUEST_TO_JOIN_DENIED: - logger.info("Received message that request to join was denied") - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.COULD_NOT_JOIN, - event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_REQUEST_TO_JOIN_DENIED, - ) - self.cleanup() - return - - if message.get("message") == BotAdapter.Messages.UI_ELEMENT_NOT_FOUND: - logger.info(f"Received message that UI element not found at {message.get('current_time')}") - - screenshot_available = message.get("screenshot_path") is not None - - new_bot_event = BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.FATAL_ERROR, - event_sub_type=BotEventSubTypes.FATAL_ERROR_UI_ELEMENT_NOT_FOUND, - event_metadata={ - "step": message.get("step"), - "current_time": message.get("current_time").isoformat(), - "exception_type": message.get("exception_type"), - "exception_message": message.get("exception_message"), - "inner_exception_type": message.get("inner_exception_type"), - "inner_exception_message": message.get("inner_exception_message"), - }, - ) - - if screenshot_available: - # Create debug screenshot - debug_screenshot = BotDebugScreenshot.objects.create(bot_event=new_bot_event) - - # Read the file content from the path - with open(message.get("screenshot_path"), "rb") as f: - screenshot_content = f.read() - debug_screenshot.file.save( - "debug_screenshot.png", - ContentFile(screenshot_content), - save=True, - ) - - self.cleanup() - return - - if message.get("message") == BotAdapter.Messages.ADAPTER_REQUESTED_BOT_LEAVE_MEETING: - logger.info(f"Received message that adapter requested bot leave meeting reason={message.get('leave_reason')}") - - event_sub_type_for_reason = { - BotAdapter.LEAVE_REASON.AUTO_LEAVE_SILENCE: BotEventSubTypes.LEAVE_REQUESTED_AUTO_LEAVE_SILENCE, - BotAdapter.LEAVE_REASON.AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING: BotEventSubTypes.LEAVE_REQUESTED_AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING, - }[message.get("leave_reason")] - - BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.LEAVE_REQUESTED, event_sub_type=event_sub_type_for_reason) - BotEventManager.set_requested_bot_action_taken_at(self.bot_in_db) - self.adapter.leave() - return - - if message.get("message") == BotAdapter.Messages.MEETING_ENDED: - logger.info("Received message that meeting ended") - self.flush_utterances() - if self.bot_in_db.state == BotStates.LEAVING: - BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.BOT_LEFT_MEETING) - else: - BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.MEETING_ENDED) - self.cleanup() - - return - - if message.get("message") == BotAdapter.Messages.ZOOM_MEETING_STATUS_FAILED_UNABLE_TO_JOIN_EXTERNAL_MEETING: - logger.info(f"Received message that meeting status failed unable to join external meeting with zoom_result_code={message.get('zoom_result_code')}") - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.COULD_NOT_JOIN, - event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_UNPUBLISHED_ZOOM_APP, - event_metadata={"zoom_result_code": str(message.get("zoom_result_code"))}, - ) - self.cleanup() - return - - if message.get("message") == BotAdapter.Messages.ZOOM_MEETING_STATUS_FAILED: - logger.info(f"Received message that meeting status failed with zoom_result_code={message.get('zoom_result_code')}") - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.COULD_NOT_JOIN, - event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_MEETING_STATUS_FAILED, - event_metadata={"zoom_result_code": str(message.get("zoom_result_code"))}, - ) - self.cleanup() - return - - if message.get("message") == BotAdapter.Messages.ZOOM_AUTHORIZATION_FAILED: - logger.info(f"Received message that authorization failed with zoom_result_code={message.get('zoom_result_code')}") - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.COULD_NOT_JOIN, - event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_AUTHORIZATION_FAILED, - event_metadata={"zoom_result_code": str(message.get("zoom_result_code"))}, - ) - self.cleanup() - return - - if message.get("message") == BotAdapter.Messages.ZOOM_SDK_INTERNAL_ERROR: - logger.info(f"Received message that SDK internal error with zoom_result_code={message.get('zoom_result_code')}") - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.COULD_NOT_JOIN, - event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_SDK_INTERNAL_ERROR, - event_metadata={"zoom_result_code": str(message.get("zoom_result_code"))}, - ) - self.cleanup() - return - - if message.get("message") == BotAdapter.Messages.LEAVE_MEETING_WAITING_FOR_HOST: - logger.info("Received message to Leave meeting because received waiting for host status") - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.COULD_NOT_JOIN, - event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_NOT_STARTED_WAITING_FOR_HOST, - ) - self.cleanup() - return - - if message.get("message") == BotAdapter.Messages.BOT_PUT_IN_WAITING_ROOM: - logger.info("Received message to put bot in waiting room") - BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.BOT_PUT_IN_WAITING_ROOM) - return - - if message.get("message") == BotAdapter.Messages.BOT_JOINED_MEETING: - logger.info("Received message that bot joined meeting") - BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.BOT_JOINED_MEETING) - return - - if message.get("message") == BotAdapter.Messages.BOT_RECORDING_PERMISSION_GRANTED: - logger.info("Received message that bot recording permission granted") - BotEventManager.create_event( - bot=self.bot_in_db, - event_type=BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED, - ) - return - - raise Exception(f"Received unexpected message from bot adapter: {message}") diff --git a/attendee/bots/bot_controller/closed_caption_manager.py b/attendee/bots/bot_controller/closed_caption_manager.py deleted file mode 100644 index 2297149..0000000 --- a/attendee/bots/bot_controller/closed_caption_manager.py +++ /dev/null @@ -1,77 +0,0 @@ -from datetime import datetime, timedelta -from typing import Dict, Optional - - -class CaptionEntry: - def __init__(self, caption_data: dict): - self.caption_data = caption_data - self.created_at = datetime.utcnow() - self.modified_at = self.created_at - self.last_upsert_to_db_at: Optional[datetime] = None - - def update(self, caption_data: dict): - self.caption_data = caption_data - self.modified_at = datetime.utcnow() - - def should_upsert_to_db(self, should_flush=False) -> bool: - # If never upserted to db, and it's been at least a few seconds since creation - if not self.last_upsert_to_db_at: - return ((datetime.utcnow() - self.created_at) > timedelta(seconds=15)) or should_flush - - # If modified since last upsert to db and hasn't been updated recently - return self.modified_at > self.last_upsert_to_db_at and (((datetime.utcnow() - self.modified_at) > timedelta(seconds=15)) or should_flush) - - def mark_upserted_to_db(self): - self.last_upsert_to_db_at = datetime.utcnow() - - -class ClosedCaptionManager: - def __init__(self, *, save_utterance_callback, get_participant_callback): - self.captions: Dict[str, CaptionEntry] = {} - self.save_utterance_callback = save_utterance_callback - self.get_participant_callback = get_participant_callback - - def upsert_caption(self, caption_data: dict): - """ - Update or insert a caption into the in-memory store - """ - caption_id = str(caption_data["captionId"]) - device_id = caption_data["deviceId"] - key = f"{device_id}:{caption_id}" - - if key in self.captions: - self.captions[key].update(caption_data) - else: - self.captions[key] = CaptionEntry(caption_data) - - def flush_captions(self): - self.process_captions(should_flush=True) - - def process_captions(self, should_flush=False): - """ - Process captions that are ready to be upserted to the database - """ - for key, entry in list(self.captions.items()): - if entry.should_upsert_to_db(should_flush=should_flush): - device_id = entry.caption_data["deviceId"] - participant = self.get_participant_callback(device_id) - - if participant: - # Save as an utterance - self.save_utterance_callback( - { - **participant, - "timestamp_ms": int(entry.created_at.timestamp() * 1000), - "duration_ms": int((entry.modified_at - entry.created_at).total_seconds() * 1000), - "text": entry.caption_data.get("text", ""), - "source_uuid_suffix": f"{entry.caption_data['deviceId']}-{entry.caption_data['captionId']}", - "sample_rate": None, - } - ) - - # Mark as upserted and remove if it hasn't been modified recently - entry.mark_upserted_to_db() - - # If this caption hasn't been modified in a while, remove it from memory - if (datetime.utcnow() - entry.modified_at) > timedelta(seconds=60): - del self.captions[key] diff --git a/attendee/bots/bot_controller/file_uploader.py b/attendee/bots/bot_controller/file_uploader.py deleted file mode 100644 index 4fcfabe..0000000 --- a/attendee/bots/bot_controller/file_uploader.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging -import threading -from pathlib import Path - -import boto3 - - -class FileUploader: - def __init__(self, bucket, key): - """Initialize the FileUploader with an S3 bucket name. - - Args: - bucket (str): The name of the S3 bucket to upload to - """ - self.s3_client = boto3.client("s3") - self.bucket = bucket - self.key = key - self._upload_thread = None - - def upload_file(self, file_path: str, callback=None): - """Start an asynchronous upload of a file to S3. - - Args: - file_path (str): Path to the local file to upload - callback (callable, optional): Function to call when upload completes - """ - self._upload_thread = threading.Thread(target=self._upload_worker, args=(file_path, callback), daemon=True) - self._upload_thread.start() - - def _upload_worker(self, file_path: str, callback=None): - """Background thread that handles the actual file upload. - - Args: - file_path (str): Path to the local file to upload - callback (callable, optional): Function to call when upload completes - """ - try: - file_path = Path(file_path) - if not file_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - - # Upload the file using S3's multipart upload functionality - self.s3_client.upload_file(str(file_path), self.bucket, self.key) - - logging.info(f"Successfully uploaded {file_path} to s3://{self.bucket}/{self.key}") - - if callback: - callback(True) - - except Exception as e: - logging.error(f"Upload error: {e}") - if callback: - callback(False) - - def wait_for_upload(self): - """Wait for the current upload to complete.""" - if self._upload_thread and self._upload_thread.is_alive(): - self._upload_thread.join() - - def delete_file(self, file_path: str): - """Delete a file from the local filesystem.""" - file_path = Path(file_path) - if file_path.exists(): - file_path.unlink() diff --git a/attendee/bots/bot_controller/gstreamer_pipeline.py b/attendee/bots/bot_controller/gstreamer_pipeline.py deleted file mode 100644 index 3db73c8..0000000 --- a/attendee/bots/bot_controller/gstreamer_pipeline.py +++ /dev/null @@ -1,318 +0,0 @@ -import gi - -gi.require_version("Gst", "1.0") -import logging -import time - -from gi.repository import GLib, Gst - -logger = logging.getLogger(__name__) - - -class GstreamerPipeline: - AUDIO_FORMAT_PCM = "audio/x-raw,format=S16LE,channels=1,rate=32000,layout=interleaved" - AUDIO_FORMAT_FLOAT = "audio/x-raw,format=F32LE,channels=1,rate=48000,layout=interleaved" - OUTPUT_FORMAT_FLV = "flv" - OUTPUT_FORMAT_MP4 = "mp4" - OUTPUT_FORMAT_WEBM = "webm" - - SINK_TYPE_APPSINK = "appsink" - SINK_TYPE_FILE = "filesink" - - def __init__( - self, - *, - on_new_sample_callback, - video_frame_size, - audio_format, - output_format, - num_audio_sources, - sink_type, - file_location=None, - ): - self.on_new_sample_callback = on_new_sample_callback - self.video_frame_size = video_frame_size - self.audio_format = audio_format - self.output_format = output_format - self.num_audio_sources = num_audio_sources - self.sink_type = sink_type - self.file_location = file_location - - self.pipeline = None - self.appsrc = None - self.recording_active = False - - self.audio_appsrcs = [] - self.audio_recording_active = False - - self.start_time_ns = None # Will be set on first frame/audio sample - - # Initialize GStreamer - Gst.init(None) - - self.queue_drops = {} - self.last_reported_drops = {} - - def on_new_sample_from_appsink(self, sink): - """Handle new samples from the appsink""" - sample = sink.emit("pull-sample") - if sample: - buffer = sample.get_buffer() - data = buffer.extract_dup(0, buffer.get_size()) - self.on_new_sample_callback(data) - return Gst.FlowReturn.OK - return Gst.FlowReturn.ERROR - - def setup(self): - """Initialize GStreamer pipeline for combined MP4 recording with audio and video""" - self.start_time_ns = None - - # Setup muxer based on output format - if self.output_format == self.OUTPUT_FORMAT_MP4: - muxer_string = "mp4mux name=muxer" - elif self.output_format == self.OUTPUT_FORMAT_FLV: - muxer_string = "h264parse ! flvmux name=muxer streamable=true" - elif self.output_format == self.OUTPUT_FORMAT_WEBM: - muxer_string = "h264parse ! matroskamux name=muxer" - else: - raise ValueError(f"Invalid output format: {self.output_format}") - - if self.sink_type == self.SINK_TYPE_APPSINK: - sink_string = "appsink name=sink emit-signals=true sync=false drop=false " - elif self.sink_type == self.SINK_TYPE_FILE: - sink_string = f"filesink location={self.file_location} name=sink sync=false " - else: - raise ValueError(f"Invalid sink type: {self.sink_type}") - - if self.num_audio_sources == 1: - # fmt: off - audio_source_string = ( - # --- AUDIO STRING FOR 1 AUDIO SOURCE --- - "appsrc name=audio_source_1 do-timestamp=false stream-type=0 format=time ! " - "queue name=q5 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - "audioconvert ! " - "audiorate ! " - "queue name=q6 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - "voaacenc bitrate=128000 ! " - "queue name=q7 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - ) - # fmt: on - elif self.num_audio_sources == 3: - audio_source_string = ( - # --- AUDIO BRANCH 1 --- - "appsrc name=audio_source_1 do-timestamp=false stream-type=0 format=time ! " - "queue name=q5_1 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - "mixer. " - # --- AUDIO BRANCH 2 --- - "appsrc name=audio_source_2 do-timestamp=false stream-type=0 format=time ! " - "queue name=q5_2 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - "mixer. " - # --- AUDIO BRANCH 3 --- - "appsrc name=audio_source_3 do-timestamp=false stream-type=0 format=time ! " - "queue name=q5_3 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - "mixer. " - # --- AUDIO MIXER - "adder name=mixer ! " - "queue name=mixer_q1 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - "audioconvert ! " - "audiorate ! " - "queue name=mixer_q2 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - "voaacenc bitrate=128000 ! " - "queue name=mixer_q3 leaky=downstream max-size-buffers=1000000 max-size-bytes=100000000 max-size-time=0 ! " - ) - else: - raise ValueError(f"Unsupported number of audio sources: {self.num_audio_sources}") - - pipeline_str = ( - "appsrc name=video_source do-timestamp=false stream-type=0 format=time ! " - "queue name=q1 max-size-buffers=1000 max-size-bytes=100000000 max-size-time=0 ! " # q1 can contain 100mb of video before it drops - "videoconvert ! " - "videorate ! " - "queue name=q2 max-size-buffers=5000 max-size-bytes=500000000 max-size-time=0 ! " # q2 can contain 100mb of video before it drops - "x264enc tune=zerolatency speed-preset=ultrafast ! " - "queue name=q3 max-size-buffers=1000 max-size-bytes=100000000 max-size-time=0 ! " - f"{muxer_string} ! queue name=q4 ! {sink_string} " - f"{audio_source_string} " - "muxer. " - ) - - self.pipeline = Gst.parse_launch(pipeline_str) - - # Get both appsrc elements - self.appsrc = self.pipeline.get_by_name("video_source") - - # Configure video appsrc - video_caps = Gst.Caps.from_string(f"video/x-raw,format=I420,width={self.video_frame_size[0]},height={self.video_frame_size[1]},framerate=30/1") - self.appsrc.set_property("caps", video_caps) - self.appsrc.set_property("format", Gst.Format.TIME) - self.appsrc.set_property("is-live", True) - self.appsrc.set_property("do-timestamp", False) - self.appsrc.set_property("stream-type", 0) # GST_APP_STREAM_TYPE_STREAM - self.appsrc.set_property("block", True) # This helps with synchronization - - audio_caps = Gst.Caps.from_string(self.audio_format) # e.g. "audio/x-raw,rate=48000,channels=2,format=S16LE" - self.audio_appsrcs = [] - for i in range(self.num_audio_sources): - audio_appsrc = self.pipeline.get_by_name(f"audio_source_{i + 1}") - audio_appsrc.set_property("caps", audio_caps) - audio_appsrc.set_property("format", Gst.Format.TIME) - audio_appsrc.set_property("is-live", True) - audio_appsrc.set_property("do-timestamp", False) - audio_appsrc.set_property("stream-type", 0) # GST_APP_STREAM_TYPE_STREAM - audio_appsrc.set_property("block", True) - self.audio_appsrcs.append(audio_appsrc) - - # Set up bus - bus = self.pipeline.get_bus() - bus.add_signal_watch() - bus.connect("message", self.on_pipeline_message) - - # Connect to the sink element - if self.sink_type == self.SINK_TYPE_APPSINK: - sink = self.pipeline.get_by_name("sink") - sink.connect("new-sample", self.on_new_sample_from_appsink) - - # Start the pipeline - self.pipeline.set_state(Gst.State.PLAYING) - - self.recording_active = True - self.audio_recording_active = True - - # Initialize queue monitoring - self.queue_drops = {} - self.last_reported_drops = {} - - # Find all queue elements and connect drop signals - iterator = self.pipeline.iterate_elements() - while True: - result, element = iterator.next() - if result == Gst.IteratorResult.DONE: - break - if result != Gst.IteratorResult.OK: - continue - - if isinstance(element, Gst.Element) and element.get_factory().get_name() == "queue": - queue_name = element.get_name() - self.queue_drops[queue_name] = 0 - self.last_reported_drops[queue_name] = 0 - element.connect("overrun", self.on_queue_overrun, queue_name) - - # Start statistics monitoring - GLib.timeout_add_seconds(15, self.monitor_pipeline_stats) - - def on_pipeline_message(self, bus, message): - """Handle pipeline messages""" - t = message.type - if t == Gst.MessageType.ERROR: - err, debug = message.parse_error() - - src = message.src - src_name = src.name if src else "unknown" - logger.info(f"GStreamer Error: {err}, Debug: {debug}, src_name: {src_name}") - elif t == Gst.MessageType.EOS: - logger.info("GStreamer pipeline reached end of stream") - - def monitor_pipeline_stats(self): - """Periodically print pipeline statistics""" - if not self.recording_active: - return False - - try: - logger.info("\nDropped Buffers Since Last Check:") - for queue_name in self.queue_drops: - drops = self.queue_drops[queue_name] - self.last_reported_drops[queue_name] - if drops > 0: - logger.info(f" {queue_name}: {drops} buffers dropped") - self.last_reported_drops[queue_name] = self.queue_drops[queue_name] - - except Exception as e: - logger.info(f"Error getting pipeline stats: {e}") - - return True # Continue timer - - def on_queue_overrun(self, queue, queue_name): - """Callback for when a queue drops buffers""" - self.queue_drops[queue_name] += 1 - return True - - def on_mixed_audio_raw_data_received_callback(self, data, timestamp=None, audio_appsrc_idx=0): - audio_appsrc = self.audio_appsrcs[audio_appsrc_idx] - - if not self.audio_recording_active or not audio_appsrc or not self.recording_active or not self.appsrc: - return - - try: - current_time_ns = timestamp if timestamp else time.time_ns() - buffer_bytes = data - buffer = Gst.Buffer.new_wrapped(buffer_bytes) - - # Initialize start time if not set - if self.start_time_ns is None: - self.start_time_ns = current_time_ns - - # Calculate timestamp relative to same start time as video - buffer.pts = current_time_ns - self.start_time_ns - - ret = audio_appsrc.emit("push-buffer", buffer) - if ret != Gst.FlowReturn.OK: - logger.info(f"Warning: Failed to push audio buffer to pipeline: {ret}") - except Exception as e: - logger.info(f"Error processing audio data: {e}") - - def wants_any_video_frames(self): - if not self.audio_recording_active or not self.audio_appsrcs[0] or not self.recording_active or not self.appsrc: - return False - - return True - - def on_new_video_frame(self, frame, current_time_ns): - try: - # Initialize start time if not set - if self.start_time_ns is None: - self.start_time_ns = current_time_ns - - # Calculate buffer timestamp relative to start time - buffer_pts = current_time_ns - self.start_time_ns - - # Create buffer with timestamp - buffer = Gst.Buffer.new_wrapped(frame) - buffer.pts = buffer_pts - - # Default to 33ms (30fps) - buffer.duration = 33 * 1000 * 1000 # 33ms in nanoseconds - - # Push buffer to pipeline - ret = self.appsrc.emit("push-buffer", buffer) - if ret != Gst.FlowReturn.OK: - logger.info(f"Warning: Failed to push buffer to pipeline: {ret}") - - except Exception as e: - logger.info(f"Error processing video frame: {e}") - - def cleanup(self): - logger.info("Shutting down GStreamer pipeline...") - - self.recording_active = False - self.audio_recording_active = False - - if not self.pipeline: - return - bus = self.pipeline.get_bus() - bus.remove_signal_watch() - - if self.appsrc: - self.appsrc.emit("end-of-stream") - for audio_appsrc in self.audio_appsrcs: - audio_appsrc.emit("end-of-stream") - - msg = bus.timed_pop_filtered( - 5 * 60 * Gst.SECOND, # 5 minute timeout - Gst.MessageType.EOS | Gst.MessageType.ERROR, - ) - - if msg and msg.type == Gst.MessageType.ERROR: - err, debug = msg.parse_error() - logger.info(f"Error during pipeline shutdown: {err}, {debug}") - - self.pipeline.set_state(Gst.State.NULL) - logger.info("GStreamer pipeline shut down") diff --git a/attendee/bots/bot_controller/individual_audio_input_manager.py b/attendee/bots/bot_controller/individual_audio_input_manager.py deleted file mode 100644 index 65a863a..0000000 --- a/attendee/bots/bot_controller/individual_audio_input_manager.py +++ /dev/null @@ -1,110 +0,0 @@ -import logging -import queue -from datetime import datetime, timedelta - -import numpy as np -import webrtcvad - -logger = logging.getLogger(__name__) - - -def calculate_normalized_rms(audio_bytes): - samples = np.frombuffer(audio_bytes, dtype=np.int16) - rms = np.sqrt(np.mean(np.square(samples))) - # Normalize by max possible value for 16-bit audio (32768) - return rms / 32768 - - -class IndividualAudioInputManager: - def __init__(self, *, save_utterance_callback, get_participant_callback): - self.queue = queue.Queue() - - self.save_utterance_callback = save_utterance_callback - self.get_participant_callback = get_participant_callback - - self.utterances = {} - self.sample_rate = 32000 - - self.first_nonsilent_audio_time = {} - self.last_nonsilent_audio_time = {} - - self.UTTERANCE_SIZE_LIMIT = 19200000 # 19.2 MB / 2 bytes per sample / 32,000 samples per second = 300 seconds of continuous audio - self.SILENCE_DURATION_LIMIT = 3 # seconds - self.vad = webrtcvad.Vad() - - def add_chunk(self, speaker_id, chunk_time, chunk_bytes): - self.queue.put((speaker_id, chunk_time, chunk_bytes)) - - def process_chunks(self): - while not self.queue.empty(): - speaker_id, chunk_time, chunk_bytes = self.queue.get() - self.process_chunk(speaker_id, chunk_time, chunk_bytes) - - for speaker_id in list(self.first_nonsilent_audio_time.keys()): - self.process_chunk(speaker_id, datetime.utcnow(), None) - - # When the meeting ends, we need to flush all utterances. Do this by pretending that we received a chunk of silence at the end of the meeting. - def flush_utterances(self): - for speaker_id in list(self.first_nonsilent_audio_time.keys()): - self.process_chunk( - speaker_id, - datetime.utcnow() + timedelta(seconds=self.SILENCE_DURATION_LIMIT + 1), - None, - ) - - def silence_detected(self, chunk_bytes): - if calculate_normalized_rms(chunk_bytes) < 0.01: - return True - return not self.vad.is_speech(chunk_bytes, self.sample_rate) - - def process_chunk(self, speaker_id, chunk_time, chunk_bytes): - audio_is_silent = self.silence_detected(chunk_bytes) if chunk_bytes else True - - # Initialize buffer and timing for new speaker - if speaker_id not in self.utterances or len(self.utterances[speaker_id]) == 0: - if audio_is_silent: - return - self.utterances[speaker_id] = bytearray() - self.first_nonsilent_audio_time[speaker_id] = chunk_time - self.last_nonsilent_audio_time[speaker_id] = chunk_time - - # Add new audio data to buffer - if chunk_bytes: - self.utterances[speaker_id].extend(chunk_bytes) - - should_flush = False - reason = None - - # Check buffer size - if len(self.utterances[speaker_id]) >= self.UTTERANCE_SIZE_LIMIT: - should_flush = True - reason = "buffer_full" - - # Check for silence - if audio_is_silent: - silence_duration = (chunk_time - self.last_nonsilent_audio_time[speaker_id]).total_seconds() - if silence_duration >= self.SILENCE_DURATION_LIMIT: - should_flush = True - reason = "silence_limit" - else: - self.last_nonsilent_audio_time[speaker_id] = chunk_time - - logger.debug(f"Speaker {speaker_id} is speaking") - - # Flush buffer if needed - if should_flush and len(self.utterances[speaker_id]) > 0: - participant = self.get_participant_callback(speaker_id) - if participant: - self.save_utterance_callback( - { - **participant, - "audio_data": bytes(self.utterances[speaker_id]), - "timestamp_ms": int(self.first_nonsilent_audio_time[speaker_id].timestamp() * 1000), - "flush_reason": reason, - "sample_rate": self.sample_rate, - } - ) - # Clear the buffer - self.utterances[speaker_id] = bytearray() - del self.first_nonsilent_audio_time[speaker_id] - del self.last_nonsilent_audio_time[speaker_id] diff --git a/attendee/bots/bot_controller/pipeline_configuration.py b/attendee/bots/bot_controller/pipeline_configuration.py deleted file mode 100644 index 925e30a..0000000 --- a/attendee/bots/bot_controller/pipeline_configuration.py +++ /dev/null @@ -1,64 +0,0 @@ -from dataclasses import dataclass -from typing import FrozenSet - - -# Specifies how the bot will use the media from the meeting platform -# For now there are only a few valid configurations, to avoid having to make the bot -# work for every possible configuration -@dataclass(frozen=True) -class PipelineConfiguration: - record_video: bool - record_audio: bool - transcribe_audio: bool - rtmp_stream_audio: bool - rtmp_stream_video: bool - - def __post_init__(self): - # Convert to FrozenSet of FrozenSet[str] - valid_configurations: FrozenSet[FrozenSet[str]] = frozenset( - { - # Basic meeting bot configuration - frozenset({"record_audio", "record_video", "transcribe_audio"}), - # RTMP streaming configuration - frozenset({"rtmp_stream_audio", "rtmp_stream_video", "transcribe_audio"}), - # Voice agent configuration - frozenset({"transcribe_audio"}), - } - ) - - # Get the set of all fields that are True - active_fields = frozenset(field for field in self.__dataclass_fields__.keys() if getattr(self, field)) - - # Check if this combination exists in our valid configurations - if active_fields not in valid_configurations: - raise ValueError(f"Invalid configuration: {active_fields}\nMust be one of: {valid_configurations}") - - @classmethod - def recorder_bot(cls) -> "PipelineConfiguration": - return cls( - record_video=True, - record_audio=True, - transcribe_audio=True, - rtmp_stream_audio=False, - rtmp_stream_video=False, - ) - - @classmethod - def rtmp_streaming_bot(cls) -> "PipelineConfiguration": - return cls( - record_video=False, - record_audio=False, - transcribe_audio=True, - rtmp_stream_audio=True, - rtmp_stream_video=True, - ) - - @classmethod - def voice_agent(cls) -> "PipelineConfiguration": - return cls( - record_video=False, - record_audio=False, - transcribe_audio=True, - rtmp_stream_audio=False, - rtmp_stream_video=False, - ) diff --git a/attendee/bots/bot_controller/rtmp_client.py b/attendee/bots/bot_controller/rtmp_client.py deleted file mode 100644 index b4dba73..0000000 --- a/attendee/bots/bot_controller/rtmp_client.py +++ /dev/null @@ -1,98 +0,0 @@ -import logging -import subprocess - -logger = logging.getLogger(__name__) - - -class RTMPClient: - def __init__(self, rtmp_url): - """ - Initialize the RTMP client for streaming FLV data to an RTMP endpoint. - - Args: - rtmp_url (str): The RTMP endpoint URL - """ - self.rtmp_url = rtmp_url - self.ffmpeg_process = None - self.is_running = False - - def start(self): - """Start the RTMP streaming process""" - if self.is_running: - return False - - # Configure FFmpeg command to copy the FLV stream directly - ffmpeg_cmd = [ - "ffmpeg", - "-y", # Overwrite output if needed - "-f", - "flv", # Input format is FLV - "-i", - "pipe:0", # Read from stdin - "-c", - "copy", # Copy both audio and video without re-encoding - "-f", - "flv", # Output format - self.rtmp_url, # RTMP destination - ] - - # Start FFmpeg process - try: - self.ffmpeg_process = subprocess.Popen( - ffmpeg_cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=10**8, - ) - self.is_running = True - logger.info(f"FFmpeg RTMP client started with PID {self.ffmpeg_process.pid}") - return True - except Exception as e: - logger.info(f"Failed to start FFmpeg process: {e}") - return False - - def write_data(self, flv_data): - """ - Write FLV data to the RTMP stream. - - Args: - flv_data (bytes): FLV formatted data containing audio and video - - Returns: - bool: True if data was written, False if failed - """ - if not self.is_running or not self.ffmpeg_process: - return False - - try: - self.ffmpeg_process.stdin.write(flv_data) - self.ffmpeg_process.stdin.flush() - return True - except BrokenPipeError: - logger.info("FFmpeg pipe broken - stream may have failed") - self.is_running = False - return False - except Exception as e: - logger.info(f"Error writing data to FFmpeg: {e}") - self.is_running = False - return False - - def stop(self): - """Stop the RTMP streaming process""" - self.is_running = False - - if self.ffmpeg_process: - try: - self.ffmpeg_process.stdin.close() - self.ffmpeg_process.terminate() - self.ffmpeg_process.wait(timeout=5.0) - except Exception as e: - logger.info(f"Error stopping FFmpeg process: {e}") - # Force kill if graceful shutdown fails - try: - self.ffmpeg_process.kill() - except Exception: - pass - - self.ffmpeg_process = None diff --git a/attendee/bots/bot_controller/streaming_uploader.py b/attendee/bots/bot_controller/streaming_uploader.py deleted file mode 100644 index 96caa6b..0000000 --- a/attendee/bots/bot_controller/streaming_uploader.py +++ /dev/null @@ -1,97 +0,0 @@ -import logging -import threading -from io import BytesIO -from queue import Queue - -import boto3 - -logger = logging.getLogger(__name__) - - -class StreamingUploader: - def __init__(self, bucket, key, chunk_size=5242880): # 5MB chunks - self.s3_client = boto3.client("s3") - self.bucket = bucket - self.key = key - self.chunk_size = chunk_size - self.buffer = BytesIO() - self.upload_id = None - self.parts = [] - self.part_number = 1 - - # Add upload queue and worker thread - self.upload_queue = Queue() - self.upload_thread = threading.Thread(target=self._upload_worker, daemon=True) - self.upload_thread.start() - - def _upload_worker(self): - """Background thread to handle uploads""" - while True: - try: - chunk, part_num = self.upload_queue.get() - if chunk is None: # Sentinel value to stop the thread - break - - response = self.s3_client.upload_part( - Bucket=self.bucket, - Key=self.key, - PartNumber=part_num, - UploadId=self.upload_id, - Body=chunk, - ) - - self.parts.append({"PartNumber": part_num, "ETag": response["ETag"]}) - except Exception as e: - logging.error(f"Upload error: {e}") - finally: - self.upload_queue.task_done() - - def upload_part(self, data): - self.buffer.write(data) - - # Upload complete chunks - while self.buffer.tell() >= self.chunk_size: - self.buffer.seek(0) - chunk = self.buffer.read(self.chunk_size) - - # Queue the chunk for upload instead of uploading directly - self.upload_queue.put((chunk, self.part_number)) - self.part_number += 1 - - # Keep remaining data - remaining = self.buffer.read() - self.buffer = BytesIO() - self.buffer.write(remaining) - - def complete_upload(self): - # If we never started a multipart upload (len(self.parts) == 0), do a regular upload - if len(self.parts) == 0: - self.buffer.seek(0) - data = self.buffer.getvalue() - self.s3_client.put_object(Bucket=self.bucket, Key=self.key, Body=data) - logger.info("len(self.parts) == 0, so did a regular upload") - return - - # Upload final part if any data remains - if self.buffer.tell() > 0: - self.buffer.seek(0) - final_chunk = self.buffer.getvalue() - self.upload_queue.put((final_chunk, self.part_number)) - - # Wait for all uploads to complete - self.upload_queue.join() - self.upload_queue.put((None, None)) # Stop the worker thread - self.upload_thread.join() - - # Complete multipart upload - self.s3_client.complete_multipart_upload( - Bucket=self.bucket, - Key=self.key, - UploadId=self.upload_id, - MultipartUpload={"Parts": sorted(self.parts, key=lambda x: x["PartNumber"])}, - ) - - def start_upload(self): - """Initialize the multipart upload and get the upload ID""" - response = self.s3_client.create_multipart_upload(Bucket=self.bucket, Key=self.key) - self.upload_id = response["UploadId"] diff --git a/attendee/bots/bot_controller/text_to_speech.py b/attendee/bots/bot_controller/text_to_speech.py deleted file mode 100644 index 7e7d6cf..0000000 --- a/attendee/bots/bot_controller/text_to_speech.py +++ /dev/null @@ -1,69 +0,0 @@ -import json - -from google.cloud import texttospeech - -from bots.models import Credentials - - -def generate_audio_from_text(bot, text, settings, sample_rate): - """ - Generate audio from text using text-to-speech settings. - - Args: - bot (Bot): The bot instance - text (str): The text to convert to speech - settings (dict): Text-to-speech configuration settings containing: - google: - voice_language_code (str): Language code (e.g., "en-US") - voice_name (str): Name of the voice to use - sample_rate (int): The sample rate in Hz - Returns: - tuple: (bytes, int) containing: - - Audio data in LINEAR16 format - - Duration in milliseconds - """ - - # Additional providers will be added, for now we only support Google TTS - google_tts_credentials = bot.project.credentials.filter(credential_type=Credentials.CredentialTypes.GOOGLE_TTS).first() - - if not google_tts_credentials: - raise ValueError("Could not find Google Text-to-Speech credentials.") - - try: - # Create client with credentials - client = texttospeech.TextToSpeechClient.from_service_account_info(json.loads(google_tts_credentials.get_credentials().get("service_account_json", {}))) - except (ValueError, json.JSONDecodeError) as e: - raise ValueError("Invalid Google Text-to-Speech credentials format: " + str(e)) from e - except Exception as e: - raise ValueError("Failed to initialize Google Text-to-Speech client: " + str(e)) from e - - # Set up text input - synthesis_input = texttospeech.SynthesisInput(text=text) - - # Get Google settings - google_settings = settings.get("google", {}) - language_code = google_settings.get("voice_language_code") - voice_name = google_settings.get("voice_name") - - # Build voice parameters - voice = texttospeech.VoiceSelectionParams(language_code=language_code, name=voice_name) - - # Configure audio output as PCM (LINEAR16) - audio_config = texttospeech.AudioConfig( - audio_encoding=texttospeech.AudioEncoding.LINEAR16, - sample_rate_hertz=sample_rate, # Using 8kHz sample rate - ) - - # Perform the text-to-speech request - response = client.synthesize_speech(input=synthesis_input, voice=voice, audio_config=audio_config) - - # Skip the WAV header (first 44 bytes) to get raw PCM data - audio_content = response.audio_content[44:] - - # Calculate duration in milliseconds - # For LINEAR16: 2 bytes per sample, sample_rate samples per second - bytes_per_sample = 2 - duration_ms = int((len(audio_content) / bytes_per_sample / sample_rate) * 1000) - - # Return both audio content and duration - return audio_content, duration_ms diff --git a/attendee/bots/bot_pod_creator/__init__.py b/attendee/bots/bot_pod_creator/__init__.py deleted file mode 100644 index 8122258..0000000 --- a/attendee/bots/bot_pod_creator/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .bot_pod_creator import BotPodCreator - -__all__ = ["BotPodCreator"] diff --git a/attendee/bots/bot_pod_creator/bot_pod_creator.py b/attendee/bots/bot_pod_creator/bot_pod_creator.py deleted file mode 100644 index c0edceb..0000000 --- a/attendee/bots/bot_pod_creator/bot_pod_creator.py +++ /dev/null @@ -1,145 +0,0 @@ -import os -import uuid -from typing import Dict, Optional - -from kubernetes import client, config - -# fmt: off - -class BotPodCreator: - def __init__(self, namespace: str = "attendee"): - try: - config.load_incluster_config() - except config.ConfigException: - config.load_kube_config() - - self.v1 = client.CoreV1Api() - self.namespace = namespace - - # Get configuration from environment variables - self.app_name = os.getenv('CUBER_APP_NAME', 'attendee') - self.app_version = os.getenv('CUBER_RELEASE_VERSION') - - if not self.app_version: - raise ValueError("CUBER_RELEASE_VERSION environment variable is required") - - # Parse instance from version (matches your pattern of {hash}-{timestamp}) - self.app_instance = f"{self.app_name}-{self.app_version.split('-')[-1]}" - self.image = f"nduncan{self.app_name}/{self.app_name}:{self.app_version}" - - def create_bot_pod( - self, - bot_id: int, - bot_name: Optional[str] = None, - ) -> Dict: - """ - Create a bot pod with configuration from environment. - - Args: - bot_id: Integer ID of the bot to run - bot_name: Optional name for the bot (will generate if not provided) - """ - if bot_name is None: - bot_name = f"bot-{bot_id}-{uuid.uuid4().hex[:8]}" - - # Set the command based on bot_id - # python manage.py run_bot --botid - command = ["python", "manage.py", "run_bot", "--botid", str(bot_id)] - - # Metadata labels matching the deployment - labels = { - "app.kubernetes.io/name": self.app_name, - "app.kubernetes.io/instance": self.app_instance, - "app.kubernetes.io/version": self.app_version, - "app.kubernetes.io/managed-by": "cuber", - "app": "bot-proc" - } - - pod = client.V1Pod( - metadata=client.V1ObjectMeta( - name=bot_name, - namespace=self.namespace, - labels=labels - ), - spec=client.V1PodSpec( - containers=[ - client.V1Container( - name="bot-proc", - image=self.image, - image_pull_policy="Always", - command=command, - resources=client.V1ResourceRequirements( - requests={ - "cpu": "2", - "memory": "4Gi", - "ephemeral-storage": "10Gi" - }, - limits={ - "memory": "4Gi", - "ephemeral-storage": "10Gi" - } - ), - env_from=[ - # environment variables for the bot - client.V1EnvFromSource( - config_map_ref=client.V1ConfigMapEnvSource( - name="env" - ) - ), - client.V1EnvFromSource( - secret_ref=client.V1SecretEnvSource( - name="app-secrets" - ) - ) - ], - env=[] - ) - ], - restart_policy="Never", - image_pull_secrets=[ - client.V1LocalObjectReference( - name="regcred" - ) - ], - termination_grace_period_seconds=60 - ) - ) - - try: - api_response = self.v1.create_namespaced_pod( - namespace=self.namespace, - body=pod - ) - - return { - "name": api_response.metadata.name, - "status": api_response.status.phase, - "created": True, - "image": self.image, - "app_instance": self.app_instance, - "app_version": self.app_version - } - - except client.ApiException as e: - return { - "name": bot_name, - "status": "Error", - "created": False, - "error": str(e) - } - - def delete_bot_pod(self, pod_name: str) -> Dict: - try: - self.v1.delete_namespaced_pod( - name=pod_name, - namespace=self.namespace, - grace_period_seconds=60 - ) - return {"deleted": True} - except client.ApiException as e: - return { - "deleted": False, - "error": str(e) - } - -# fmt: on diff --git a/attendee/bots/bots_api_urls.py b/attendee/bots/bots_api_urls.py deleted file mode 100644 index cb16aca..0000000 --- a/attendee/bots/bots_api_urls.py +++ /dev/null @@ -1,45 +0,0 @@ -from django.urls import path - -from . import bots_api_views - -urlpatterns = [ - path("bots", bots_api_views.BotCreateView.as_view(), name="bot-create"), - path( - "bots/", - bots_api_views.BotDetailView.as_view(), - name="bot-detail", - ), - path( - "bots//leave", - bots_api_views.BotLeaveView.as_view(), - name="bot-leave", - ), - path( - "bots//transcript", - bots_api_views.TranscriptView.as_view(), - name="bot-transcript", - ), - path( - "bots//recording", - bots_api_views.RecordingView.as_view(), - name="bot-recording", - ), - path( - "bots//output_audio", - bots_api_views.OutputAudioView.as_view(), - name="bot-output-audio", - ), - path( - "bots//output_image", - bots_api_views.OutputImageView.as_view(), - name="bot-output-image", - ), - path( - "bots//speech", - bots_api_views.SpeechView.as_view(), - name="bot-speech", - ), -] - -# catch any other paths and return a 404 json response - must be last -urlpatterns += [path("", bots_api_views.NotFoundView.as_view())] diff --git a/attendee/bots/bots_api_views.py b/attendee/bots/bots_api_views.py deleted file mode 100644 index 81e3cb8..0000000 --- a/attendee/bots/bots_api_views.py +++ /dev/null @@ -1,668 +0,0 @@ -import json -import logging -import os - -import redis -from django.core.exceptions import ValidationError -from django.urls import reverse -from drf_spectacular.utils import ( - OpenApiExample, - OpenApiParameter, - OpenApiResponse, - extend_schema, -) -from rest_framework import status -from rest_framework.response import Response -from rest_framework.views import APIView - -from .authentication import ApiKeyAuthentication -from .models import ( - Bot, - BotEventManager, - BotEventSubTypes, - BotEventTypes, - BotMediaRequest, - BotMediaRequestMediaTypes, - BotStates, - Credentials, - MediaBlob, - Recording, - RecordingTypes, - TranscriptionProviders, - TranscriptionTypes, - Utterance, -) -from .serializers import ( - BotSerializer, - CreateBotSerializer, - RecordingSerializer, - SpeechSerializer, - TranscriptUtteranceSerializer, -) -from .tasks import run_bot - -TokenHeaderParameter = [ - OpenApiParameter( - name="Authorization", - type=str, - location=OpenApiParameter.HEADER, - description="API key for authentication", - required=True, - default="Token YOUR_API_KEY_HERE", - ), - OpenApiParameter( - name="Content-Type", - type=str, - location=OpenApiParameter.HEADER, - description="Should always be application/json", - required=True, - default="application/json", - ), -] - -LeavingBotExample = OpenApiExample( - "Leaving Bot", - value={ - "id": "bot_weIAju4OXNZkDTpZ", - "meeting_url": "https://zoom.us/j/123?pwd=456", - "state": "leaving", - "events": [ - {"type": "join_requested", "created_at": "2024-01-18T12:34:56Z"}, - {"type": "joined_meeting", "created_at": "2024-01-18T12:35:00Z"}, - {"type": "leave_requested", "created_at": "2024-01-18T13:34:56Z"}, - ], - "transcription_state": "in_progress", - "recording_state": "in_progress", - }, - description="Example response when requesting a bot to leave", -) - -NewlyCreatedBotExample = OpenApiExample( - "New bot", - value={ - "id": "bot_weIAju4OXNZkDTpZ", - "meeting_url": "https://zoom.us/j/123?pwd=456", - "state": "joining", - "events": [{"type": "join_requested", "created_at": "2024-01-18T12:34:56Z"}], - "transcription_state": "not_started", - "recording_state": "not_started", - }, - description="Example response when creating a new bot", -) - - -@extend_schema(exclude=True) -class NotFoundView(APIView): - def get(self, request, *args, **kwargs): - return self.handle_request(request, *args, **kwargs) - - def post(self, request, *args, **kwargs): - return self.handle_request(request, *args, **kwargs) - - def put(self, request, *args, **kwargs): - return self.handle_request(request, *args, **kwargs) - - def patch(self, request, *args, **kwargs): - return self.handle_request(request, *args, **kwargs) - - def delete(self, request, *args, **kwargs): - return self.handle_request(request, *args, **kwargs) - - def handle_request(self, request, *args, **kwargs): - return Response({"error": "Not found"}, status=status.HTTP_404_NOT_FOUND) - - -def send_sync_command(bot, command="sync"): - redis_url = os.getenv("REDIS_URL") + ("?ssl_cert_reqs=none" if os.getenv("DISABLE_REDIS_SSL") else "") - redis_client = redis.from_url(redis_url) - channel = f"bot_{bot.id}" - message = {"command": command} - redis_client.publish(channel, json.dumps(message)) - - -def launch_bot(bot): - # If this instance is running in Kubernetes, use the Kubernetes pod creator - # which spins up a new pod for the bot - if os.getenv("LAUNCH_BOT_METHOD") == "kubernetes": - from .bot_pod_creator import BotPodCreator - - bot_pod_creator = BotPodCreator() - bot_pod_creator.create_bot_pod(bot_id=bot.id, bot_name=bot.k8s_pod_name()) - else: - # Default to launching bot via celery - run_bot.delay(bot.id) - - -class BotCreateView(APIView): - authentication_classes = [ApiKeyAuthentication] - - @extend_schema( - operation_id="Create Bot", - summary="Create a new bot", - description="After being created, the bot will attempt to join the specified meeting.", - request=CreateBotSerializer, - responses={ - 201: OpenApiResponse( - response=BotSerializer, - description="Bot created successfully", - examples=[NewlyCreatedBotExample], - ), - 400: OpenApiResponse(description="Invalid input"), - }, - parameters=TokenHeaderParameter, - tags=["Bots"], - ) - def post(self, request): - serializer = CreateBotSerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - # Access the bot through the api key - project = request.auth.project - - meeting_url = serializer.validated_data["meeting_url"] - - if "meet.google.com" in meeting_url: - if not meeting_url.startswith("https://meet.google.com/"): - return Response( - {"error": "Google Meet URL must start with https://meet.google.com/"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check if this is a Zoom meeting and validate credentials - if "zoom.us" in meeting_url: - zoom_credentials = project.credentials.filter(credential_type=Credentials.CredentialTypes.ZOOM_OAUTH).first() - - if not zoom_credentials: - settings_url = request.build_absolute_uri(reverse("bots:project-settings", kwargs={"object_id": project.object_id})) - return Response( - {"error": f"Zoom App credentials are required to create a Zoom bot. Please add Zoom credentials at {settings_url}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - bot_name = serializer.validated_data["bot_name"] - transcription_settings = serializer.validated_data["transcription_settings"] - rtmp_settings = serializer.validated_data["rtmp_settings"] - recording_settings = serializer.validated_data["recording_settings"] - settings = { - "transcription_settings": transcription_settings, - "rtmp_settings": rtmp_settings, - "recording_settings": recording_settings, - } - bot = Bot.objects.create( - project=project, - meeting_url=meeting_url, - name=bot_name, - settings=settings, - ) - - Recording.objects.create( - bot=bot, - recording_type=RecordingTypes.AUDIO_AND_VIDEO, - transcription_type=TranscriptionTypes.NON_REALTIME, - transcription_provider=TranscriptionProviders.DEEPGRAM, - is_default_recording=True, - ) - - # Try to transition the state from READY to JOINING - logger = logging.getLogger(__name__) - BotEventManager.create_event(bot, BotEventTypes.JOIN_REQUESTED) - launch_bot(bot) - - return Response(BotSerializer(bot).data, status=status.HTTP_201_CREATED) - - -class SpeechView(APIView): - authentication_classes = [ApiKeyAuthentication] - - @extend_schema( - operation_id="Output speech", - summary="Output speech", - description="Causes the bot to speak a message in the meeting.", - request=SpeechSerializer, - responses={ - 200: OpenApiResponse(description="Speech request created successfully"), - 400: OpenApiResponse(description="Invalid input"), - 404: OpenApiResponse(description="Bot not found"), - }, - parameters=[ - *TokenHeaderParameter, - OpenApiParameter( - name="object_id", - type=str, - location=OpenApiParameter.PATH, - description="Bot ID", - examples=[OpenApiExample("Bot ID Example", value="bot_xxxxxxxxxxx")], - ), - ], - tags=["Bots"], - ) - def post(self, request, object_id): - # Get the bot - try: - bot = Bot.objects.get(object_id=object_id, project=request.auth.project) - except Bot.DoesNotExist: - return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND) - - # Validate the request data - serializer = SpeechSerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - # Check if bot is in a state that can play media - if not BotEventManager.is_state_that_can_play_media(bot.state): - return Response( - {"error": f"Bot is in state {BotStates.state_to_api_code(bot.state)} and cannot play media"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Check for Google TTS credentials. This is currently the only supported text-to-speech provider. - google_tts_credentials = bot.project.credentials.filter(credential_type=Credentials.CredentialTypes.GOOGLE_TTS).first() - - if not google_tts_credentials: - settings_url = request.build_absolute_uri(reverse("bots:project-settings", kwargs={"object_id": bot.project.object_id})) - return Response( - {"error": f"Google Text-to-Speech credentials are required. Please add credentials at {settings_url}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Create the media request - BotMediaRequest.objects.create( - bot=bot, - text_to_speak=serializer.validated_data["text"], - text_to_speech_settings=serializer.validated_data["text_to_speech_settings"], - media_type=BotMediaRequestMediaTypes.AUDIO, - ) - - # Send sync command to notify bot of new media request - send_sync_command(bot, "sync_media_requests") - - return Response(status=status.HTTP_200_OK) - - -class OutputAudioView(APIView): - authentication_classes = [ApiKeyAuthentication] - - @extend_schema( - operation_id="Output Audio", - summary="Output audio", - description="Causes the bot to output audio in the meeting.", - request={ - "application/json": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ct[0] for ct in MediaBlob.VALID_AUDIO_CONTENT_TYPES], - }, - "data": { - "type": "string", - "format": "binary", - "description": "Base64 encoded audio data", - }, - }, - "required": ["type", "data"], - } - }, - responses={ - 200: OpenApiResponse(description="Audio request created successfully"), - 400: OpenApiResponse(description="Invalid input"), - 404: OpenApiResponse(description="Bot not found"), - }, - parameters=[ - *TokenHeaderParameter, - OpenApiParameter( - name="object_id", - type=str, - location=OpenApiParameter.PATH, - description="Bot ID", - examples=[OpenApiExample("Bot ID Example", value="bot_xxxxxxxxxxx")], - ), - ], - tags=["Bots"], - ) - def post(self, request, object_id): - try: - # Validate request data - if "type" not in request.data or "data" not in request.data: - return Response( - {"error": "Both type and data are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - content_type = request.data["type"] - if content_type not in [ct[0] for ct in MediaBlob.VALID_AUDIO_CONTENT_TYPES]: - return Response( - {"error": "Invalid audio content type"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - # Decode base64 data - import base64 - - audio_data = base64.b64decode(request.data["data"]) - except Exception: - return Response( - {"error": "Invalid base64 encoded data"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the bot - bot = Bot.objects.get(object_id=object_id, project=request.auth.project) - if not BotEventManager.is_state_that_can_play_media(bot.state): - return Response( - {"error": f"Bot is in state {BotStates.state_to_api_code(bot.state)} and cannot play media"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - # Create or get existing MediaBlob - media_blob = MediaBlob.get_or_create_from_blob(project=request.auth.project, blob=audio_data, content_type=content_type) - except Exception as e: - error_message_first_line = str(e).split("\n")[0] - logging.error(f"Error creating audio blob: {error_message_first_line} (content_type={content_type}, bot_id={object_id})") - return Response({"error": f"Error creating the audio blob. Are you sure it's a valid {content_type} file?", "raw_error": error_message_first_line}, status=status.HTTP_400_BAD_REQUEST) - - # Create BotMediaRequest - BotMediaRequest.objects.create( - bot=bot, - media_blob=media_blob, - media_type=BotMediaRequestMediaTypes.AUDIO, - ) - - # Send sync command - send_sync_command(bot, "sync_media_requests") - - return Response(status=status.HTTP_200_OK) - - except Bot.DoesNotExist: - return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND) - - -class OutputImageView(APIView): - authentication_classes = [ApiKeyAuthentication] - - @extend_schema( - operation_id="Output Image", - summary="Output image", - description="Causes the bot to output an image in the meeting.", - request={ - "application/json": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ct[0] for ct in MediaBlob.VALID_IMAGE_CONTENT_TYPES], - }, - "data": { - "type": "string", - "format": "binary", - "description": "Base64 encoded image data", - }, - }, - "required": ["type", "data"], - } - }, - responses={ - 200: OpenApiResponse(description="Image request created successfully"), - 400: OpenApiResponse(description="Invalid input"), - 404: OpenApiResponse(description="Bot not found"), - }, - parameters=[ - *TokenHeaderParameter, - OpenApiParameter( - name="object_id", - type=str, - location=OpenApiParameter.PATH, - description="Bot ID", - examples=[OpenApiExample("Bot ID Example", value="bot_xxxxxxxxxxx")], - ), - ], - tags=["Bots"], - ) - def post(self, request, object_id): - try: - # Validate request data - if "type" not in request.data or "data" not in request.data: - return Response( - {"error": "Both type and data are required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - content_type = request.data["type"] - if content_type not in [ct[0] for ct in MediaBlob.VALID_IMAGE_CONTENT_TYPES]: - return Response( - {"error": "Invalid image content type"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - # Decode base64 data - import base64 - - image_data = base64.b64decode(request.data["data"]) - except Exception: - return Response( - {"error": "Invalid base64 encoded data"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - # Get the bot - bot = Bot.objects.get(object_id=object_id, project=request.auth.project) - if not BotEventManager.is_state_that_can_play_media(bot.state): - return Response( - {"error": f"Bot is in state {BotStates.state_to_api_code(bot.state)} and cannot play media"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - try: - # Create or get existing MediaBlob - media_blob = MediaBlob.get_or_create_from_blob(project=request.auth.project, blob=image_data, content_type=content_type) - except Exception as e: - error_message_first_line = str(e).split("\n")[0] - logging.error(f"Error creating image blob: {error_message_first_line} (content_type={content_type}, bot_id={object_id})") - return Response({"error": f"Error creating the image blob. Are you sure it's a valid {content_type} file?", "debug_message": error_message_first_line}, status=status.HTTP_400_BAD_REQUEST) - - # Create BotMediaRequest - BotMediaRequest.objects.create( - bot=bot, - media_blob=media_blob, - media_type=BotMediaRequestMediaTypes.IMAGE, - ) - - # Send sync command - send_sync_command(bot, "sync_media_requests") - - return Response(status=status.HTTP_200_OK) - - except Bot.DoesNotExist: - return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND) - - -class BotLeaveView(APIView): - authentication_classes = [ApiKeyAuthentication] - - @extend_schema( - operation_id="Leave Meeting", - summary="Leave a meeting", - description="Causes the bot to leave the meeting.", - responses={ - 200: OpenApiResponse( - response=BotSerializer, - description="Successfully requested to leave meeting", - examples=[LeavingBotExample], - ), - 400: OpenApiResponse(description="Bot is not in a valid state to leave the meeting"), - 404: OpenApiResponse(description="Bot not found"), - }, - parameters=[ - *TokenHeaderParameter, - OpenApiParameter( - name="object_id", - type=str, - location=OpenApiParameter.PATH, - description="Bot ID", - examples=[OpenApiExample("Bot ID Example", value="bot_xxxxxxxxxxx")], - ), - ], - tags=["Bots"], - ) - def post(self, request, object_id): - try: - bot = Bot.objects.get(object_id=object_id, project=request.auth.project) - - BotEventManager.create_event(bot, BotEventTypes.LEAVE_REQUESTED, event_sub_type=BotEventSubTypes.LEAVE_REQUESTED_USER_REQUESTED) - - send_sync_command(bot) - return Response(BotSerializer(bot).data, status=status.HTTP_200_OK) - except ValidationError as e: - logging.error(f"Error leaving meeting: {str(e)} (bot_id={object_id})") - return Response({"error": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST) - except Bot.DoesNotExist: - return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND) - - -class RecordingView(APIView): - authentication_classes = [ApiKeyAuthentication] - - @extend_schema( - operation_id="Get Bot Recording", - summary="Get the recording for a bot", - description="Returns a short-lived S3 URL for the recording of the bot.", - responses={ - 200: OpenApiResponse( - response=RecordingSerializer, - description="Short-lived S3 URL for the recording", - ) - }, - parameters=[ - *TokenHeaderParameter, - OpenApiParameter( - name="object_id", - type=str, - location=OpenApiParameter.PATH, - description="Bot ID", - examples=[OpenApiExample("Bot ID Example", value="bot_xxxxxxxxxxx")], - ), - ], - tags=["Bots"], - ) - def get(self, request, object_id): - try: - bot = Bot.objects.get(object_id=object_id, project=request.auth.project) - - recording = Recording.objects.filter(bot=bot, is_default_recording=True).first() - if not recording: - return Response( - {"error": "No recording found for bot"}, - status=status.HTTP_404_NOT_FOUND, - ) - - recording_file = recording.file - if not recording_file: - return Response( - {"error": "No recording file found for bot"}, - status=status.HTTP_404_NOT_FOUND, - ) - - return Response(RecordingSerializer(recording).data) - - except Bot.DoesNotExist: - return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND) - - -class TranscriptView(APIView): - authentication_classes = [ApiKeyAuthentication] - - @extend_schema( - operation_id="Get Bot Transcript", - summary="Get the transcript for a bot", - description="If the meeting is still in progress, this returns the transcript so far.", - responses={ - 200: OpenApiResponse( - response=TranscriptUtteranceSerializer(many=True), - description="List of transcribed utterances", - ), - 404: OpenApiResponse(description="Bot not found"), - }, - parameters=[ - *TokenHeaderParameter, - OpenApiParameter( - name="object_id", - type=str, - location=OpenApiParameter.PATH, - description="Bot ID", - examples=[OpenApiExample("Bot ID Example", value="bot_xxxxxxxxxxx")], - ), - ], - tags=["Bots"], - ) - def get(self, request, object_id): - try: - bot = Bot.objects.get(object_id=object_id, project=request.auth.project) - - recording = Recording.objects.filter(bot=bot, is_default_recording=True).first() - if not recording: - return Response( - {"error": "No recording found for bot"}, - status=status.HTTP_404_NOT_FOUND, - ) - - # Get all utterances with transcriptions, sorted by timeline - utterances = Utterance.objects.select_related("participant").filter(recording=recording, transcription__isnull=False).order_by("timestamp_ms") - - # Format the response, skipping empty transcriptions - transcript_data = [ - { - "speaker_name": utterance.participant.full_name, - "speaker_uuid": utterance.participant.uuid, - "speaker_user_uuid": utterance.participant.user_uuid, - "timestamp_ms": utterance.timestamp_ms, - "duration_ms": utterance.duration_ms, - "transcription": utterance.transcription, - } - for utterance in utterances - if utterance.transcription.get("transcript", "") - ] - - serializer = TranscriptUtteranceSerializer(transcript_data, many=True) - return Response(serializer.data) - - except Bot.DoesNotExist: - return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND) - - -class BotDetailView(APIView): - authentication_classes = [ApiKeyAuthentication] - - @extend_schema( - operation_id="Get Bot", - summary="Get the details for a bot", - responses={ - 200: OpenApiResponse( - response=BotSerializer, - description="Bot details", - examples=[NewlyCreatedBotExample], - ), - 404: OpenApiResponse(description="Bot not found"), - }, - parameters=[ - *TokenHeaderParameter, - OpenApiParameter( - name="object_id", - type=str, - location=OpenApiParameter.PATH, - description="Bot ID", - examples=[OpenApiExample("Bot ID Example", value="bot_xxxxxxxxxxx")], - ), - ], - tags=["Bots"], - ) - def get(self, request, object_id): - try: - bot = Bot.objects.get(object_id=object_id, project=request.auth.project) - return Response(BotSerializer(bot).data) - - except Bot.DoesNotExist: - return Response({"error": "Bot not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/attendee/bots/google_meet_bot_adapter/__init__.py b/attendee/bots/google_meet_bot_adapter/__init__.py deleted file mode 100644 index a85070d..0000000 --- a/attendee/bots/google_meet_bot_adapter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .google_meet_bot_adapter import GoogleMeetBotAdapter - -__all__ = ["GoogleMeetBotAdapter"] diff --git a/attendee/bots/google_meet_bot_adapter/google_meet_bot_adapter.py b/attendee/bots/google_meet_bot_adapter/google_meet_bot_adapter.py deleted file mode 100644 index 5cb27ea..0000000 --- a/attendee/bots/google_meet_bot_adapter/google_meet_bot_adapter.py +++ /dev/null @@ -1,12 +0,0 @@ -from bots.google_meet_bot_adapter.google_meet_ui_methods import ( - GoogleMeetUIMethods, -) -from bots.web_bot_adapter import WebBotAdapter - - -class GoogleMeetBotAdapter(WebBotAdapter, GoogleMeetUIMethods): - def get_chromedriver_payload_file_name(self): - return "google_meet_bot_adapter/google_meet_chromedriver_payload.js" - - def get_websocket_port(self): - return 8700 diff --git a/attendee/bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js b/attendee/bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js deleted file mode 100644 index 91ee756..0000000 --- a/attendee/bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js +++ /dev/null @@ -1,1125 +0,0 @@ -// Video track manager -class VideoTrackManager { - constructor(ws) { - this.videoTracks = new Map(); - this.ws = ws; - this.trackToSendCache = null; - } - - deleteVideoTrack(videoTrack) { - this.videoTracks.delete(videoTrack.id); - this.trackToSendCache = null; - } - - upsertVideoTrack(videoTrack, streamId, isScreenShare) { - const existingVideoTrack = this.videoTracks.get(videoTrack.id); - - // Create new object with track info and firstSeenAt timestamp - const trackInfo = { - originalTrack: videoTrack, - isScreenShare: isScreenShare, - firstSeenAt: existingVideoTrack ? existingVideoTrack.firstSeenAt : Date.now(), - streamId: streamId - }; - - console.log('upsertVideoTrack for', videoTrack.id, '=', trackInfo); - - this.videoTracks.set(videoTrack.id, trackInfo); - this.trackToSendCache = null; - } - - getStreamIdToSendCached() { - return this.getTrackToSendCached()?.streamId; - } - - getTrackToSendCached() { - if (this.trackToSendCache) { - return this.trackToSendCache; - } - - this.trackToSendCache = this.getTrackToSend(); - return this.trackToSendCache; - } - - getTrackToSend() { - const screenShareTracks = Array.from(this.videoTracks.values()).filter(track => track.isScreenShare); - const mostRecentlyCreatedScreenShareTrack = screenShareTracks.reduce((max, track) => { - return track.firstSeenAt > max.firstSeenAt ? track : max; - }, screenShareTracks[0]); - - if (mostRecentlyCreatedScreenShareTrack) { - return mostRecentlyCreatedScreenShareTrack; - } - - const nonScreenShareTracks = Array.from(this.videoTracks.values()).filter(track => !track.isScreenShare); - const mostRecentlyCreatedNonScreenShareTrack = nonScreenShareTracks.reduce((max, track) => { - return track.firstSeenAt > max.firstSeenAt ? track : max; - }, nonScreenShareTracks[0]); - - if (mostRecentlyCreatedNonScreenShareTrack) { - return mostRecentlyCreatedNonScreenShareTrack; - } - - return null; - } -} - -// Caption manager -class CaptionManager { - constructor(ws) { - this.captions = new Map(); - this.ws = ws; - } - - singleCaptionSynced(caption) { - this.captions.set(caption.captionId, caption); - this.ws.sendClosedCaptionUpdate(caption); - } -} - -const DEVICE_OUTPUT_TYPE = { - AUDIO: 1, - VIDEO: 2 -} - -// User manager -class UserManager { - constructor(ws) { - this.allUsersMap = new Map(); - this.currentUsersMap = new Map(); - this.deviceOutputMap = new Map(); - - this.ws = ws; - } - - deviceForStreamIsActive(streamId) { - for(const deviceOutput of this.deviceOutputMap.values()) { - if (deviceOutput.streamId === streamId) { - return !deviceOutput.disabled; - } - } - - return false; - } - - getDeviceOutput(deviceId, outputType) { - return this.deviceOutputMap.get(`${deviceId}-${outputType}`); - } - - updateDeviceOutputs(deviceOutputs) { - for (const output of deviceOutputs) { - const key = `${output.deviceId}-${output.deviceOutputType}`; // Unique key combining device ID and output type - - const deviceOutput = { - deviceId: output.deviceId, - outputType: output.deviceOutputType, // 1 = audio, 2 = video - streamId: output.streamId, - disabled: output.deviceOutputStatus.disabled, - lastUpdated: Date.now() - }; - - this.deviceOutputMap.set(key, deviceOutput); - } - - // Notify websocket clients about the device output update - this.ws.sendJson({ - type: 'DeviceOutputsUpdate', - deviceOutputs: Array.from(this.deviceOutputMap.values()) - }); - } - - getUserByDeviceId(deviceId) { - return this.allUsersMap.get(deviceId); - } - - // constants for meeting status - MEETING_STATUS = { - IN_MEETING: 1, - NOT_IN_MEETING: 6 - } - - getCurrentUsersInMeeting() { - return Array.from(this.currentUsersMap.values()).filter(user => user.status === this.MEETING_STATUS.IN_MEETING); - } - - getCurrentUsersInMeetingWhoAreScreenSharing() { - return this.getCurrentUsersInMeeting().filter(user => user.parentDeviceId); - } - - singleUserSynced(user) { - // Create array with new user and existing users, then filter for unique deviceIds - // keeping the first occurrence (new user takes precedence) - const allUsers = [...this.currentUsersMap.values(), user]; - const uniqueUsers = Array.from( - new Map(allUsers.map(user => [user.deviceId, user])).values() - ); - this.newUsersListSynced(uniqueUsers); - } - - newUsersListSynced(newUsersListRaw) { - const newUsersList = newUsersListRaw.map(user => { - const userStatusMap = { - 1: 'in_meeting', - 6: 'not_in_meeting', - 7: 'removed_from_meeting' - } - - return { - ...user, - humanized_status: userStatusMap[user.status] || "unknown" - } - }) - // Get the current user IDs before updating - const previousUserIds = new Set(this.currentUsersMap.keys()); - const newUserIds = new Set(newUsersList.map(user => user.deviceId)); - const updatedUserIds = new Set([]) - - // Update all users map - for (const user of newUsersList) { - if (previousUserIds.has(user.deviceId) && JSON.stringify(this.currentUsersMap.get(user.deviceId)) !== JSON.stringify(user)) { - updatedUserIds.add(user.deviceId); - } - - this.allUsersMap.set(user.deviceId, { - deviceId: user.deviceId, - displayName: user.displayName, - fullName: user.fullName, - profile: user.profile, - status: user.status, - humanized_status: user.humanized_status, - parentDeviceId: user.parentDeviceId - }); - } - - // Calculate new, removed, and updated users - const newUsers = newUsersList.filter(user => !previousUserIds.has(user.deviceId)); - const removedUsers = Array.from(previousUserIds) - .filter(id => !newUserIds.has(id)) - .map(id => this.currentUsersMap.get(id)); - - // Clear current users map and update with new list - this.currentUsersMap.clear(); - for (const user of newUsersList) { - this.currentUsersMap.set(user.deviceId, { - deviceId: user.deviceId, - displayName: user.displayName, - fullName: user.fullName, - profilePicture: user.profilePicture, - status: user.status, - humanized_status: user.humanized_status, - parentDeviceId: user.parentDeviceId - }); - } - - const updatedUsers = Array.from(updatedUserIds).map(id => this.currentUsersMap.get(id)); - - if (newUsers.length > 0 || removedUsers.length > 0 || updatedUsers.length > 0) { - this.ws.sendJson({ - type: 'UsersUpdate', - newUsers: newUsers, - removedUsers: removedUsers, - updatedUsers: updatedUsers - }); - } - } -} - -// Websocket client -class WebSocketClient { - // Message types - static MESSAGE_TYPES = { - JSON: 1, - VIDEO: 2, // Reserved for future use - AUDIO: 3 // Reserved for future use - }; - - constructor() { - const url = `ws://localhost:${window.initialData.websocketPort}`; - console.log('WebSocketClient url', url); - this.ws = new WebSocket(url); - this.ws.binaryType = 'arraybuffer'; - - this.ws.onopen = () => { - console.log('WebSocket Connected'); - }; - - this.ws.onmessage = (event) => { - this.handleMessage(event.data); - }; - - this.ws.onerror = (error) => { - console.error('WebSocket Error:', error); - }; - - this.ws.onclose = () => { - console.log('WebSocket Disconnected'); - }; - - this.mediaSendingEnabled = false; - this.lastVideoFrameTime = performance.now(); - this.fillerFrameInterval = null; - - this.lastVideoFrame = this.getBlackFrame(); - this.blackVideoFrame = this.getBlackFrame(); - } - - getBlackFrame() { - // Create black frame data (I420 format) - const width = 1920, height = 1080; - const yPlaneSize = width * height; - const uvPlaneSize = (width * height) / 4; - - const frameData = new Uint8Array(yPlaneSize + 2 * uvPlaneSize); - // Y plane (black = 0) - frameData.fill(0, 0, yPlaneSize); - // U and V planes (black = 128) - frameData.fill(128, yPlaneSize); - - return {width, height, frameData}; - } - - currentVideoStreamIsActive() { - const result = window.userManager?.deviceForStreamIsActive(window.videoTrackManager?.getStreamIdToSendCached()); - - // This avoids a situation where we transition from no video stream to video stream and we send a filler frame from the - // last time we had a video stream and it's not the same as the current video stream. - if (!result) - this.lastVideoFrame = this.blackVideoFrame; - - return result; - } - - startFillerFrameTimer() { - if (this.fillerFrameInterval) return; // Don't start if already running - - this.fillerFrameInterval = setInterval(() => { - try { - const currentTime = performance.now(); - if (currentTime - this.lastVideoFrameTime >= 500 && this.mediaSendingEnabled) { - // Fix: Math.floor() the milliseconds before converting to BigInt - const currentTimeMicros = BigInt(Math.floor(currentTime) * 1000); - const frameToUse = this.currentVideoStreamIsActive() ? this.lastVideoFrame : this.blackVideoFrame; - this.sendVideo(currentTimeMicros, '0', frameToUse.width, frameToUse.height, frameToUse.frameData); - } - } catch (error) { - console.error('Error in black frame timer:', error); - } - }, 250); - } - - stopFillerFrameTimer() { - if (this.fillerFrameInterval) { - clearInterval(this.fillerFrameInterval); - this.fillerFrameInterval = null; - } - } - - enableMediaSending() { - this.mediaSendingEnabled = true; - this.startFillerFrameTimer(); - } - - disableMediaSending() { - this.mediaSendingEnabled = false; - this.stopFillerFrameTimer(); - } - - handleMessage(data) { - const view = new DataView(data); - const messageType = view.getInt32(0, true); // true for little-endian - - // Handle different message types - switch (messageType) { - case WebSocketClient.MESSAGE_TYPES.JSON: - const jsonData = new TextDecoder().decode(new Uint8Array(data, 4)); - console.log('Received JSON message:', JSON.parse(jsonData)); - break; - // Add future message type handlers here - default: - console.warn('Unknown message type:', messageType); - } - } - - sendJson(data) { - if (this.ws.readyState !== WebSocket.OPEN) { - console.error('WebSocket is not connected'); - return; - } - - try { - // Convert JSON to string then to Uint8Array - const jsonString = JSON.stringify(data); - const jsonBytes = new TextEncoder().encode(jsonString); - - // Create final message: type (4 bytes) + json data - const message = new Uint8Array(4 + jsonBytes.length); - - // Set message type (1 for JSON) - new DataView(message.buffer).setInt32(0, WebSocketClient.MESSAGE_TYPES.JSON, true); - - // Copy JSON data after type - message.set(jsonBytes, 4); - - // Send the binary message - this.ws.send(message.buffer); - } catch (error) { - console.error('Error sending WebSocket message:', error); - console.error('Message data:', data); - } - } - - sendClosedCaptionUpdate(item) { - if (!this.mediaSendingEnabled) - return; - - this.sendJson({ - type: 'CaptionUpdate', - caption: item - }); - } - - sendAudio(timestamp, streamId, audioData) { - if (this.ws.readyState !== WebSocket.OPEN) { - console.error('WebSocket is not connected for audio send', this.ws.readyState); - return; - } - - - if (!this.mediaSendingEnabled) { - return; - } - - try { - // Create final message: type (4 bytes) + timestamp (8 bytes) + audio data - const message = new Uint8Array(4 + 8 + 4 + audioData.buffer.byteLength); - const dataView = new DataView(message.buffer); - - // Set message type (3 for AUDIO) - dataView.setInt32(0, WebSocketClient.MESSAGE_TYPES.AUDIO, true); - - // Set timestamp as BigInt64 - dataView.setBigInt64(4, BigInt(timestamp), true); - - // Set streamId length and bytes - dataView.setInt32(12, streamId, true); - - // Copy audio data after type and timestamp - message.set(new Uint8Array(audioData.buffer), 16); - - // Send the binary message - this.ws.send(message.buffer); - } catch (error) { - console.error('Error sending WebSocket audio message:', error); - } - } - - sendVideo(timestamp, streamId, width, height, videoData) { - if (this.ws.readyState !== WebSocket.OPEN) { - console.error('WebSocket is not connected for video send', this.ws.readyState); - return; - } - - if (!this.mediaSendingEnabled) { - return; - } - - this.lastVideoFrameTime = performance.now(); - this.lastVideoFrame = {width, height, frameData: videoData}; - - try { - // Convert streamId to UTF-8 bytes - const streamIdBytes = new TextEncoder().encode(streamId); - - // Create final message: type (4 bytes) + timestamp (8 bytes) + streamId length (4 bytes) + - // streamId bytes + width (4 bytes) + height (4 bytes) + video data - const message = new Uint8Array(4 + 8 + 4 + streamIdBytes.length + 4 + 4 + videoData.buffer.byteLength); - const dataView = new DataView(message.buffer); - - // Set message type (2 for VIDEO) - dataView.setInt32(0, WebSocketClient.MESSAGE_TYPES.VIDEO, true); - - // Set timestamp as BigInt64 - dataView.setBigInt64(4, BigInt(timestamp), true); - - // Set streamId length and bytes - dataView.setInt32(12, streamIdBytes.length, true); - message.set(streamIdBytes, 16); - - // Set width and height - const streamIdOffset = 16 + streamIdBytes.length; - dataView.setInt32(streamIdOffset, width, true); - dataView.setInt32(streamIdOffset + 4, height, true); - - // Copy video data after headers - message.set(new Uint8Array(videoData.buffer), streamIdOffset + 8); - - // Send the binary message - this.ws.send(message.buffer); - } catch (error) { - console.error('Error sending WebSocket video message:', error); - } - } -} - -// Interceptors - -class FetchInterceptor { - constructor(responseCallback) { - this.originalFetch = window.fetch; - this.responseCallback = responseCallback; - window.fetch = (...args) => this.interceptFetch(...args); - } - - async interceptFetch(...args) { - try { - // Call the original fetch - const response = await this.originalFetch.apply(window, args); - - // Clone the response since it can only be consumed once - const clonedResponse = response.clone(); - - // Call the callback with the cloned response - await this.responseCallback(clonedResponse); - - // Return the original response to maintain normal flow - return response; - } catch (error) { - console.error('Error in intercepted fetch:', error); - throw error; - } - } -} -class RTCInterceptor { - constructor(callbacks) { - // Store the original RTCPeerConnection - const originalRTCPeerConnection = window.RTCPeerConnection; - - // Store callbacks - const onPeerConnectionCreate = callbacks.onPeerConnectionCreate || (() => {}); - const onDataChannelCreate = callbacks.onDataChannelCreate || (() => {}); - - // Override the RTCPeerConnection constructor - window.RTCPeerConnection = function(...args) { - // Create instance using the original constructor - const peerConnection = Reflect.construct( - originalRTCPeerConnection, - args - ); - - // Notify about the creation - onPeerConnectionCreate(peerConnection); - - // Override createDataChannel - const originalCreateDataChannel = peerConnection.createDataChannel.bind(peerConnection); - peerConnection.createDataChannel = (label, options) => { - const dataChannel = originalCreateDataChannel(label, options); - onDataChannelCreate(dataChannel, peerConnection); - return dataChannel; - }; - - return peerConnection; - }; - } -} - -// Message type definitions -const messageTypes = [ - { - name: 'CollectionEvent', - fields: [ - { name: 'body', fieldNumber: 1, type: 'message', messageType: 'CollectionEventBody' } - ] - }, - { - name: 'CollectionEventBody', - fields: [ - { name: 'userInfoListWrapperAndChatWrapperWrapper', fieldNumber: 2, type: 'message', messageType: 'UserInfoListWrapperAndChatWrapperWrapper' } - ] - }, - { - name: 'UserInfoListWrapperAndChatWrapperWrapper', - fields: [ - { name: 'deviceInfoWrapper', fieldNumber: 3, type: 'message', messageType: 'DeviceInfoWrapper' }, - { name: 'userInfoListWrapperAndChatWrapper', fieldNumber: 13, type: 'message', messageType: 'UserInfoListWrapperAndChatWrapper' } - ] - }, - { - name: 'UserInfoListWrapperAndChatWrapper', - fields: [ - { name: 'userInfoListWrapper', fieldNumber: 1, type: 'message', messageType: 'UserInfoListWrapper' }, - { name: 'chatMessageWrapper', fieldNumber: 4, type: 'message', messageType: 'ChatMessageWrapper', repeated: true } - ] - }, - { - name: 'DeviceInfoWrapper', - fields: [ - { name: 'deviceOutputInfoList', fieldNumber: 2, type: 'message', messageType: 'DeviceOutputInfoList', repeated: true } - ] - }, - { - name: 'DeviceOutputInfoList', - fields: [ - { name: 'deviceOutputType', fieldNumber: 2, type: 'varint' }, // Speculating that 1 = audio, 2 = video - { name: 'streamId', fieldNumber: 4, type: 'string' }, - { name: 'deviceId', fieldNumber: 6, type: 'string' }, - { name: 'deviceOutputStatus', fieldNumber: 10, type: 'message', messageType: 'DeviceOutputStatus' } - ] - }, - { - name: 'DeviceOutputStatus', - fields: [ - { name: 'disabled', fieldNumber: 1, type: 'varint' } - ] - }, - // Existing message types - { - name: 'UserInfoListResponse', - fields: [ - { name: 'userInfoListWrapperWrapper', fieldNumber: 2, type: 'message', messageType: 'UserInfoListWrapperWrapper' } - ] - }, - { - name: 'UserInfoListResponse', - fields: [ - { name: 'userInfoListWrapperWrapper', fieldNumber: 2, type: 'message', messageType: 'UserInfoListWrapperWrapper' } - ] - }, - { - name: 'UserInfoListWrapperWrapper', - fields: [ - { name: 'userInfoListWrapper', fieldNumber: 2, type: 'message', messageType: 'UserInfoListWrapper' } - ] - }, - { - name: 'UserEventInfo', - fields: [ - { name: 'eventNumber', fieldNumber: 1, type: 'varint' } // sequence number for the event - ] - }, - { - name: 'UserInfoListWrapper', - fields: [ - { name: 'userEventInfo', fieldNumber: 1, type: 'message', messageType: 'UserEventInfo' }, - { name: 'userInfoList', fieldNumber: 2, type: 'message', messageType: 'UserInfoList', repeated: true } - ] - }, - { - name: 'UserInfoList', - fields: [ - { name: 'deviceId', fieldNumber: 1, type: 'string' }, - { name: 'fullName', fieldNumber: 2, type: 'string' }, - { name: 'profilePicture', fieldNumber: 3, type: 'string' }, - { name: 'status', fieldNumber: 4, type: 'varint' }, // in meeting = 1 vs not in meeting = 6. kicked out = 7? - { name: 'displayName', fieldNumber: 29, type: 'string' }, - { name: 'parentDeviceId', fieldNumber: 21, type: 'string' } // if this is present, then this is a screenshare device. The parentDevice is the person that is sharing - ] - }, - { - name: 'CaptionWrapper', - fields: [ - { name: 'caption', fieldNumber: 1, type: 'message', messageType: 'Caption' } - ] - }, - { - name: 'Caption', - fields: [ - { name: 'deviceId', fieldNumber: 1, type: 'string' }, - { name: 'captionId', fieldNumber: 2, type: 'int64' }, - { name: 'version', fieldNumber: 3, type: 'int64' }, - { name: 'text', fieldNumber: 6, type: 'string' }, - { name: 'languageId', fieldNumber: 8, type: 'int64' } - ] - }, - { - name: 'ChatMessageWrapper', - fields: [ - { name: 'chatMessage', fieldNumber: 2, type: 'message', messageType: 'ChatMessage' } - ] - }, - { - name: 'ChatMessage', - fields: [ - { name: 'messageId', fieldNumber: 1, type: 'string' }, - { name: 'deviceId', fieldNumber: 2, type: 'string' }, - { name: 'timestamp', fieldNumber: 3, type: 'int64' }, - { name: 'chatMessageContent', fieldNumber: 5, type: 'message', messageType: 'ChatMessageContent' } - ] - }, - { - name: 'ChatMessageContent', - fields: [ - { name: 'text', fieldNumber: 1, type: 'string' } - ] - } -]; - -// Generic message decoder factory -function createMessageDecoder(messageType) { - return function decode(reader, length) { - if (!(reader instanceof protobuf.Reader)) { - reader = protobuf.Reader.create(reader); - } - - const end = length === undefined ? reader.len : reader.pos + length; - const message = {}; - - while (reader.pos < end) { - const tag = reader.uint32(); - const fieldNumber = tag >>> 3; - - const field = messageType.fields.find(f => f.fieldNumber === fieldNumber); - if (!field) { - reader.skipType(tag & 7); - continue; - } - - let value; - switch (field.type) { - case 'string': - value = reader.string(); - break; - case 'int64': - value = reader.int64(); - break; - case 'varint': - value = reader.uint32(); - break; - case 'message': - value = messageDecoders[field.messageType](reader, reader.uint32()); - break; - default: - reader.skipType(tag & 7); - continue; - } - - if (field.repeated) { - if (!message[field.name]) { - message[field.name] = []; - } - message[field.name].push(value); - } else { - message[field.name] = value; - } - } - - return message; - }; -} - -const ws = new WebSocketClient(); -window.ws = ws; -const userManager = new UserManager(ws); -const captionManager = new CaptionManager(ws); -const videoTrackManager = new VideoTrackManager(ws); -window.videoTrackManager = videoTrackManager; -window.userManager = userManager; - -// Create decoders for all message types -const messageDecoders = {}; -messageTypes.forEach(type => { - messageDecoders[type.name] = createMessageDecoder(type); -}); - -function base64ToUint8Array(base64) { - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes; -} - -const syncMeetingSpaceCollectionsUrl = "https://meet.google.com/$rpc/google.rtc.meetings.v1.MeetingSpaceService/SyncMeetingSpaceCollections"; -const userMap = new Map(); -new FetchInterceptor(async (response) => { - if (response.url === syncMeetingSpaceCollectionsUrl) { - const responseText = await response.text(); - const decodedData = base64ToUint8Array(responseText); - const userInfoListResponse = messageDecoders['UserInfoListResponse'](decodedData); - const userInfoList = userInfoListResponse.userInfoListWrapperWrapper?.userInfoListWrapper?.userInfoList || []; - console.log('userInfoList', userInfoList); - if (userInfoList.length > 0) { - userManager.newUsersListSynced(userInfoList); - } - } -}); - -const handleCollectionEvent = (event) => { - const decodedData = pako.inflate(new Uint8Array(event.data)); - //console.log(' handleCollectionEventdecodedData', decodedData); - // Convert decoded data to base64 - const base64Data = btoa(String.fromCharCode.apply(null, decodedData)); - //console.log('Decoded collection event data (base64):', base64Data); - - const collectionEvent = messageDecoders['CollectionEvent'](decodedData); - - const deviceOutputInfoList = collectionEvent.body.userInfoListWrapperAndChatWrapperWrapper?.deviceInfoWrapper?.deviceOutputInfoList; - if (deviceOutputInfoList) { - userManager.updateDeviceOutputs(deviceOutputInfoList); - } - - const chatMessageWrapper = collectionEvent.body.userInfoListWrapperAndChatWrapperWrapper?.userInfoListWrapperAndChatWrapper?.chatMessageWrapper; - if (chatMessageWrapper) { - console.log('chatMessageWrapper', chatMessageWrapper); - } - - //console.log('deviceOutputInfoList', JSON.stringify(collectionEvent.body.userInfoListWrapperAndChatWrapperWrapper?.deviceInfoWrapper?.deviceOutputInfoList)); - //console.log('usermap', userMap.allUsersMap); - //console.log('userInfoList And Event', collectionEvent.body.userInfoListWrapperAndChatWrapperWrapper.userInfoListWrapperAndChatWrapper.userInfoListWrapper); - const userInfoList = collectionEvent.body.userInfoListWrapperAndChatWrapperWrapper.userInfoListWrapperAndChatWrapper.userInfoListWrapper?.userInfoList || []; - console.log('userInfoList in collection event', userInfoList); - // This event is triggered when a single user joins (or leaves) the meeting - // generally this array only contains a single user - // we can't tell whether the event is a join or leave event, so we'll assume it's a join - // if it's a leave, then we'll pick it up from the periodic call to syncMeetingSpaceCollections - // so there will be a lag of roughly a minute for leave events - for (const user of userInfoList) { - userManager.singleUserSynced(user); - } -}; - -// the stream ID, not the track id in the TRACK appears in the payload of the protobuf message somewhere - -const handleCaptionEvent = (event) => { - const decodedData = new Uint8Array(event.data); - const captionWrapper = messageDecoders['CaptionWrapper'](decodedData); - const caption = captionWrapper.caption; - captionManager.singleCaptionSynced(caption); -} - -const handleMediaDirectorEvent = (event) => { - console.log('handleMediaDirectorEvent', event); - const decodedData = new Uint8Array(event.data); - //console.log(' handleCollectionEventdecodedData', decodedData); - // Convert decoded data to base64 - const base64Data = btoa(String.fromCharCode.apply(null, decodedData)); - console.log('Decoded media director event data (base64):', base64Data); -} - -const handleVideoTrack = async (event) => { - try { - // Create processor to get raw frames - const processor = new MediaStreamTrackProcessor({ track: event.track }); - const generator = new MediaStreamTrackGenerator({ kind: 'video' }); - - // Add track ended listener - event.track.addEventListener('ended', () => { - console.log('Video track ended:', event.track.id); - videoTrackManager.deleteVideoTrack(event.track); - }); - - // Get readable stream of video frames - const readable = processor.readable; - const writable = generator.writable; - - const firstStreamId = event.streams[0]?.id; - - // Check if of the users who are in the meeting and screensharers - // if any of them have an associated device output with the first stream ID of this video track - const isScreenShare = userManager - .getCurrentUsersInMeetingWhoAreScreenSharing() - .some(user => firstStreamId && userManager.getDeviceOutput(user.deviceId, DEVICE_OUTPUT_TYPE.VIDEO).streamId === firstStreamId); - if (firstStreamId) { - videoTrackManager.upsertVideoTrack(event.track, firstStreamId, isScreenShare); - } - - // Add frame rate control variables - const targetFPS = isScreenShare ? 5 : 15; - const frameInterval = 1000 / targetFPS; // milliseconds between frames - let lastFrameTime = 0; - - const transformStream = new TransformStream({ - async transform(frame, controller) { - if (!frame) { - return; - } - - try { - // Check if controller is still active - if (controller.desiredSize === null) { - frame.close(); - return; - } - - const currentTime = performance.now(); - - if (firstStreamId && firstStreamId === videoTrackManager.getStreamIdToSendCached()) { - // Check if enough time has passed since the last frame - if (currentTime - lastFrameTime >= frameInterval) { - // Copy the frame to get access to raw data - const rawFrame = new VideoFrame(frame, { - format: 'I420' - }); - - // Get the raw data from the frame - const data = new Uint8Array(rawFrame.allocationSize()); - rawFrame.copyTo(data); - - /* - const currentFormat = { - width: frame.displayWidth, - height: frame.displayHeight, - dataSize: data.length, - format: rawFrame.format, - duration: frame.duration, - colorSpace: frame.colorSpace, - codedWidth: frame.codedWidth, - codedHeight: frame.codedHeight - }; - */ - // Get current time in microseconds (multiply milliseconds by 1000) - const currentTimeMicros = BigInt(Math.floor(currentTime * 1000)); - ws.sendVideo(currentTimeMicros, firstStreamId, frame.displayWidth, frame.displayHeight, data); - - rawFrame.close(); - lastFrameTime = currentTime; - } - } - - // Always enqueue the frame for the video element - controller.enqueue(frame); - } catch (error) { - console.error('Error processing frame:', error); - frame.close(); - } - }, - flush() { - console.log('Transform stream flush called'); - } - }); - - // Create an abort controller for cleanup - const abortController = new AbortController(); - - try { - // Connect the streams - await readable - .pipeThrough(transformStream) - .pipeTo(writable, { - signal: abortController.signal - }) - .catch(error => { - if (error.name !== 'AbortError') { - console.error('Pipeline error:', error); - } - }); - } catch (error) { - console.error('Stream pipeline error:', error); - abortController.abort(); - } - - } catch (error) { - console.error('Error setting up video interceptor:', error); - } -}; - -const handleAudioTrack = async (event) => { - let lastAudioFormat = null; // Track last seen format - - try { - // Create processor to get raw frames - const processor = new MediaStreamTrackProcessor({ track: event.track }); - const generator = new MediaStreamTrackGenerator({ kind: 'audio' }); - - // Get readable stream of audio frames - const readable = processor.readable; - const writable = generator.writable; - - const firstStreamId = event.streams[0]?.id; - - // Transform stream to intercept frames - const transformStream = new TransformStream({ - async transform(frame, controller) { - if (!frame) { - return; - } - - try { - // Check if controller is still active - if (controller.desiredSize === null) { - frame.close(); - return; - } - - // Copy the audio data - const numChannels = frame.numberOfChannels; - const numSamples = frame.numberOfFrames; - const audioData = new Float32Array(numSamples); - - // Copy data from each channel - // If multi-channel, average all channels together - if (numChannels > 1) { - // Temporary buffer to hold each channel's data - const channelData = new Float32Array(numSamples); - - // Sum all channels - for (let channel = 0; channel < numChannels; channel++) { - frame.copyTo(channelData, { planeIndex: channel }); - for (let i = 0; i < numSamples; i++) { - audioData[i] += channelData[i]; - } - } - - // Average by dividing by number of channels - for (let i = 0; i < numSamples; i++) { - audioData[i] /= numChannels; - } - } else { - // If already mono, just copy the data - frame.copyTo(audioData, { planeIndex: 0 }); - } - - // console.log('frame', frame) - // console.log('audioData', audioData) - - // Check if audio format has changed - const currentFormat = { - numberOfChannels: 1, - originalNumberOfChannels: frame.numberOfChannels, - numberOfFrames: frame.numberOfFrames, - sampleRate: frame.sampleRate, - format: frame.format, - duration: frame.duration - }; - - // If format is different from last seen format, send update - if (!lastAudioFormat || - JSON.stringify(currentFormat) !== JSON.stringify(lastAudioFormat)) { - lastAudioFormat = currentFormat; - ws.sendJson({ - type: 'AudioFormatUpdate', - format: currentFormat - }); - } - - // If the audioData buffer is all zeros, then we don't want to send it - // Removing this since we implemented 3 audio sources in gstreamer pipeline - // if (audioData.every(value => value === 0)) { - // return; - // } - - // Send audio data through websocket - const currentTimeMicros = BigInt(Math.floor(performance.now() * 1000)); - ws.sendAudio(currentTimeMicros, firstStreamId, audioData); - - // Pass through the original frame - controller.enqueue(frame); - } catch (error) { - console.error('Error processing frame:', error); - frame.close(); - } - }, - flush() { - console.log('Transform stream flush called'); - } - }); - - // Create an abort controller for cleanup - const abortController = new AbortController(); - - try { - // Connect the streams - await readable - .pipeThrough(transformStream) - .pipeTo(writable, { - signal: abortController.signal - }) - .catch(error => { - if (error.name !== 'AbortError') { - console.error('Pipeline error:', error); - } - }); - } catch (error) { - console.error('Stream pipeline error:', error); - abortController.abort(); - } - - } catch (error) { - console.error('Error setting up audio interceptor:', error); - } -}; - -new RTCInterceptor({ - onPeerConnectionCreate: (peerConnection) => { - console.log('New RTCPeerConnection created:', peerConnection); - peerConnection.addEventListener('datachannel', (event) => { - console.log('datachannel', event); - if (event.channel.label === "collections") { - event.channel.addEventListener("message", (messageEvent) => { - console.log('RAWcollectionsevent', messageEvent); - handleCollectionEvent(messageEvent); - }); - } - }); - - peerConnection.addEventListener('track', (event) => { - // Log the track and its associated streams - console.log('New track:', { - trackId: event.track.id, - streams: event.streams, - streamIds: event.streams.map(stream => stream.id), - // Get any msid information - transceiver: event.transceiver, - // Get the RTP parameters which might contain stream IDs - rtpParameters: event.transceiver?.sender.getParameters() - }); - if (event.track.kind === 'audio') { - handleAudioTrack(event); - } - if (event.track.kind === 'video') { - handleVideoTrack(event); - } - }); - - // Log the signaling state changes - peerConnection.addEventListener('signalingstatechange', () => { - console.log('Signaling State:', peerConnection.signalingState); - }); - - // Log the SDP being exchanged - const originalSetLocalDescription = peerConnection.setLocalDescription; - peerConnection.setLocalDescription = function(description) { - console.log('Local SDP:', description); - return originalSetLocalDescription.apply(this, arguments); - }; - - const originalSetRemoteDescription = peerConnection.setRemoteDescription; - peerConnection.setRemoteDescription = function(description) { - console.log('Remote SDP:', description); - return originalSetRemoteDescription.apply(this, arguments); - }; - - // Log ICE candidates - peerConnection.addEventListener('icecandidate', (event) => { - if (event.candidate) { - console.log('ICE Candidate:', event.candidate); - } - }); - }, - onDataChannelCreate: (dataChannel, peerConnection) => { - console.log('New DataChannel created:', dataChannel); - console.log('On PeerConnection:', peerConnection); - console.log('Channel label:', dataChannel.label); - - //if (dataChannel.label === 'collections') { - // dataChannel.addEventListener("message", (event) => { - // console.log('collectionsevent', event) - // }); - //} - - - if (dataChannel.label === 'media-director') { - dataChannel.addEventListener("message", (mediaDirectorEvent) => { - handleMediaDirectorEvent(mediaDirectorEvent); - }); - } - - if (dataChannel.label === 'captions') { - dataChannel.addEventListener("message", (captionEvent) => { - handleCaptionEvent(captionEvent); - }); - } - } -}); - - diff --git a/attendee/bots/google_meet_bot_adapter/google_meet_ui_methods.py b/attendee/bots/google_meet_bot_adapter/google_meet_ui_methods.py deleted file mode 100644 index d0ea9b2..0000000 --- a/attendee/bots/google_meet_bot_adapter/google_meet_ui_methods.py +++ /dev/null @@ -1,211 +0,0 @@ -import logging - -from selenium.common.exceptions import NoSuchElementException, TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait - -from bots.web_bot_adapter.ui_methods import UiCouldNotClickElementException, UiCouldNotLocateElementException, UiRequestToJoinDeniedException, UiRetryableException - -logger = logging.getLogger(__name__) - - -class UiGoogleBlockingUsException(UiRetryableException): - def __init__(self, message, step=None, inner_exception=None): - super().__init__(message, step, inner_exception) - - -class GoogleMeetUIMethods: - def locate_element(self, step, condition, wait_time_seconds=60): - try: - element = WebDriverWait(self.driver, wait_time_seconds).until(condition) - return element - except Exception as e: - # Take screenshot when any exception occurs - logger.info(f"Exception raised in locate_element for {step}") - raise UiCouldNotLocateElementException(f"Exception raised in locate_element for {step}", step, e) - - def find_element_by_selector(self, selector_type, selector): - try: - return self.driver.find_element(selector_type, selector) - except NoSuchElementException: - return None - except Exception as e: - logger.info(f"Unknown error occurred in find_element_by_selector. Exception type = {type(e)}") - return None - - def click_element(self, element, step): - try: - element.click() - except Exception as e: - logger.info(f"Error occurred when clicking element {step}, will retry") - raise UiCouldNotClickElementException("Error occurred when clicking element", step, e) - - # If the meeting you're about to join is being recorded, gmeet makes you click an additional button after you're admitted to the meeting - def click_this_meeting_is_being_recorded_join_now_button(self, step): - this_meeting_is_being_recorded_join_now_button = self.find_element_by_selector(By.XPATH, '//button[.//span[text()="Join now"]]') - if this_meeting_is_being_recorded_join_now_button: - this_meeting_is_being_recorded_join_now_button.click() - - def look_for_blocked_element(self, step): - cannot_join_element = self.find_element_by_selector(By.XPATH, '//*[contains(text(), "You can\'t join this video call")]') - if cannot_join_element: - # This means google is blocking us for whatever reason, but we can retry - logger.info("Google is blocking us for whatever reason, but we can retry. Raising UiGoogleBlockingUsException") - raise UiGoogleBlockingUsException("You can't join this video call", step) - - def look_for_denied_your_request_element(self, step): - denied_your_request_element = self.find_element_by_selector( - By.XPATH, - '//*[contains(text(), "Someone in the call denied your request to join") or contains(text(), "No one responded to your request to join the call")]', - ) - if denied_your_request_element: - logger.info("Someone in the call denied our request to join. Raising UiRequestToJoinDeniedException") - raise UiRequestToJoinDeniedException("Someone in the call denied your request to join", step) - - def look_for_asking_to_be_let_in_element_after_waiting_period_expired(self, step): - asking_to_be_let_in_element = self.find_element_by_selector( - By.XPATH, - '//*[contains(text(), "Asking to be let in")]', - ) - if asking_to_be_let_in_element: - logger.info("Bot was not let in after waiting period expired. Raising UiRequestToJoinDeniedException") - raise UiRequestToJoinDeniedException("Bot was not let in after waiting period expired", step) - - def fill_out_name_input(self): - num_attempts_to_look_for_name_input = 30 - logger.info("Waiting for the name input field...") - for attempt_to_look_for_name_input_index in range(num_attempts_to_look_for_name_input): - try: - name_input = WebDriverWait(self.driver, 1).until(EC.presence_of_element_located((By.CSS_SELECTOR, 'input[type="text"][aria-label="Your name"]'))) - logger.info("name input found") - name_input.send_keys(self.display_name) - return - except TimeoutException as e: - self.look_for_blocked_element("name_input") - - last_check_timed_out = attempt_to_look_for_name_input_index == num_attempts_to_look_for_name_input - 1 - if last_check_timed_out: - logger.info("Could not find name input. Timed out. Raising UiCouldNotLocateElementException") - raise UiCouldNotLocateElementException("Could not find name input. Timed out.", "name_input", e) - - except Exception as e: - logger.info(f"Could not find name input. Unknown error {e} of type {type(e)}. Raising UiCouldNotLocateElementException") - raise UiCouldNotLocateElementException("Could not find name input. Unknown error.", "name_input", e) - - def click_captions_button(self): - num_attempts_to_look_for_captions_button = 600 - logger.info("Waiting for captions button...") - for attempt_to_look_for_captions_button_index in range(num_attempts_to_look_for_captions_button): - try: - captions_button = WebDriverWait(self.driver, 1).until(EC.presence_of_element_located((By.CSS_SELECTOR, 'button[aria-label="Turn on captions"]'))) - logger.info("Captions button found") - self.click_element(captions_button, "click_captions_button") - return - except UiCouldNotClickElementException as e: - raise e - except TimeoutException as e: - self.look_for_blocked_element("click_captions_button") - self.look_for_denied_your_request_element("click_captions_button") - self.click_this_meeting_is_being_recorded_join_now_button("click_captions_button") - - last_check_timed_out = attempt_to_look_for_captions_button_index == num_attempts_to_look_for_captions_button - 1 - if last_check_timed_out: - self.look_for_asking_to_be_let_in_element_after_waiting_period_expired("click_captions_button") - - logger.info("Could not find captions button. Timed out. Raising UiCouldNotLocateElementException") - raise UiCouldNotLocateElementException( - "Could not find captions button. Timed out.", - "click_captions_button", - e, - ) - - except Exception as e: - logger.info(f"Could not find captions button. Unknown error {e} of type {type(e)}. Raising UiCouldNotLocateElementException") - raise UiCouldNotLocateElementException( - "Could not find captions button. Unknown error.", - "click_captions_button", - e, - ) - - # returns nothing if succeeded, raises an exception if failed - def attempt_to_join_meeting(self): - logger.info("Reached code till here.......................") - self.driver.get(self.meeting_url) - - logger.info("Here as well") - self.driver.execute_cdp_cmd( - "Browser.grantPermissions", - { - "origin": self.meeting_url, - "permissions": [ - "geolocation", - "audioCapture", - "displayCapture", - "videoCapture", - ], - }, - ) - - self.fill_out_name_input() - - logger.info("Waiting for the 'Ask to join' or 'Join now' button...") - join_button = self.locate_element( - step="join_button", - condition=EC.presence_of_element_located((By.XPATH, '//button[.//span[text()="Ask to join" or text()="Join now"]]')), - wait_time_seconds=60, - ) - logger.info("Clicking the join button...") - self.click_element(join_button, "join_button") - - self.click_captions_button() - - logger.info("Waiting for the more options button...") - MORE_OPTIONS_BUTTON_SELECTOR = 'button[jsname="NakZHc"][aria-label="More options"]' - more_options_button = self.locate_element( - step="more_options_button", - condition=EC.presence_of_element_located((By.CSS_SELECTOR, MORE_OPTIONS_BUTTON_SELECTOR)), - wait_time_seconds=6, - ) - logger.info("Clicking the more options button...") - self.click_element(more_options_button, "more_options_button") - - logger.info("Waiting for the 'Change layout' list item...") - change_layout_list_item = self.locate_element( - step="change_layout_item", - condition=EC.presence_of_element_located((By.XPATH, '//li[.//span[text()="Change layout"]]')), - wait_time_seconds=6, - ) - logger.info("Clicking the 'Change layout' list item...") - self.click_element(change_layout_list_item, "change_layout_list_item") - - logger.info("Waiting for the 'Spotlight' label element") - spotlight_label = self.locate_element( - step="spotlight_label", - condition=EC.presence_of_element_located((By.XPATH, '//label[.//span[text()="Spotlight"]]')), - wait_time_seconds=6, - ) - logger.info("Clicking the 'Spotlight' label element") - self.click_element(spotlight_label, "spotlight_label") - - logger.info("Waiting for the close button") - close_button = self.locate_element( - step="close_button", - condition=EC.presence_of_element_located((By.CSS_SELECTOR, 'button[aria-label="Close"]')), - wait_time_seconds=6, - ) - logger.info("Clicking the close button") - self.click_element(close_button, "close_button") - - def click_leave_button(self): - logger.info("Waiting for the leave button") - leave_button = WebDriverWait(self.driver, 6).until( - EC.presence_of_element_located( - ( - By.CSS_SELECTOR, - 'button[jsname="CQylAd"][aria-label="Leave call"]', - ) - ) - ) - logger.info("Clicking the leave button") - leave_button.click() diff --git a/attendee/bots/management/commands/clean_up_completed_bot_pods.py b/attendee/bots/management/commands/clean_up_completed_bot_pods.py deleted file mode 100644 index 68a56ff..0000000 --- a/attendee/bots/management/commands/clean_up_completed_bot_pods.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging -from typing import List - -from django.core.management.base import BaseCommand -from kubernetes import client, config - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - help = "Cleans up completed bot pods" - - def __init__(self): - super().__init__() - # Initialize kubernetes client - try: - config.load_incluster_config() - except config.ConfigException: - config.load_kube_config() - self.v1 = client.CoreV1Api() - self.namespace = "attendee" - logger.info("initialized kubernetes client") - - def handle(self, *args, **options): - logger.info("Cleaning up completed bot pods...") - - try: - # Get all pods in the namespace - pods = self.v1.list_namespaced_pod(namespace=self.namespace) - - # Filter for completed bot pods - completed_pods: List[str] = [pod.metadata.name for pod in pods.items if (pod.metadata.name.startswith("bot-pod-") and pod.status.phase == "Succeeded")] - - # Delete each completed pod - for pod_name in completed_pods: - try: - self.v1.delete_namespaced_pod(name=pod_name, namespace=self.namespace, grace_period_seconds=60) - logger.info(f"Deleted pod: {pod_name}") - except client.ApiException as e: - logger.info(f"Error deleting pod {pod_name}: {str(e)}") - - logger.info(f"Bot pod cleanup completed. Deleted {len(completed_pods)} pods") - - except client.ApiException as e: - logger.info(f"Failed to cleanup bot pods: {str(e)}") diff --git a/attendee/bots/management/commands/launch_bot.py b/attendee/bots/management/commands/launch_bot.py deleted file mode 100644 index 94efa62..0000000 --- a/attendee/bots/management/commands/launch_bot.py +++ /dev/null @@ -1,60 +0,0 @@ -import json -import logging - -from django.core.management.base import BaseCommand - -from bots.models import ( - Bot, - BotEventManager, - BotEventTypes, - Project, - Recording, - RecordingTypes, - TranscriptionProviders, - TranscriptionTypes, -) -from bots.tasks import run_bot # Import your task - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - help = "Runs the celery task directly for debugging" - - def add_arguments(self, parser): - # Add any arguments you need - parser.add_argument("--joinurl", type=str, help="Join URL") - parser.add_argument("--rtmpsettings", type=str, help="RTMP Settings") - parser.add_argument("--botname", type=str, help="Bot Name") - parser.add_argument("--projectid", type=str, help="Project ID") - - def handle(self, *args, **options): - logger.info("Running task...") - - project = Project.objects.get(object_id=options["projectid"]) - - meeting_url = options["joinurl"] - rtmp_settings = json.loads(options.get("rtmpsettings")) if options.get("rtmpsettings") else None - bot_name = options["botname"] - bot = Bot.objects.create( - project=project, - meeting_url=meeting_url, - name=bot_name, - settings={"rtmp_settings": rtmp_settings}, - ) - - Recording.objects.create( - bot=bot, - recording_type=RecordingTypes.AUDIO_AND_VIDEO, - transcription_type=TranscriptionTypes.NON_REALTIME, - transcription_provider=TranscriptionProviders.DEEPGRAM, - is_default_recording=True, - ) - - # Try to transition the state from READY to JOINING - BotEventManager.create_event(bot, BotEventTypes.JOIN_REQUESTED) - - # Call your task directly - result = run_bot.run(bot.id) - - logger.info(f"Task completed with result: {result}") diff --git a/attendee/bots/management/commands/run_bot.py b/attendee/bots/management/commands/run_bot.py deleted file mode 100644 index 589c655..0000000 --- a/attendee/bots/management/commands/run_bot.py +++ /dev/null @@ -1,23 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand - -from bots.tasks import run_bot # Import your task - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - help = "Runs the celery task synchronously on a given bot that is already created" - - def add_arguments(self, parser): - # Add any arguments you need - parser.add_argument("--botid", type=int, help="Bot ID") - - def handle(self, *args, **options): - logger.info("Running run bot task...") - - # Call your task directly - result = run_bot.run(options["botid"]) - - logger.info(f"Run bot task completed with result: {result}") diff --git a/attendee/bots/management/commands/setup_test_db.py b/attendee/bots/management/commands/setup_test_db.py deleted file mode 100644 index 71bf6de..0000000 --- a/attendee/bots/management/commands/setup_test_db.py +++ /dev/null @@ -1,99 +0,0 @@ -import os - -import django - -# Set the default Django settings module -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "attendee.settings.test") - -# Initialize Django (this is required before accessing settings) -django.setup() - -import psycopg2 -from django.conf import settings -from django.core.management.base import BaseCommand -from psycopg2 import sql -from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT - - -class Command(BaseCommand): - help = "Sets up test database and user if they do not already exist" - - def handle(self, *args, **options): - """Setup test database and user if they do not already exist""" - - # Default connection parameters - db_host = settings.DATABASES["default"]["HOST"] - db_port = settings.DATABASES["default"]["PORT"] - db_name = settings.DATABASES["default"]["NAME"] - db_user = settings.DATABASES["default"]["USER"] - db_password = settings.DATABASES["default"]["PASSWORD"] - postgres_user = os.environ.get("POSTGRES_USER", "attendee_development_user") - postgres_password = os.environ.get("POSTGRES_PASSWORD", "attendee_development_user") - - # Connect to the default postgres database - try: - conn = psycopg2.connect(host=db_host, port=db_port, database=os.environ.get("POSTGRES_DB", "attendee_development"), user=postgres_user, password=postgres_password) - - # Set isolation level to autocommit for database creation - conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) - - # Create a cursor - cursor = conn.cursor() - - print("Checking if database and user already exist...") - - # Check if user exists - cursor.execute("SELECT 1 FROM pg_roles WHERE rolname = %s", (db_user,)) - user_exists = cursor.fetchone() is not None - - # Create user if it doesn't exist - if not user_exists: - print(f"Creating user {db_user}...") - cursor.execute(sql.SQL("CREATE USER {} WITH PASSWORD %s").format(sql.Identifier(db_user)), (db_password,)) - print(f"Granting createdb permission to {db_user}...") - cursor.execute(sql.SQL("ALTER USER {} WITH CREATEDB").format(sql.Identifier(db_user))) - else: - print(f"User {db_user} already exists. Skipping user creation.") - - # Check if database exists - cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) - db_exists = cursor.fetchone() is not None - - # Create database if it doesn't exist - if not db_exists: - print(f"Creating database {db_name}...") - cursor.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name))) - - print(f"Setting database owner to {db_user}...") - cursor.execute(sql.SQL("ALTER DATABASE {} OWNER TO {}").format(sql.Identifier(db_name), sql.Identifier(db_user))) - else: - print(f"Database {db_name} already exists. Skipping database creation.") - - # Grant privileges on database - print(f"Granting privileges to {db_user}...") - cursor.execute(sql.SQL("GRANT ALL PRIVILEGES ON DATABASE {} TO {}").format(sql.Identifier(db_name), sql.Identifier(db_user))) - - # Connect to the specific database to grant privileges on schema objects - cursor.close() - conn.close() - - # Connect to the test database to set up schema privileges - conn = psycopg2.connect(host=db_host, port=db_port, database=db_name, user=postgres_user, password=postgres_password) - conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) - cursor = conn.cursor() - - # Grant schema privileges - cursor.execute(sql.SQL("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {}").format(sql.Identifier(db_user))) - cursor.execute(sql.SQL("GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO {}").format(sql.Identifier(db_user))) - cursor.execute(sql.SQL("GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO {}").format(sql.Identifier(db_user))) - - print("Database setup completed successfully!") - - except Exception as e: - print(f"Error: {str(e)}") - raise - finally: - if cursor: - cursor.close() - if conn: - conn.close() diff --git a/attendee/bots/management/commands/terminate_bots_with_heartbeat_timeout.py b/attendee/bots/management/commands/terminate_bots_with_heartbeat_timeout.py deleted file mode 100644 index 1e16540..0000000 --- a/attendee/bots/management/commands/terminate_bots_with_heartbeat_timeout.py +++ /dev/null @@ -1,82 +0,0 @@ -import logging -import os - -from django.core.management.base import BaseCommand -from django.db import models -from django.utils import timezone -from kubernetes import client, config - -from bots.models import Bot, BotEventManager, BotEventSubTypes, BotEventTypes - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - help = "Terminates bots that have not sent a heartbeat in the last ten minutes" - - def __init__(self): - super().__init__() - self.namespace = "attendee" - - def terminate_bot(self, bot): - try: - BotEventManager.create_event( - bot=bot, - event_type=BotEventTypes.FATAL_ERROR, - event_sub_type=BotEventSubTypes.FATAL_ERROR_HEARTBEAT_TIMEOUT, - ) - except Exception as e: - logger.error(f"Failed to create fatal error heartbeat timeout event for bot {bot.id}: {str(e)}") - - # There isn't really a safe way to terminate the bot if it's running as a celery task - if not os.getenv("LAUNCH_BOT_METHOD") == "kubernetes": - return - - # Initialize kubernetes client - try: - config.load_incluster_config() - except config.ConfigException: - config.load_kube_config() - v1 = client.CoreV1Api() - logger.info("initialized kubernetes client") - - # Try to delete the pod if it exists - try: - pod_name = bot.k8s_pod_name() - v1.delete_namespaced_pod( - name=pod_name, - namespace=self.namespace, - grace_period_seconds=0, - ) - logger.info(f"Deleted pod: {pod_name}") - except client.ApiException as pod_error: - # 404 means pod doesn't exist, which is fine - if pod_error.status != 404: - logger.warning(f"Error deleting pod {pod_name}: {str(pod_error)}") - - def handle(self, *args, **options): - logger.info("Terminating bots with heartbeat timeout...") - - try: - ten_minutes_ago_timestamp = int(timezone.now().timestamp() - 600) - - # Find non-terminal bots where: - # - last heartbeat is over 10 minutes ago - heartbeat_timeout_q_filter = models.Q(last_heartbeat_timestamp__isnull=False) & models.Q(last_heartbeat_timestamp__lt=ten_minutes_ago_timestamp) - problem_bots = Bot.objects.filter(~BotEventManager.get_terminal_states_q_filter() & heartbeat_timeout_q_filter) - - logger.info(f"Found {problem_bots.count()} bots with heartbeat timeout") - - # Create fatal error events for each bot - for bot in problem_bots: - try: - logger.info(f"Terminating bot {bot.object_id} due to heartbeat timeout") - self.terminate_bot(bot) - - except Exception as e: - logger.error(f"Failed to terminate bot {bot.object_id}: {str(e)}") - - logger.info("Finished terminating bots with heartbeat timeout") - - except client.ApiException as e: - logger.error(f"Failed to terminate bots with heartbeat timeout: {str(e)}") diff --git a/attendee/bots/migrations/0001_initial.py b/attendee/bots/migrations/0001_initial.py deleted file mode 100644 index 2566240..0000000 --- a/attendee/bots/migrations/0001_initial.py +++ /dev/null @@ -1,151 +0,0 @@ -# Generated by Django 5.1.2 on 2024-12-02 04:40 - -import bots.models -import concurrency.fields -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('accounts', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Bot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.CharField(editable=False, max_length=32, unique=True)), - ('name', models.CharField(default='My bot', max_length=255)), - ('meeting_url', models.CharField(max_length=511)), - ('meeting_uuid', models.CharField(blank=True, max_length=511, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('version', concurrency.fields.IntegerVersionField(default=0, help_text='record revision number')), - ('state', models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining - Request Not Started By Bot'), (3, 'Joining - Request Started By Bot'), (4, 'Joined - Not Recording'), (5, 'Joined - Recording'), (6, 'Leaving - Request Not Started By Bot'), (7, 'Leaving - Request Started By Bot'), (8, 'Ended'), (9, 'Fatal Error'), (10, 'Waiting Room')], default=1)), - ('sub_state', models.IntegerField(choices=[(1, 'Fatal Error - Meeting Not Started - Waiting for Host'), (2, 'Fatal Error - Process Terminated')], default=None, null=True)), - ], - ), - migrations.CreateModel( - name='BotEvent', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('old_state', models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining - Request Not Started By Bot'), (3, 'Joining - Request Started By Bot'), (4, 'Joined - Not Recording'), (5, 'Joined - Recording'), (6, 'Leaving - Request Not Started By Bot'), (7, 'Leaving - Request Started By Bot'), (8, 'Ended'), (9, 'Fatal Error'), (10, 'Waiting Room')])), - ('new_state', models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining - Request Not Started By Bot'), (3, 'Joining - Request Started By Bot'), (4, 'Joined - Not Recording'), (5, 'Joined - Recording'), (6, 'Leaving - Request Not Started By Bot'), (7, 'Leaving - Request Started By Bot'), (8, 'Ended'), (9, 'Fatal Error'), (10, 'Waiting Room')])), - ('old_sub_state', models.IntegerField(choices=[(1, 'Fatal Error - Meeting Not Started - Waiting for Host'), (2, 'Fatal Error - Process Terminated')], null=True)), - ('new_sub_state', models.IntegerField(choices=[(1, 'Fatal Error - Meeting Not Started - Waiting for Host'), (2, 'Fatal Error - Process Terminated')], null=True)), - ('event_type', models.IntegerField(choices=[(1, 'Join Requested by API'), (2, 'Join Requested by Bot'), (3, 'Waiting for Host to Start Meeting Message Received'), (4, 'Bot Put in Waiting Room'), (5, 'Bot Joined Meeting'), (6, 'Bot Recording Permission Granted'), (7, 'Process Terminated'), (8, 'Meeting Ended'), (9, 'Leave Requested by API'), (10, 'Leave Requested by Bot'), (11, 'Bot Left Meeting')])), - ('version', models.BigIntegerField()), - ('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bot_events', to='bots.bot')), - ], - options={ - 'ordering': ['created_at'], - }, - ), - migrations.CreateModel( - name='Participant', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uuid', models.CharField(max_length=255)), - ('user_uuid', models.CharField(blank=True, max_length=255, null=True)), - ('full_name', models.CharField(blank=True, max_length=255, null=True)), - ('email', models.EmailField(blank=True, max_length=254, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='bots.bot')), - ], - ), - migrations.CreateModel( - name='Project', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('object_id', models.CharField(editable=False, max_length=32, unique=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='projects', to='accounts.organization')), - ], - ), - migrations.CreateModel( - name='Credentials', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('credential_type', models.IntegerField(choices=[(1, 'Deepgram'), (2, 'Zoom OAuth')])), - ('_encrypted_data', models.BinaryField(null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credentials', to='bots.project')), - ], - ), - migrations.AddField( - model_name='bot', - name='project', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bots', to='bots.project'), - ), - migrations.CreateModel( - name='ApiKey', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('object_id', models.CharField(editable=False, max_length=32, unique=True)), - ('key_hash', models.CharField(max_length=64, unique=True)), - ('disabled_at', models.DateTimeField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to='bots.project')), - ], - ), - migrations.CreateModel( - name='Recording', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('recording_type', models.IntegerField(choices=[(1, 'Audio and Video'), (2, 'Audio Only')])), - ('transcription_type', models.IntegerField(choices=[(1, 'Non realtime'), (2, 'Realtime'), (3, 'No Transcription')])), - ('is_default_recording', models.BooleanField(default=False)), - ('state', models.IntegerField(choices=[(1, 'Not Started'), (2, 'In Progress'), (3, 'Complete'), (4, 'Failed')], default=1)), - ('transcription_state', models.IntegerField(choices=[(1, 'Not Started'), (2, 'In Progress'), (3, 'Complete'), (4, 'Failed')], default=1)), - ('transcription_provider', models.IntegerField(blank=True, choices=[(1, 'Deepgram')], null=True)), - ('version', concurrency.fields.IntegerVersionField(default=0, help_text='record revision number')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('started_at', models.DateTimeField(blank=True, null=True)), - ('completed_at', models.DateTimeField(blank=True, null=True)), - ('first_buffer_timestamp_ms', models.BigIntegerField(blank=True, null=True)), - ('file', models.FileField(storage=bots.models.RecordingStorage(), upload_to='')), - ('object_id', models.CharField(editable=False, max_length=32, unique=True)), - ('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='bots.bot')), - ], - ), - migrations.CreateModel( - name='Utterance', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('audio_blob', models.BinaryField()), - ('audio_format', models.IntegerField(choices=[(1, 'PCM'), (2, 'MP3')], default=1)), - ('timestamp_ms', models.BigIntegerField()), - ('duration_ms', models.IntegerField()), - ('transcription', models.JSONField(default=None, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('participant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='utterances', to='bots.participant')), - ('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='utterances', to='bots.recording')), - ], - ), - migrations.AddConstraint( - model_name='participant', - constraint=models.UniqueConstraint(fields=('bot', 'uuid'), name='unique_participant_per_bot'), - ), - migrations.AddConstraint( - model_name='credentials', - constraint=models.UniqueConstraint(fields=('project', 'credential_type'), name='unique_project_credentials'), - ), - migrations.AddConstraint( - model_name='bot', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('state', 9), models.Q(('sub_state', 1), ('sub_state', 2), _connector='OR')), models.Q(models.Q(('state', 9), _negated=True), ('sub_state__isnull', True)), _connector='OR'), name='valid_state_substate_combinations'), - ), - ] diff --git a/attendee/bots/migrations/0002_mediablob_botmediarequest_and_more.py b/attendee/bots/migrations/0002_mediablob_botmediarequest_and_more.py deleted file mode 100644 index b0529a3..0000000 --- a/attendee/bots/migrations/0002_mediablob_botmediarequest_and_more.py +++ /dev/null @@ -1,47 +0,0 @@ -# Generated by Django 5.1.2 on 2024-12-08 22:19 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='MediaBlob', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.CharField(editable=False, max_length=32, unique=True)), - ('blob', models.BinaryField()), - ('content_type', models.CharField(choices=[('audio/mp3', 'MP3 Audio'), ('image/png', 'PNG Image')], max_length=255)), - ('checksum', models.CharField(editable=False, max_length=64)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('duration_ms', models.IntegerField()), - ('project', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='media_blobs', to='bots.project')), - ], - ), - migrations.CreateModel( - name='BotMediaRequest', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('media_type', models.IntegerField(choices=[(1, 'Image'), (2, 'Audio')])), - ('state', models.IntegerField(choices=[(1, 'Enqueued'), (2, 'Playing'), (3, 'Dropped'), (4, 'Finished'), (5, 'Failed to Play')], default=1)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_requests', to='bots.bot')), - ('media_blob', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='bot_media_requests', to='bots.mediablob')), - ], - ), - migrations.AddConstraint( - model_name='mediablob', - constraint=models.UniqueConstraint(fields=('project', 'checksum'), name='unique_project_blob'), - ), - migrations.AddConstraint( - model_name='botmediarequest', - constraint=models.UniqueConstraint(condition=models.Q(('state', 2)), fields=('bot', 'media_type'), name='unique_playing_media_request_per_bot_and_type'), - ), - ] diff --git a/attendee/bots/migrations/0003_remove_bot_valid_state_substate_combinations_and_more.py b/attendee/bots/migrations/0003_remove_bot_valid_state_substate_combinations_and_more.py deleted file mode 100644 index 1320097..0000000 --- a/attendee/bots/migrations/0003_remove_bot_valid_state_substate_combinations_and_more.py +++ /dev/null @@ -1,74 +0,0 @@ -# Generated by Django 5.1.2 on 2024-12-15 07:02 - -import concurrency.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0002_mediablob_botmediarequest_and_more'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='bot', - name='valid_state_substate_combinations', - ), - migrations.RemoveField( - model_name='bot', - name='sub_state', - ), - migrations.RemoveField( - model_name='botevent', - name='new_sub_state', - ), - migrations.RemoveField( - model_name='botevent', - name='old_sub_state', - ), - migrations.AddField( - model_name='botevent', - name='debug_message', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='botevent', - name='event_sub_type', - field=models.IntegerField(choices=[(1, 'Bot could not join meeting - Meeting Not Started - Waiting for Host'), (2, 'Fatal error - Process Terminated')], null=True), - ), - migrations.AddField( - model_name='botevent', - name='requested_bot_action_taken_at', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.AlterField( - model_name='bot', - name='state', - field=models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining'), (3, 'Joined - Not Recording'), (4, 'Joined - Recording'), (5, 'Leaving'), (6, 'Ended'), (7, 'Fatal Error'), (8, 'Waiting Room')], default=1), - ), - migrations.AlterField( - model_name='botevent', - name='event_type', - field=models.IntegerField(choices=[(1, 'Bot Put in Waiting Room'), (2, 'Bot Joined Meeting'), (3, 'Bot Recording Permission Granted'), (4, 'Meeting Ended'), (5, 'Bot Left Meeting'), (6, 'Bot requested to join meeting'), (7, 'Bot Encountered Fatal error'), (8, 'Bot requested to leave meeting'), (9, 'Bot could not join meeting')]), - ), - migrations.AlterField( - model_name='botevent', - name='new_state', - field=models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining'), (3, 'Joined - Not Recording'), (4, 'Joined - Recording'), (5, 'Leaving'), (6, 'Ended'), (7, 'Fatal Error'), (8, 'Waiting Room')]), - ), - migrations.AlterField( - model_name='botevent', - name='old_state', - field=models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining'), (3, 'Joined - Not Recording'), (4, 'Joined - Recording'), (5, 'Leaving'), (6, 'Ended'), (7, 'Fatal Error'), (8, 'Waiting Room')]), - ), - migrations.AlterField( - model_name='botevent', - name='version', - field=concurrency.fields.IntegerVersionField(default=0, help_text='record revision number'), - ), - migrations.AddConstraint( - model_name='botevent', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('event_type', 7), ('event_sub_type', 2)), models.Q(('event_type', 9), ('event_sub_type', 1)), models.Q(models.Q(('event_type', 7), _negated=True), models.Q(('event_type', 9), _negated=True), ('event_sub_type__isnull', True)), _connector='OR'), name='valid_event_type_event_sub_type_combinations'), - ), - ] diff --git a/attendee/bots/migrations/0004_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py b/attendee/bots/migrations/0004_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py deleted file mode 100644 index 765f682..0000000 --- a/attendee/bots/migrations/0004_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.2 on 2024-12-26 18:04 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0003_remove_bot_valid_state_substate_combinations_and_more'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='botevent', - name='valid_event_type_event_sub_type_combinations', - ), - migrations.AlterField( - model_name='botevent', - name='event_sub_type', - field=models.IntegerField(choices=[(1, 'Bot could not join meeting - Meeting Not Started - Waiting for Host'), (2, 'Fatal error - Process Terminated'), (3, 'Bot could not join meeting - Zoom Authorization Failed')], null=True), - ), - migrations.AddConstraint( - model_name='botevent', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('event_type', 7), ('event_sub_type', 2)), models.Q(('event_type', 9), models.Q(('event_sub_type', 1), ('event_sub_type', 3), _connector='OR')), models.Q(models.Q(('event_type', 7), _negated=True), models.Q(('event_type', 9), _negated=True), ('event_sub_type__isnull', True)), _connector='OR'), name='valid_event_type_event_sub_type_combinations'), - ), - ] diff --git a/attendee/bots/migrations/0005_utterance_source_utterance_source_uuid_and_more.py b/attendee/bots/migrations/0005_utterance_source_utterance_source_uuid_and_more.py deleted file mode 100644 index ede324a..0000000 --- a/attendee/bots/migrations/0005_utterance_source_utterance_source_uuid_and_more.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.2 on 2025-01-16 02:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0004_remove_botevent_valid_event_type_event_sub_type_combinations_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='utterance', - name='source', - field=models.IntegerField(choices=[(1, 'Per Participant Audio'), (2, 'Closed Caption From Platform')], default=1), - ), - migrations.AddField( - model_name='utterance', - name='source_uuid', - field=models.CharField(max_length=255, null=True, unique=True), - ), - migrations.AlterField( - model_name='utterance', - name='audio_format', - field=models.IntegerField(choices=[(1, 'PCM'), (2, 'MP3')], default=1, null=True), - ), - ] diff --git a/attendee/bots/migrations/0006_bot_settings.py b/attendee/bots/migrations/0006_bot_settings.py deleted file mode 100644 index 1a9c58e..0000000 --- a/attendee/bots/migrations/0006_bot_settings.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2025-01-24 15:12 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0005_utterance_source_utterance_source_uuid_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='bot', - name='settings', - field=models.JSONField(default=dict), - ), - ] diff --git a/attendee/bots/migrations/0007_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py b/attendee/bots/migrations/0007_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py deleted file mode 100644 index e365185..0000000 --- a/attendee/bots/migrations/0007_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.2 on 2025-01-27 15:27 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0006_bot_settings'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='botevent', - name='valid_event_type_event_sub_type_combinations', - ), - migrations.AlterField( - model_name='botevent', - name='event_sub_type', - field=models.IntegerField(choices=[(1, 'Bot could not join meeting - Meeting Not Started - Waiting for Host'), (2, 'Fatal error - Process Terminated'), (3, 'Bot could not join meeting - Zoom Authorization Failed'), (4, 'Bot could not join meeting - Zoom Meeting Status Failed'), (5, 'Bot could not join meeting - Unpublished Zoom Apps cannot join external meetings. See https://developers.zoom.us/blog/prepare-meeting-sdk-app-for-review')], null=True), - ), - migrations.AddConstraint( - model_name='botevent', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('event_type', 7), ('event_sub_type', 2)), models.Q(('event_type', 9), models.Q(('event_sub_type', 1), ('event_sub_type', 3), ('event_sub_type', 4), ('event_sub_type', 5), _connector='OR')), models.Q(models.Q(('event_type', 7), _negated=True), models.Q(('event_type', 9), _negated=True), ('event_sub_type__isnull', True)), _connector='OR'), name='valid_event_type_event_sub_type_combinations'), - ), - ] diff --git a/attendee/bots/migrations/0008_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py b/attendee/bots/migrations/0008_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py deleted file mode 100644 index 0ce4ed1..0000000 --- a/attendee/bots/migrations/0008_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.2 on 2025-01-30 03:44 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0007_remove_botevent_valid_event_type_event_sub_type_combinations_and_more'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='botevent', - name='valid_event_type_event_sub_type_combinations', - ), - migrations.AlterField( - model_name='botevent', - name='event_sub_type', - field=models.IntegerField(choices=[(1, 'Bot could not join meeting - Meeting Not Started - Waiting for Host'), (2, 'Fatal error - Process Terminated'), (3, 'Bot could not join meeting - Zoom Authorization Failed'), (4, 'Bot could not join meeting - Zoom Meeting Status Failed'), (5, 'Bot could not join meeting - Unpublished Zoom Apps cannot join external meetings. See https://developers.zoom.us/blog/prepare-meeting-sdk-app-for-review'), (6, 'Fatal error - RTMP Connection Failed')], null=True), - ), - migrations.AddConstraint( - model_name='botevent', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('event_type', 7), models.Q(('event_sub_type', 2), ('event_sub_type', 6), _connector='OR')), models.Q(('event_type', 9), models.Q(('event_sub_type', 1), ('event_sub_type', 3), ('event_sub_type', 4), ('event_sub_type', 5), _connector='OR')), models.Q(models.Q(('event_type', 7), _negated=True), models.Q(('event_type', 9), _negated=True), ('event_sub_type__isnull', True)), _connector='OR'), name='valid_event_type_event_sub_type_combinations'), - ), - ] diff --git a/attendee/bots/migrations/0009_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py b/attendee/bots/migrations/0009_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py deleted file mode 100644 index 004397c..0000000 --- a/attendee/bots/migrations/0009_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.2 on 2025-01-30 20:19 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0008_remove_botevent_valid_event_type_event_sub_type_combinations_and_more'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='botevent', - name='valid_event_type_event_sub_type_combinations', - ), - migrations.AlterField( - model_name='botevent', - name='event_sub_type', - field=models.IntegerField(choices=[(1, 'Bot could not join meeting - Meeting Not Started - Waiting for Host'), (2, 'Fatal error - Process Terminated'), (3, 'Bot could not join meeting - Zoom Authorization Failed'), (4, 'Bot could not join meeting - Zoom Meeting Status Failed'), (5, 'Bot could not join meeting - Unpublished Zoom Apps cannot join external meetings. See https://developers.zoom.us/blog/prepare-meeting-sdk-app-for-review'), (6, 'Fatal error - RTMP Connection Failed'), (7, 'Bot could not join meeting - Zoom SDK Internal Error')], null=True), - ), - migrations.AddConstraint( - model_name='botevent', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('event_type', 7), models.Q(('event_sub_type', 2), ('event_sub_type', 6), _connector='OR')), models.Q(('event_type', 9), models.Q(('event_sub_type', 1), ('event_sub_type', 3), ('event_sub_type', 4), ('event_sub_type', 5), ('event_sub_type', 7), _connector='OR')), models.Q(models.Q(('event_type', 7), _negated=True), models.Q(('event_type', 9), _negated=True), ('event_sub_type__isnull', True)), _connector='OR'), name='valid_event_type_event_sub_type_combinations'), - ), - ] diff --git a/attendee/bots/migrations/0010_botmediarequest_text_to_speak_and_more.py b/attendee/bots/migrations/0010_botmediarequest_text_to_speak_and_more.py deleted file mode 100644 index b045ea3..0000000 --- a/attendee/bots/migrations/0010_botmediarequest_text_to_speak_and_more.py +++ /dev/null @@ -1,29 +0,0 @@ -# Generated by Django 5.1.2 on 2025-02-03 21:48 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0009_remove_botevent_valid_event_type_event_sub_type_combinations_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='botmediarequest', - name='text_to_speak', - field=models.TextField(blank=True, null=True), - ), - migrations.AddField( - model_name='botmediarequest', - name='text_to_speech_settings', - field=models.JSONField(default=None, null=True), - ), - migrations.AlterField( - model_name='botmediarequest', - name='media_blob', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='bot_media_requests', to='bots.mediablob'), - ), - ] diff --git a/attendee/bots/migrations/0011_alter_credentials_credential_type.py b/attendee/bots/migrations/0011_alter_credentials_credential_type.py deleted file mode 100644 index 6283542..0000000 --- a/attendee/bots/migrations/0011_alter_credentials_credential_type.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2025-02-05 16:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0010_botmediarequest_text_to_speak_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='credentials', - name='credential_type', - field=models.IntegerField(choices=[(1, 'Deepgram'), (2, 'Zoom OAuth'), (3, 'Google Text To Speech')]), - ), - ] diff --git a/attendee/bots/migrations/0012_botdebugscreenshot_and_more.py b/attendee/bots/migrations/0012_botdebugscreenshot_and_more.py deleted file mode 100644 index e7a4472..0000000 --- a/attendee/bots/migrations/0012_botdebugscreenshot_and_more.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 5.1.2 on 2025-02-11 15:32 - -import bots.models -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0011_alter_credentials_credential_type'), - ] - - operations = [ - migrations.CreateModel( - name='BotDebugScreenshot', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.CharField(editable=False, max_length=32, unique=True)), - ('metadata', models.JSONField(default=dict)), - ('file', models.FileField(storage=bots.models.BotDebugScreenshotStorage(), upload_to='')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.RemoveConstraint( - model_name='botevent', - name='valid_event_type_event_sub_type_combinations', - ), - migrations.RemoveField( - model_name='botevent', - name='debug_message', - ), - migrations.AddField( - model_name='botevent', - name='metadata', - field=models.JSONField(default=dict), - ), - migrations.AlterField( - model_name='botevent', - name='event_sub_type', - field=models.IntegerField(choices=[(1, 'Bot could not join meeting - Meeting Not Started - Waiting for Host'), (2, 'Fatal error - Process Terminated'), (3, 'Bot could not join meeting - Zoom Authorization Failed'), (4, 'Bot could not join meeting - Zoom Meeting Status Failed'), (5, 'Bot could not join meeting - Unpublished Zoom Apps cannot join external meetings. See https://developers.zoom.us/blog/prepare-meeting-sdk-app-for-review'), (6, 'Fatal error - RTMP Connection Failed'), (7, 'Bot could not join meeting - Zoom SDK Internal Error'), (8, 'Fatal error - UI Element Not Found'), (9, 'Bot could not join meeting - Request to join denied')], null=True), - ), - migrations.AddConstraint( - model_name='botevent', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('event_type', 7), models.Q(('event_sub_type', 2), ('event_sub_type', 6), ('event_sub_type', 8), _connector='OR')), models.Q(('event_type', 9), models.Q(('event_sub_type', 1), ('event_sub_type', 3), ('event_sub_type', 4), ('event_sub_type', 5), ('event_sub_type', 7), ('event_sub_type', 9), _connector='OR')), models.Q(models.Q(('event_type', 7), _negated=True), models.Q(('event_type', 9), _negated=True), ('event_sub_type__isnull', True)), _connector='OR'), name='valid_event_type_event_sub_type_combinations'), - ), - migrations.AddField( - model_name='botdebugscreenshot', - name='bot_event', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='debug_screenshots', to='bots.botevent'), - ), - ] diff --git a/attendee/bots/migrations/0013_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py b/attendee/bots/migrations/0013_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py deleted file mode 100644 index a3a01d8..0000000 --- a/attendee/bots/migrations/0013_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.2 on 2025-02-19 15:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0012_botdebugscreenshot_and_more'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='botevent', - name='valid_event_type_event_sub_type_combinations', - ), - migrations.AlterField( - model_name='botevent', - name='event_sub_type', - field=models.IntegerField(choices=[(1, 'Bot could not join meeting - Meeting Not Started - Waiting for Host'), (2, 'Fatal error - Process Terminated'), (3, 'Bot could not join meeting - Zoom Authorization Failed'), (4, 'Bot could not join meeting - Zoom Meeting Status Failed'), (5, 'Bot could not join meeting - Unpublished Zoom Apps cannot join external meetings. See https://developers.zoom.us/blog/prepare-meeting-sdk-app-for-review'), (6, 'Fatal error - RTMP Connection Failed'), (7, 'Bot could not join meeting - Zoom SDK Internal Error'), (8, 'Fatal error - UI Element Not Found'), (9, 'Bot could not join meeting - Request to join denied'), (10, 'Leave requested - User requested'), (11, 'Leave requested - Auto leave silence'), (12, 'Leave requested - Auto leave only participant in meeting')], null=True), - ), - migrations.AddConstraint( - model_name='botevent', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('event_type', 7), models.Q(('event_sub_type', 2), ('event_sub_type', 6), ('event_sub_type', 8), _connector='OR')), models.Q(('event_type', 9), models.Q(('event_sub_type', 1), ('event_sub_type', 3), ('event_sub_type', 4), ('event_sub_type', 5), ('event_sub_type', 7), ('event_sub_type', 9), _connector='OR')), models.Q(('event_type', 8), models.Q(('event_sub_type', 10), ('event_sub_type', 11), ('event_sub_type', 12), ('event_sub_type__isnull', True), _connector='OR')), models.Q(models.Q(('event_type', 7), _negated=True), models.Q(('event_type', 9), _negated=True), models.Q(('event_type', 8), _negated=True), ('event_sub_type__isnull', True)), _connector='OR'), name='valid_event_type_event_sub_type_combinations'), - ), - ] diff --git a/attendee/bots/migrations/0014_utterance_sample_rate.py b/attendee/bots/migrations/0014_utterance_sample_rate.py deleted file mode 100644 index d520b01..0000000 --- a/attendee/bots/migrations/0014_utterance_sample_rate.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2025-02-25 23:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0013_remove_botevent_valid_event_type_event_sub_type_combinations_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='utterance', - name='sample_rate', - field=models.IntegerField(default=None, null=True), - ), - ] diff --git a/attendee/bots/migrations/0015_alter_bot_state_alter_botevent_event_type_and_more.py b/attendee/bots/migrations/0015_alter_bot_state_alter_botevent_event_type_and_more.py deleted file mode 100644 index 3c6e3d3..0000000 --- a/attendee/bots/migrations/0015_alter_bot_state_alter_botevent_event_type_and_more.py +++ /dev/null @@ -1,33 +0,0 @@ -# Generated by Django 5.1.2 on 2025-02-28 05:55 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0014_utterance_sample_rate'), - ] - - operations = [ - migrations.AlterField( - model_name='bot', - name='state', - field=models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining'), (3, 'Joined - Not Recording'), (4, 'Joined - Recording'), (5, 'Leaving'), (6, 'Post Processing'), (7, 'Fatal Error'), (8, 'Waiting Room'), (9, 'Ended')], default=1), - ), - migrations.AlterField( - model_name='botevent', - name='event_type', - field=models.IntegerField(choices=[(1, 'Bot Put in Waiting Room'), (2, 'Bot Joined Meeting'), (3, 'Bot Recording Permission Granted'), (4, 'Meeting Ended'), (5, 'Bot Left Meeting'), (6, 'Bot requested to join meeting'), (7, 'Bot Encountered Fatal error'), (8, 'Bot requested to leave meeting'), (9, 'Bot could not join meeting'), (10, 'Post Processing Completed')]), - ), - migrations.AlterField( - model_name='botevent', - name='new_state', - field=models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining'), (3, 'Joined - Not Recording'), (4, 'Joined - Recording'), (5, 'Leaving'), (6, 'Post Processing'), (7, 'Fatal Error'), (8, 'Waiting Room'), (9, 'Ended')]), - ), - migrations.AlterField( - model_name='botevent', - name='old_state', - field=models.IntegerField(choices=[(1, 'Ready'), (2, 'Joining'), (3, 'Joined - Not Recording'), (4, 'Joined - Recording'), (5, 'Leaving'), (6, 'Post Processing'), (7, 'Fatal Error'), (8, 'Waiting Room'), (9, 'Ended')]), - ), - ] diff --git a/attendee/bots/migrations/0016_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py b/attendee/bots/migrations/0016_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py deleted file mode 100644 index dedc3f7..0000000 --- a/attendee/bots/migrations/0016_remove_botevent_valid_event_type_event_sub_type_combinations_and_more.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 5.1.2 on 2025-03-07 03:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('bots', '0015_alter_bot_state_alter_botevent_event_type_and_more'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='botevent', - name='valid_event_type_event_sub_type_combinations', - ), - migrations.AddField( - model_name='bot', - name='first_heartbeat_timestamp', - field=models.IntegerField(blank=True, null=True), - ), - migrations.AddField( - model_name='bot', - name='last_heartbeat_timestamp', - field=models.IntegerField(blank=True, null=True), - ), - migrations.AlterField( - model_name='botevent', - name='event_sub_type', - field=models.IntegerField(choices=[(1, 'Bot could not join meeting - Meeting Not Started - Waiting for Host'), (2, 'Fatal error - Process Terminated'), (3, 'Bot could not join meeting - Zoom Authorization Failed'), (4, 'Bot could not join meeting - Zoom Meeting Status Failed'), (5, 'Bot could not join meeting - Unpublished Zoom Apps cannot join external meetings. See https://developers.zoom.us/blog/prepare-meeting-sdk-app-for-review'), (6, 'Fatal error - RTMP Connection Failed'), (7, 'Bot could not join meeting - Zoom SDK Internal Error'), (8, 'Fatal error - UI Element Not Found'), (9, 'Bot could not join meeting - Request to join denied'), (10, 'Leave requested - User requested'), (11, 'Leave requested - Auto leave silence'), (12, 'Leave requested - Auto leave only participant in meeting'), (13, 'Fatal error - Heartbeat timeout')], null=True), - ), - migrations.AddConstraint( - model_name='botevent', - constraint=models.CheckConstraint(condition=models.Q(models.Q(('event_type', 7), models.Q(('event_sub_type', 2), ('event_sub_type', 6), ('event_sub_type', 8), ('event_sub_type', 13), _connector='OR')), models.Q(('event_type', 9), models.Q(('event_sub_type', 1), ('event_sub_type', 3), ('event_sub_type', 4), ('event_sub_type', 5), ('event_sub_type', 7), ('event_sub_type', 9), _connector='OR')), models.Q(('event_type', 8), models.Q(('event_sub_type', 10), ('event_sub_type', 11), ('event_sub_type', 12), ('event_sub_type__isnull', True), _connector='OR')), models.Q(models.Q(('event_type', 7), _negated=True), models.Q(('event_type', 9), _negated=True), models.Q(('event_type', 8), _negated=True), ('event_sub_type__isnull', True)), _connector='OR'), name='valid_event_type_event_sub_type_combinations'), - ), - ] diff --git a/attendee/bots/migrations/__init__.py b/attendee/bots/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/attendee/bots/models.py b/attendee/bots/models.py deleted file mode 100644 index acfe495..0000000 --- a/attendee/bots/models.py +++ /dev/null @@ -1,1042 +0,0 @@ -import hashlib -import json -import random -import string - -from concurrency.exceptions import RecordModifiedError -from concurrency.fields import IntegerVersionField -from cryptography.fernet import Fernet -from django.conf import settings -from django.core.exceptions import ValidationError -from django.db import models, transaction -from django.db.models import Q -from django.utils import timezone -from django.utils.crypto import get_random_string - -from accounts.models import Organization - -# Create your models here. - - -class Project(models.Model): - name = models.CharField(max_length=255) - organization = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="projects") - - OBJECT_ID_PREFIX = "proj_" - object_id = models.CharField(max_length=32, unique=True, editable=False) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - def save(self, *args, **kwargs): - if not self.object_id: - # Generate a random 16-character string - random_string = "".join(random.choices(string.ascii_letters + string.digits, k=16)) - self.object_id = f"{self.OBJECT_ID_PREFIX}{random_string}" - super().save(*args, **kwargs) - - def __str__(self): - return self.name - - -class ApiKey(models.Model): - name = models.CharField(max_length=255) - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="api_keys") - - OBJECT_ID_PREFIX = "key_" - object_id = models.CharField(max_length=32, unique=True, editable=False) - - def save(self, *args, **kwargs): - if not self.object_id: - # Generate a random 16-character string - random_string = "".join(random.choices(string.ascii_letters + string.digits, k=16)) - self.object_id = f"{self.OBJECT_ID_PREFIX}{random_string}" - super().save(*args, **kwargs) - - key_hash = models.CharField(max_length=64, unique=True) # SHA-256 hash is 64 chars - disabled_at = models.DateTimeField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - @classmethod - def create(cls, project, name): - # Generate a random API key (you might want to adjust the length) - api_key = get_random_string(length=32) - # Create hash of the API key - key_hash = hashlib.sha256(api_key.encode()).hexdigest() - - instance = cls(project=project, name=name, key_hash=key_hash) - instance.save() - - # Return both the instance and the plain text key - # The plain text key will only be available during creation - return instance, api_key - - def __str__(self): - return f"{self.name} ({self.project.name})" - - -class MeetingTypes(models.TextChoices): - ZOOM = "zoom" - GOOGLE_MEET = "google_meet" - TEAMS = "teams" - - -class BotStates(models.IntegerChoices): - READY = 1, "Ready" - JOINING = 2, "Joining" - JOINED_NOT_RECORDING = 3, "Joined - Not Recording" - JOINED_RECORDING = 4, "Joined - Recording" - LEAVING = 5, "Leaving" - POST_PROCESSING = 6, "Post Processing" - FATAL_ERROR = 7, "Fatal Error" - WAITING_ROOM = 8, "Waiting Room" - ENDED = 9, "Ended" - - @classmethod - def state_to_api_code(cls, value): - """Returns the API code for a given state value""" - mapping = { - cls.READY: "ready", - cls.JOINING: "joining", - cls.JOINED_NOT_RECORDING: "joined_not_recording", - cls.JOINED_RECORDING: "joined_recording", - cls.LEAVING: "leaving", - cls.POST_PROCESSING: "post_processing", - cls.FATAL_ERROR: "fatal_error", - cls.WAITING_ROOM: "waiting_room", - cls.ENDED: "ended", - } - return mapping.get(value) - - -class RecordingFormats(models.TextChoices): - MP4 = "mp4" - WEBM = "webm" - - -class Bot(models.Model): - OBJECT_ID_PREFIX = "bot_" - - object_id = models.CharField(max_length=32, unique=True, editable=False) - - project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="bots") - - name = models.CharField(max_length=255, default="My bot") - meeting_url = models.CharField(max_length=511) - meeting_uuid = models.CharField(max_length=511, null=True, blank=True) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - version = IntegerVersionField() - - state = models.IntegerField(choices=BotStates.choices, default=BotStates.READY, null=False) - - settings = models.JSONField(null=False, default=dict) - - first_heartbeat_timestamp = models.IntegerField(null=True, blank=True) - last_heartbeat_timestamp = models.IntegerField(null=True, blank=True) - - def set_heartbeat(self): - retry_count = 0 - max_retries = 10 - while retry_count < max_retries: - try: - self.refresh_from_db() - current_timestamp = int(timezone.now().timestamp()) - if self.first_heartbeat_timestamp is None: - self.first_heartbeat_timestamp = current_timestamp - self.last_heartbeat_timestamp = current_timestamp - self.save() - return - except RecordModifiedError: - retry_count += 1 - if retry_count >= max_retries: - raise - continue - - def deepgram_language(self): - return self.settings.get("transcription_settings", {}).get("deepgram", {}).get("language", None) - - def deepgram_detect_language(self): - return self.settings.get("transcription_settings", {}).get("deepgram", {}).get("detect_language", None) - - def rtmp_destination_url(self): - rtmp_settings = self.settings.get("rtmp_settings") - if not rtmp_settings: - return None - - destination_url = rtmp_settings.get("destination_url", "").rstrip("/") - stream_key = rtmp_settings.get("stream_key", "") - - if not destination_url: - return None - - return f"{destination_url}/{stream_key}" - - def recording_format(self): - recording_settings = self.settings.get("recording_settings", {}) - if recording_settings is None: - recording_settings = {} - return recording_settings.get("format", RecordingFormats.WEBM) - - def last_bot_event(self): - return self.bot_events.order_by("-created_at").first() - - def save(self, *args, **kwargs): - if not self.object_id: - # Generate a random 16-character string - random_string = "".join(random.choices(string.ascii_letters + string.digits, k=16)) - self.object_id = f"{self.OBJECT_ID_PREFIX}{random_string}" - super().save(*args, **kwargs) - - def __str__(self): - return f"{self.object_id} - {self.project.name} in {self.meeting_url}" - - def k8s_pod_name(self): - return f"bot-pod-{self.id}-{self.object_id}".lower().replace("_", "-") - - -class BotEventTypes(models.IntegerChoices): - BOT_PUT_IN_WAITING_ROOM = 1, "Bot Put in Waiting Room" - BOT_JOINED_MEETING = 2, "Bot Joined Meeting" - BOT_RECORDING_PERMISSION_GRANTED = 3, "Bot Recording Permission Granted" - MEETING_ENDED = 4, "Meeting Ended" - BOT_LEFT_MEETING = 5, "Bot Left Meeting" - JOIN_REQUESTED = 6, "Bot requested to join meeting" - FATAL_ERROR = 7, "Bot Encountered Fatal error" - LEAVE_REQUESTED = 8, "Bot requested to leave meeting" - COULD_NOT_JOIN = 9, "Bot could not join meeting" - POST_PROCESSING_COMPLETED = 10, "Post Processing Completed" - - @classmethod - def type_to_api_code(cls, value): - """Returns the API code for a given type value""" - mapping = { - cls.BOT_PUT_IN_WAITING_ROOM: "put_in_waiting_room", - cls.BOT_JOINED_MEETING: "joined_meeting", - cls.BOT_RECORDING_PERMISSION_GRANTED: "recording_permission_granted", - cls.MEETING_ENDED: "meeting_ended", - cls.BOT_LEFT_MEETING: "left_meeting", - cls.JOIN_REQUESTED: "join_requested", - cls.FATAL_ERROR: "fatal_error", - cls.LEAVE_REQUESTED: "leave_requested", - cls.COULD_NOT_JOIN: "could_not_join_meeting", - cls.POST_PROCESSING_COMPLETED: "post_processing_completed", - } - return mapping.get(value) - - -class BotEventSubTypes(models.IntegerChoices): - COULD_NOT_JOIN_MEETING_NOT_STARTED_WAITING_FOR_HOST = ( - 1, - "Bot could not join meeting - Meeting Not Started - Waiting for Host", - ) - FATAL_ERROR_PROCESS_TERMINATED = 2, "Fatal error - Process Terminated" - COULD_NOT_JOIN_MEETING_ZOOM_AUTHORIZATION_FAILED = ( - 3, - "Bot could not join meeting - Zoom Authorization Failed", - ) - COULD_NOT_JOIN_MEETING_ZOOM_MEETING_STATUS_FAILED = ( - 4, - "Bot could not join meeting - Zoom Meeting Status Failed", - ) - COULD_NOT_JOIN_MEETING_UNPUBLISHED_ZOOM_APP = ( - 5, - "Bot could not join meeting - Unpublished Zoom Apps cannot join external meetings. See https://developers.zoom.us/blog/prepare-meeting-sdk-app-for-review", - ) - FATAL_ERROR_RTMP_CONNECTION_FAILED = 6, "Fatal error - RTMP Connection Failed" - COULD_NOT_JOIN_MEETING_ZOOM_SDK_INTERNAL_ERROR = ( - 7, - "Bot could not join meeting - Zoom SDK Internal Error", - ) - FATAL_ERROR_UI_ELEMENT_NOT_FOUND = 8, "Fatal error - UI Element Not Found" - COULD_NOT_JOIN_MEETING_REQUEST_TO_JOIN_DENIED = ( - 9, - "Bot could not join meeting - Request to join denied", - ) - LEAVE_REQUESTED_USER_REQUESTED = 10, "Leave requested - User requested" - LEAVE_REQUESTED_AUTO_LEAVE_SILENCE = 11, "Leave requested - Auto leave silence" - LEAVE_REQUESTED_AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING = 12, "Leave requested - Auto leave only participant in meeting" - FATAL_ERROR_HEARTBEAT_TIMEOUT = 13, "Fatal error - Heartbeat timeout" - - @classmethod - def sub_type_to_api_code(cls, value): - """Returns the API code for a given sub type value""" - mapping = { - cls.COULD_NOT_JOIN_MEETING_NOT_STARTED_WAITING_FOR_HOST: "meeting_not_started_waiting_for_host", - cls.FATAL_ERROR_PROCESS_TERMINATED: "process_terminated", - cls.COULD_NOT_JOIN_MEETING_ZOOM_AUTHORIZATION_FAILED: "zoom_authorization_failed", - cls.COULD_NOT_JOIN_MEETING_ZOOM_MEETING_STATUS_FAILED: "zoom_meeting_status_failed", - cls.COULD_NOT_JOIN_MEETING_UNPUBLISHED_ZOOM_APP: "unpublished_zoom_app", - cls.FATAL_ERROR_RTMP_CONNECTION_FAILED: "rtmp_connection_failed", - cls.COULD_NOT_JOIN_MEETING_ZOOM_SDK_INTERNAL_ERROR: "zoom_sdk_internal_error", - cls.FATAL_ERROR_UI_ELEMENT_NOT_FOUND: "ui_element_not_found", - cls.COULD_NOT_JOIN_MEETING_REQUEST_TO_JOIN_DENIED: "request_to_join_denied", - cls.LEAVE_REQUESTED_USER_REQUESTED: "user_requested", - cls.LEAVE_REQUESTED_AUTO_LEAVE_SILENCE: "auto_leave_silence", - cls.LEAVE_REQUESTED_AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING: "auto_leave_only_participant_in_meeting", - cls.FATAL_ERROR_HEARTBEAT_TIMEOUT: "heartbeat_timeout", - } - return mapping.get(value) - - -class BotEvent(models.Model): - bot = models.ForeignKey(Bot, on_delete=models.CASCADE, related_name="bot_events") - - created_at = models.DateTimeField(auto_now_add=True) - - old_state = models.IntegerField(choices=BotStates.choices) - new_state = models.IntegerField(choices=BotStates.choices) - - event_type = models.IntegerField(choices=BotEventTypes.choices) # What happened - event_sub_type = models.IntegerField(choices=BotEventSubTypes.choices, null=True) # Why it happened - metadata = models.JSONField(null=False, default=dict) - requested_bot_action_taken_at = models.DateTimeField(null=True, blank=True) # For when a bot action is requested, this is the time it was taken - version = IntegerVersionField() - - def __str__(self): - old_state_str = BotStates(self.old_state).label - new_state_str = BotStates(self.new_state).label - - # Base string with event type - base_str = f"{self.bot.object_id} - [{BotEventTypes(self.event_type).label}" - - # Add event sub type if it exists - if self.event_sub_type is not None: - base_str += f" - {BotEventSubTypes(self.event_sub_type).label}" - - # Add state transition - base_str += f"] - {old_state_str} -> {new_state_str}" - - return base_str - - class Meta: - ordering = ["created_at"] - constraints = [ - models.CheckConstraint( - check=( - # For FATAL_ERROR event type, must have one of the valid event subtypes - (Q(event_type=BotEventTypes.FATAL_ERROR) & (Q(event_sub_type=BotEventSubTypes.FATAL_ERROR_PROCESS_TERMINATED) | Q(event_sub_type=BotEventSubTypes.FATAL_ERROR_RTMP_CONNECTION_FAILED) | Q(event_sub_type=BotEventSubTypes.FATAL_ERROR_UI_ELEMENT_NOT_FOUND) | Q(event_sub_type=BotEventSubTypes.FATAL_ERROR_HEARTBEAT_TIMEOUT))) - | - # For COULD_NOT_JOIN event type, must have one of the valid event subtypes - (Q(event_type=BotEventTypes.COULD_NOT_JOIN) & (Q(event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_NOT_STARTED_WAITING_FOR_HOST) | Q(event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_AUTHORIZATION_FAILED) | Q(event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_MEETING_STATUS_FAILED) | Q(event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_UNPUBLISHED_ZOOM_APP) | Q(event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_SDK_INTERNAL_ERROR) | Q(event_sub_type=BotEventSubTypes.COULD_NOT_JOIN_MEETING_REQUEST_TO_JOIN_DENIED))) - | - # For LEAVE_REQUESTED event type, must have one of the valid event subtypes or be null (for backwards compatibility, this will eventually be removed) - (Q(event_type=BotEventTypes.LEAVE_REQUESTED) & (Q(event_sub_type=BotEventSubTypes.LEAVE_REQUESTED_USER_REQUESTED) | Q(event_sub_type=BotEventSubTypes.LEAVE_REQUESTED_AUTO_LEAVE_SILENCE) | Q(event_sub_type=BotEventSubTypes.LEAVE_REQUESTED_AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING) | Q(event_sub_type__isnull=True))) - | - # For all other events, event_sub_type must be null - (~Q(event_type=BotEventTypes.FATAL_ERROR) & ~Q(event_type=BotEventTypes.COULD_NOT_JOIN) & ~Q(event_type=BotEventTypes.LEAVE_REQUESTED) & Q(event_sub_type__isnull=True)) - ), - name="valid_event_type_event_sub_type_combinations", - ) - ] - - -class BotEventManager: - TERMINAL_STATES = [BotStates.FATAL_ERROR, BotStates.ENDED] - - # Define valid state transitions for each event type - VALID_TRANSITIONS = { - BotEventTypes.JOIN_REQUESTED: { - "from": BotStates.READY, - "to": BotStates.JOINING, - }, - BotEventTypes.COULD_NOT_JOIN: { - "from": BotStates.JOINING, - "to": BotStates.FATAL_ERROR, - }, - BotEventTypes.FATAL_ERROR: { - "from": [ - BotStates.JOINING, - BotStates.JOINED_RECORDING, - BotStates.JOINED_NOT_RECORDING, - BotStates.WAITING_ROOM, - BotStates.LEAVING, - ], - "to": BotStates.FATAL_ERROR, - }, - BotEventTypes.BOT_PUT_IN_WAITING_ROOM: { - "from": BotStates.JOINING, - "to": BotStates.WAITING_ROOM, - }, - BotEventTypes.BOT_JOINED_MEETING: { - "from": [BotStates.WAITING_ROOM, BotStates.JOINING], - "to": BotStates.JOINED_NOT_RECORDING, - }, - BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED: { - "from": BotStates.JOINED_NOT_RECORDING, - "to": BotStates.JOINED_RECORDING, - }, - BotEventTypes.MEETING_ENDED: { - "from": [ - BotStates.JOINED_RECORDING, - BotStates.JOINED_NOT_RECORDING, - BotStates.WAITING_ROOM, - BotStates.JOINING, - BotStates.LEAVING, - ], - "to": BotStates.POST_PROCESSING, - }, - BotEventTypes.LEAVE_REQUESTED: { - "from": [ - BotStates.JOINED_RECORDING, - BotStates.JOINED_NOT_RECORDING, - BotStates.WAITING_ROOM, - BotStates.JOINING, - ], - "to": BotStates.LEAVING, - }, - BotEventTypes.BOT_LEFT_MEETING: { - "from": BotStates.LEAVING, - "to": BotStates.POST_PROCESSING, - }, - BotEventTypes.POST_PROCESSING_COMPLETED: { - "from": BotStates.POST_PROCESSING, - "to": BotStates.ENDED, - }, - } - - @classmethod - def set_requested_bot_action_taken_at(cls, bot: Bot): - event_type = { - BotStates.JOINING: BotEventTypes.JOIN_REQUESTED, - BotStates.LEAVING: BotEventTypes.LEAVE_REQUESTED, - }[bot.state] - - if event_type is None: - raise ValueError(f"Bot {bot.object_id} is in state {bot.state}. This is not a valid state to initiate a bot request.") - - last_bot_event = bot.last_bot_event() - - if last_bot_event is None: - raise ValueError(f"Bot {bot.object_id} has no bot events. This is not a valid state to initiate a bot request.") - - if last_bot_event.event_type != event_type: - raise ValueError(f"Bot {bot.object_id} has unexpected event type {last_bot_event.event_type}. We expected {event_type} since it's in state {bot.state}") - - if last_bot_event.requested_bot_action_taken_at is not None: - raise ValueError(f"Bot {bot.object_id} has already initiated this bot request") - - last_bot_event.requested_bot_action_taken_at = timezone.now() - last_bot_event.save() - - @classmethod - def is_state_that_can_play_media(cls, state: int): - return state == BotStates.JOINED_RECORDING or state == BotStates.JOINED_NOT_RECORDING - - @classmethod - def is_terminal_state(cls, state: int): - return state in cls.TERMINAL_STATES - - @classmethod - def get_terminal_states_q_filter(cls): - """Returns a Q object to filter for terminal states""" - q_filter = models.Q() - for state in cls.TERMINAL_STATES: - q_filter |= models.Q(state=state) - return q_filter - - @classmethod - def create_event( - cls, - bot: Bot, - event_type: int, - event_sub_type: int = None, - event_metadata: dict = None, - max_retries: int = 3, - ) -> BotEvent: - """ - Creates a new event and updates the bot state, handling concurrency issues. - - Args: - bot: The Bot instance - event_type: The type of event (from BotEventTypes) - event_sub_type: Optional sub-type of the event - event_metadata: Optional metadata dictionary (defaults to empty dict) - max_retries: Maximum number of retries for concurrent modifications - - Returns: - BotEvent instance - - Raises: - ValidationError: If the state transition is not valid - """ - if event_metadata is None: - event_metadata = {} - retry_count = 0 - - while retry_count < max_retries: - try: - with transaction.atomic(): - # Get fresh bot state - bot.refresh_from_db() - old_state = bot.state - - # Get valid transition for this event type - transition = cls.VALID_TRANSITIONS.get(event_type) - if not transition: - raise ValidationError(f"No valid transitions defined for event type {event_type}") - - # Check if current state is valid for this transition - valid_from_states = transition["from"] - if not isinstance(valid_from_states, (list, tuple)): - valid_from_states = [valid_from_states] - - if old_state not in valid_from_states: - valid_states_labels = [BotStates.state_to_api_code(state) for state in valid_from_states] - raise ValidationError(f"Event {BotEventTypes.type_to_api_code(event_type)} not allowed when bot is in state {BotStates.state_to_api_code(old_state)}. It is only allowed in these states: {', '.join(valid_states_labels)}") - - # Update bot state based on 'to' definition - new_state = transition["to"] - bot.state = new_state - - bot.save() # This will raise RecordModifiedError if version mismatch - - # There's a chance that some other thread in the same process will modify the bot state to be something other than new_state. This should never happen, but we - # should raise an exception if it does. - if bot.state != new_state: - raise ValidationError(f"Bot state was modified by another thread to be '{BotStates.state_to_api_code(bot.state)}' instead of '{BotStates.state_to_api_code(new_state)}'.") - - # Create event record - event = BotEvent.objects.create( - bot=bot, - old_state=old_state, - new_state=bot.state, - event_type=event_type, - event_sub_type=event_sub_type, - metadata=event_metadata, - ) - - # If we moved to the recording state - if new_state == BotStates.JOINED_RECORDING: - pending_recordings = bot.recordings.filter(state=RecordingStates.NOT_STARTED) - if pending_recordings.count() != 1: - raise ValidationError(f"Expected exactly one pending recording for bot {bot.object_id} in state {BotStates.state_to_api_code(new_state)}, but found {pending_recordings.count()}") - pending_recording = pending_recordings.first() - RecordingManager.set_recording_in_progress(pending_recording) - - # If we're in a terminal state - if cls.is_terminal_state(new_state): - # If there is an in progress recording, set it to complete - in_progress_recordings = bot.recordings.filter(state=RecordingStates.IN_PROGRESS) - if in_progress_recordings.count() > 1: - raise ValidationError(f"Expected at most one in progress recording for bot {bot.object_id} in state {BotStates.state_to_api_code(new_state)}, but found {in_progress_recordings.count()}") - for recording in in_progress_recordings: - RecordingManager.set_recording_complete(recording) - - # At this point, we'll want to create a final charge for the current heartbeat - - return event - - except RecordModifiedError: - retry_count += 1 - if retry_count >= max_retries: - raise - continue - - -class Participant(models.Model): - bot = models.ForeignKey(Bot, on_delete=models.CASCADE, related_name="participants") - uuid = models.CharField(max_length=255) - user_uuid = models.CharField(max_length=255, null=True, blank=True) - full_name = models.CharField(max_length=255, null=True, blank=True) - email = models.EmailField(null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [models.UniqueConstraint(fields=["bot", "uuid"], name="unique_participant_per_bot")] - - def __str__(self): - display_name = self.full_name or self.uuid - return f"{display_name} in {self.bot.object_id}" - - -class RecordingStates(models.IntegerChoices): - NOT_STARTED = 1, "Not Started" - IN_PROGRESS = 2, "In Progress" - COMPLETE = 3, "Complete" - FAILED = 4, "Failed" - - @classmethod - def state_to_api_code(cls, value): - """Returns the API code for a given state value""" - mapping = { - cls.NOT_STARTED: "not_started", - cls.IN_PROGRESS: "in_progress", - cls.COMPLETE: "complete", - cls.FAILED: "failed", - } - return mapping.get(value) - - -class RecordingTranscriptionStates(models.IntegerChoices): - NOT_STARTED = 1, "Not Started" - IN_PROGRESS = 2, "In Progress" - COMPLETE = 3, "Complete" - FAILED = 4, "Failed" - - @classmethod - def state_to_api_code(cls, value): - """Returns the API code for a given state value""" - mapping = { - cls.NOT_STARTED: "not_started", - cls.IN_PROGRESS: "in_progress", - cls.COMPLETE: "complete", - cls.FAILED: "failed", - } - return mapping.get(value) - - -class RecordingTypes(models.IntegerChoices): - AUDIO_AND_VIDEO = 1, "Audio and Video" - AUDIO_ONLY = 2, "Audio Only" - - -class TranscriptionTypes(models.IntegerChoices): - NON_REALTIME = 1, "Non realtime" - REALTIME = 2, "Realtime" - NO_TRANSCRIPTION = 3, "No Transcription" - - -class TranscriptionProviders(models.IntegerChoices): - DEEPGRAM = 1, "Deepgram" - - -from storages.backends.s3boto3 import S3Boto3Storage - - -class RecordingStorage(S3Boto3Storage): - bucket_name = settings.AWS_RECORDING_STORAGE_BUCKET_NAME - - -class Recording(models.Model): - bot = models.ForeignKey(Bot, on_delete=models.CASCADE, related_name="recordings") - - recording_type = models.IntegerField(choices=RecordingTypes.choices, null=False) - - transcription_type = models.IntegerField(choices=TranscriptionTypes.choices, null=False) - - is_default_recording = models.BooleanField(default=False) - - state = models.IntegerField(choices=RecordingStates.choices, default=RecordingStates.NOT_STARTED, null=False) - - transcription_state = models.IntegerField( - choices=RecordingTranscriptionStates.choices, - default=RecordingTranscriptionStates.NOT_STARTED, - null=False, - ) - - transcription_provider = models.IntegerField(choices=TranscriptionProviders.choices, null=True, blank=True) - - version = IntegerVersionField() - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - started_at = models.DateTimeField(null=True, blank=True) - completed_at = models.DateTimeField(null=True, blank=True) - first_buffer_timestamp_ms = models.BigIntegerField(null=True, blank=True) - - file = models.FileField(storage=RecordingStorage()) - - def __str__(self): - return f"Recording for {self.bot.object_id}" - - @property - def url(self): - if not self.file.name: - return None - # Generate a temporary signed URL that expires in 30 minutes (1800 seconds) - return self.file.storage.bucket.meta.client.generate_presigned_url( - "get_object", - Params={"Bucket": self.file.storage.bucket_name, "Key": self.file.name}, - ExpiresIn=1800, - ) - - OBJECT_ID_PREFIX = "rec_" - object_id = models.CharField(max_length=32, unique=True, editable=False) - - def save(self, *args, **kwargs): - if not self.object_id: - # Generate a random 16-character string - random_string = "".join(random.choices(string.ascii_letters + string.digits, k=16)) - self.object_id = f"{self.OBJECT_ID_PREFIX}{random_string}" - super().save(*args, **kwargs) - - -class RecordingManager: - @classmethod - def set_recording_in_progress(cls, recording: Recording): - recording.refresh_from_db() - - if recording.state == RecordingStates.IN_PROGRESS: - return - if recording.state != RecordingStates.NOT_STARTED: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in state {recording.get_state_display()}") - - recording.state = RecordingStates.IN_PROGRESS - recording.started_at = timezone.now() - recording.save() - - @classmethod - def set_recording_complete(cls, recording: Recording): - recording.refresh_from_db() - - if recording.state == RecordingStates.COMPLETE: - return - if recording.state != RecordingStates.IN_PROGRESS: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in state {recording.get_state_display()}") - - recording.state = RecordingStates.COMPLETE - recording.completed_at = timezone.now() - recording.save() - - # If there is an in progress transcription recording - # that has no utterances left to transcribe, set it to complete - if recording.transcription_state == RecordingTranscriptionStates.IN_PROGRESS and Utterance.objects.filter(recording=recording, transcription__isnull=True).count() == 0: - RecordingManager.set_recording_transcription_complete(recording) - - @classmethod - def set_recording_failed(cls, recording: Recording): - recording.refresh_from_db() - - if recording.state == RecordingStates.FAILED: - return - if recording.state != RecordingStates.IN_PROGRESS: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in state {recording.get_state_display()}") - - # todo: ADD REASON WHY IT FAILED STORAGE? OR MAYBE PUT IN THE EVENTs? - - recording.state = RecordingStates.FAILED - recording.save() - - @classmethod - def set_recording_transcription_in_progress(cls, recording: Recording): - recording.refresh_from_db() - - if recording.transcription_state == RecordingTranscriptionStates.IN_PROGRESS: - return - if recording.transcription_state != RecordingTranscriptionStates.NOT_STARTED: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in transcription state {recording.get_transcription_state_display()}") - if recording.state != RecordingStates.COMPLETE and recording.state != RecordingStates.FAILED and recording.state != RecordingStates.IN_PROGRESS: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in recording state {recording.get_state_display()}") - - recording.transcription_state = RecordingTranscriptionStates.IN_PROGRESS - recording.save() - - @classmethod - def set_recording_transcription_complete(cls, recording: Recording): - recording.refresh_from_db() - - if recording.transcription_state == RecordingTranscriptionStates.COMPLETE: - return - if recording.transcription_state != RecordingTranscriptionStates.IN_PROGRESS: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in transcription state {recording.get_transcription_state_display()}") - if recording.state != RecordingStates.COMPLETE and recording.state != RecordingStates.FAILED: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in recording state {recording.get_state_display()}") - - recording.transcription_state = RecordingTranscriptionStates.COMPLETE - recording.save() - - @classmethod - def set_recording_transcription_failed(cls, recording: Recording): - recording.refresh_from_db() - - if recording.transcription_state == RecordingTranscriptionStates.FAILED: - return - if recording.transcription_state != RecordingTranscriptionStates.IN_PROGRESS: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in transcription state {recording.get_transcription_state_display()}") - if recording.state != RecordingStates.COMPLETE and recording.state != RecordingStates.FAILED and recording.state != RecordingStates.IN_PROGRESS: - raise ValueError(f"Invalid state transition. Recording {recording.id} is in recording state {recording.get_state_display()}") - - # todo: ADD REASON WHY IT FAILED STORAGE? OR MAYBE PUT IN THE EVENTs? - recording.transcription_state = RecordingTranscriptionStates.FAILED - recording.save() - - @classmethod - def is_terminal_state(cls, state: int): - return state == RecordingStates.COMPLETE or state == RecordingStates.FAILED - - -class Utterance(models.Model): - class Sources(models.IntegerChoices): - PER_PARTICIPANT_AUDIO = 1, "Per Participant Audio" - CLOSED_CAPTION_FROM_PLATFORM = 2, "Closed Caption From Platform" - - class AudioFormat(models.IntegerChoices): - PCM = 1, "PCM" - MP3 = 2, "MP3" - - recording = models.ForeignKey(Recording, on_delete=models.CASCADE, related_name="utterances") - participant = models.ForeignKey(Participant, on_delete=models.PROTECT, related_name="utterances") - audio_blob = models.BinaryField() - audio_format = models.IntegerField(choices=AudioFormat.choices, default=AudioFormat.PCM, null=True) - timestamp_ms = models.BigIntegerField() - duration_ms = models.IntegerField() - transcription = models.JSONField(null=True, default=None) - source_uuid = models.CharField(max_length=255, null=True, unique=True) - sample_rate = models.IntegerField(null=True, default=None) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - source = models.IntegerField(choices=Sources.choices, default=Sources.PER_PARTICIPANT_AUDIO, null=False) - - def __str__(self): - return f"Utterance at {self.timestamp_ms}ms ({self.duration_ms}ms long)" - - -class Credentials(models.Model): - class CredentialTypes(models.IntegerChoices): - DEEPGRAM = 1, "Deepgram" - ZOOM_OAUTH = 2, "Zoom OAuth" - GOOGLE_TTS = 3, "Google Text To Speech" - - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="credentials") - credential_type = models.IntegerField(choices=CredentialTypes.choices, null=False) - - _encrypted_data = models.BinaryField( - null=True, - editable=False, # Prevents editing through admin/forms - ) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - constraints = [models.UniqueConstraint(fields=["project", "credential_type"], name="unique_project_credentials")] - - def set_credentials(self, credentials_dict): - """Encrypt and save credentials""" - f = Fernet(settings.CREDENTIALS_ENCRYPTION_KEY) - json_data = json.dumps(credentials_dict) - self._encrypted_data = f.encrypt(json_data.encode()) - self.save() - - def get_credentials(self): - """Decrypt and return credentials""" - if not self._encrypted_data: - return None - f = Fernet(settings.CREDENTIALS_ENCRYPTION_KEY) - decrypted_data = f.decrypt(bytes(self._encrypted_data)) - return json.loads(decrypted_data.decode()) - - def __str__(self): - return f"{self.project.name} - {self.get_credential_type_display()}" - - -class MediaBlob(models.Model): - VALID_AUDIO_CONTENT_TYPES = [ - ("audio/mp3", "MP3 Audio"), - ] - VALID_VIDEO_CONTENT_TYPES = [] - VALID_IMAGE_CONTENT_TYPES = [ - ("image/png", "PNG Image"), - ] - - OBJECT_ID_PREFIX = "blob_" - object_id = models.CharField(max_length=32, unique=True, editable=False) - - project = models.ForeignKey(Project, on_delete=models.PROTECT, related_name="media_blobs") - - blob = models.BinaryField() - content_type = models.CharField( - max_length=255, - choices=VALID_AUDIO_CONTENT_TYPES + VALID_VIDEO_CONTENT_TYPES + VALID_IMAGE_CONTENT_TYPES, - ) - checksum = models.CharField(max_length=64, editable=False) # SHA-256 hash is 64 chars - created_at = models.DateTimeField(auto_now_add=True) - duration_ms = models.IntegerField() - - def save(self, *args, **kwargs): - if not self.object_id: - # Generate a random 16-character string - random_string = "".join(random.choices(string.ascii_letters + string.digits, k=16)) - self.object_id = f"{self.OBJECT_ID_PREFIX}{random_string}" - - if len(self.blob) > 10485760: - raise ValueError("blob exceeds 10MB limit") - - # Calculate checksum if this is a new object - if not self.checksum: - self.checksum = hashlib.sha256(self.blob).hexdigest() - - # Calculate duration for audio content types - if any(content_type == self.content_type for content_type, _ in self.VALID_AUDIO_CONTENT_TYPES): - from .utils import calculate_audio_duration_ms - - self.duration_ms = calculate_audio_duration_ms(self.blob, self.content_type) - - if any(content_type == self.content_type for content_type, _ in self.VALID_IMAGE_CONTENT_TYPES): - self.duration_ms = 0 - - if self.id: - raise ValueError("MediaBlob objects cannot be updated") - - super().save(*args, **kwargs) - - class Meta: - # Ensure we don't store duplicate blobs within a project - constraints = [models.UniqueConstraint(fields=["project", "checksum"], name="unique_project_blob")] - - def __str__(self): - return f"{self.object_id} ({len(self.blob)} bytes)" - - @classmethod - def get_or_create_from_blob(cls, project: Project, blob: bytes, content_type: str) -> "MediaBlob": - checksum = hashlib.sha256(blob).hexdigest() - - existing = cls.objects.filter(project=project, checksum=checksum).first() - - if existing: - return existing - - return cls.objects.create(project=project, blob=blob, content_type=content_type) - - -class TextToSpeechProviders(models.IntegerChoices): - GOOGLE = 1, "Google" - - -class BotMediaRequestMediaTypes(models.IntegerChoices): - IMAGE = 1, "Image" - AUDIO = 2, "Audio" - - -class BotMediaRequestStates(models.IntegerChoices): - ENQUEUED = 1, "Enqueued" - PLAYING = 2, "Playing" - DROPPED = 3, "Dropped" - FINISHED = 4, "Finished" - FAILED_TO_PLAY = 5, "Failed to Play" - - @classmethod - def state_to_api_code(cls, value): - """Returns the API code for a given state value""" - mapping = { - cls.ENQUEUED: "enqueued", - cls.PLAYING: "playing", - cls.DROPPED: "dropped", - cls.FINISHED: "finished", - cls.FAILED_TO_PLAY: "failed_to_play", - } - return mapping.get(value) - - -class BotMediaRequest(models.Model): - bot = models.ForeignKey(Bot, on_delete=models.CASCADE, related_name="media_requests") - - text_to_speak = models.TextField(null=True, blank=True) - - text_to_speech_settings = models.JSONField(null=True, default=None) - - media_blob = models.ForeignKey( - MediaBlob, - on_delete=models.PROTECT, - related_name="bot_media_requests", - null=True, - blank=True, - ) - - media_type = models.IntegerField(choices=BotMediaRequestMediaTypes.choices, null=False) - - state = models.IntegerField( - choices=BotMediaRequestStates.choices, - default=BotMediaRequestStates.ENQUEUED, - null=False, - ) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - @property - def duration_ms(self): - return self.media_blob.duration_ms - - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["bot", "media_type"], - condition=Q(state=BotMediaRequestStates.PLAYING), - name="unique_playing_media_request_per_bot_and_type", - ) - ] - - -class BotMediaRequestManager: - @classmethod - def set_media_request_playing(cls, media_request: BotMediaRequest): - if media_request.state == BotMediaRequestStates.PLAYING: - return - if media_request.state != BotMediaRequestStates.ENQUEUED: - raise ValueError(f"Invalid state transition. Media request {media_request.id} is in state {media_request.get_state_display()}") - - media_request.state = BotMediaRequestStates.PLAYING - media_request.save() - - @classmethod - def set_media_request_finished(cls, media_request: BotMediaRequest): - if media_request.state == BotMediaRequestStates.FINISHED: - return - if media_request.state != BotMediaRequestStates.PLAYING: - raise ValueError(f"Invalid state transition. Media request {media_request.id} is in state {media_request.get_state_display()}") - - media_request.state = BotMediaRequestStates.FINISHED - media_request.save() - - @classmethod - def set_media_request_failed_to_play(cls, media_request: BotMediaRequest): - if media_request.state == BotMediaRequestStates.FAILED_TO_PLAY: - return - if media_request.state != BotMediaRequestStates.PLAYING: - raise ValueError(f"Invalid state transition. Media request {media_request.id} is in state {media_request.get_state_display()}") - - media_request.state = BotMediaRequestStates.FAILED_TO_PLAY - media_request.save() - - @classmethod - def set_media_request_dropped(cls, media_request: BotMediaRequest): - if media_request.state == BotMediaRequestStates.DROPPED: - return - if media_request.state != BotMediaRequestStates.PLAYING and media_request.state != BotMediaRequestStates.ENQUEUED: - raise ValueError(f"Invalid state transition. Media request {media_request.id} is in state {media_request.get_state_display()}") - - media_request.state = BotMediaRequestStates.DROPPED - media_request.save() - - -class BotDebugScreenshotStorage(S3Boto3Storage): - bucket_name = settings.AWS_RECORDING_STORAGE_BUCKET_NAME - - -class BotDebugScreenshot(models.Model): - OBJECT_ID_PREFIX = "shot_" - object_id = models.CharField(max_length=32, unique=True, editable=False) - - bot_event = models.ForeignKey(BotEvent, on_delete=models.CASCADE, related_name="debug_screenshots") - - metadata = models.JSONField(null=False, default=dict) - - file = models.FileField(storage=BotDebugScreenshotStorage()) - created_at = models.DateTimeField(auto_now_add=True) - - def save(self, *args, **kwargs): - if not self.object_id: - # Generate a random 16-character string - random_string = "".join(random.choices(string.ascii_letters + string.digits, k=16)) - self.object_id = f"{self.OBJECT_ID_PREFIX}{random_string}" - super().save(*args, **kwargs) - - @property - def url(self): - if not self.file.name: - return None - # Generate a temporary signed URL that expires in 30 minutes (1800 seconds) - return self.file.storage.bucket.meta.client.generate_presigned_url( - "get_object", - Params={"Bucket": self.file.storage.bucket_name, "Key": self.file.name}, - ExpiresIn=1800, - ) - - def __str__(self): - return f"Debug Screenshot {self.object_id} for event {self.bot_event}" diff --git a/attendee/bots/projects_urls.py b/attendee/bots/projects_urls.py deleted file mode 100644 index fdcdb4b..0000000 --- a/attendee/bots/projects_urls.py +++ /dev/null @@ -1,59 +0,0 @@ -from django.urls import path - -from . import projects_views - -app_name = "bots" - -urlpatterns = [ - path( - "", - projects_views.ProjectDashboardView.as_view(), - name="project-dashboard", - ), - path( - "/bots", - projects_views.ProjectBotsView.as_view(), - name="project-bots", - ), - path( - "/bots/", - projects_views.ProjectBotDetailView.as_view(), - name="project-bot-detail", - ), - path( - "/settings", - projects_views.ProjectSettingsView.as_view(), - name="project-settings", - ), - path( - "/keys", - projects_views.ProjectApiKeysView.as_view(), - name="project-api-keys", - ), - path( - "/keys/create/", - projects_views.CreateApiKeyView.as_view(), - name="create-api-key", - ), - path( - "/keys//delete/", - projects_views.DeleteApiKeyView.as_view(), - name="delete-api-key", - ), - path( - "/settings/credentials/", - projects_views.CreateCredentialsView.as_view(), - name="create-credentials", - ), - # Don't put anything after this, it will redirect to the dashboard - path( - "/", - projects_views.RedirectToDashboardView.as_view(), - name="project-unrecognized", - ), - path( - "/", - projects_views.RedirectToDashboardView.as_view(), - name="project-unrecognized", - ), -] diff --git a/attendee/bots/projects_views.py b/attendee/bots/projects_views.py deleted file mode 100644 index b181bd1..0000000 --- a/attendee/bots/projects_views.py +++ /dev/null @@ -1,220 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.db import models -from django.http import HttpResponse -from django.shortcuts import get_object_or_404, redirect, render -from django.views import View - -from .models import ( - ApiKey, - Bot, - BotStates, - Credentials, - Project, - RecordingStates, - Utterance, -) -from .utils import generate_recordings_json_for_bot_detail_view - - -class ProjectUrlContextMixin: - def get_project_context(self, object_id, project): - return { - "project": project, - } - - -class ProjectDashboardView(LoginRequiredMixin, ProjectUrlContextMixin, View): - def get(self, request, object_id): - try: - project = get_object_or_404(Project, object_id=object_id, organization=request.user.organization) - except: - return redirect("/") - - # Quick start guide status checks - zoom_credentials = Credentials.objects.filter(project=project, credential_type=Credentials.CredentialTypes.ZOOM_OAUTH).exists() - - deepgram_credentials = Credentials.objects.filter(project=project, credential_type=Credentials.CredentialTypes.DEEPGRAM).exists() - - has_api_keys = ApiKey.objects.filter(project=project).exists() - - has_ended_bots = Bot.objects.filter(project=project, state=BotStates.ENDED).exists() - - context = self.get_project_context(object_id, project) - context.update( - { - "quick_start": { - "has_credentials": zoom_credentials and deepgram_credentials, - "has_api_keys": has_api_keys, - "has_ended_bots": has_ended_bots, - } - } - ) - - return render(request, "projects/project_dashboard.html", context) - - -class ProjectApiKeysView(LoginRequiredMixin, ProjectUrlContextMixin, View): - def get(self, request, object_id): - project = get_object_or_404(Project, object_id=object_id, organization=request.user.organization) - context = self.get_project_context(object_id, project) - context["api_keys"] = ApiKey.objects.filter(project=project).order_by("-created_at") - return render(request, "projects/project_api_keys.html", context) - - -class CreateApiKeyView(LoginRequiredMixin, View): - def post(self, request, object_id): - project = get_object_or_404(Project, object_id=object_id, organization=request.user.organization) - name = request.POST.get("name") - - if not name: - return HttpResponse("Name is required", status=400) - - api_key_instance, api_key = ApiKey.create(project=project, name=name) - - # Render the success modal content - return render( - request, - "projects/partials/api_key_created_modal.html", - {"api_key": api_key, "name": name}, - ) - - -class DeleteApiKeyView(LoginRequiredMixin, ProjectUrlContextMixin, View): - def delete(self, request, object_id, key_object_id): - api_key = get_object_or_404( - ApiKey, - object_id=key_object_id, - project__organization=request.user.organization, - ) - api_key.delete() - context = self.get_project_context(object_id, api_key.project) - context["api_keys"] = ApiKey.objects.filter(project=api_key.project).order_by("-created_at") - return render(request, "projects/project_api_keys.html", context) - - -class RedirectToDashboardView(LoginRequiredMixin, View): - def get(self, request, object_id, extra=None): - return redirect("bots:project-dashboard", object_id=object_id) - - -class CreateCredentialsView(LoginRequiredMixin, ProjectUrlContextMixin, View): - def post(self, request, object_id): - project = get_object_or_404(Project, object_id=object_id, organization=request.user.organization) - - try: - credential_type = int(request.POST.get("credential_type")) - if credential_type not in [choice[0] for choice in Credentials.CredentialTypes.choices]: - return HttpResponse("Invalid credential type", status=400) - - # Get or create the credential instance - credential, created = Credentials.objects.get_or_create(project=project, credential_type=credential_type) - - # Parse the credentials data based on type - if credential_type == Credentials.CredentialTypes.ZOOM_OAUTH: - credentials_data = { - "client_id": request.POST.get("client_id"), - "client_secret": request.POST.get("client_secret"), - } - - if not all(credentials_data.values()): - return HttpResponse("Missing required credentials data", status=400) - - elif credential_type == Credentials.CredentialTypes.DEEPGRAM: - credentials_data = {"api_key": request.POST.get("api_key")} - - if not all(credentials_data.values()): - return HttpResponse("Missing required credentials data", status=400) - elif credential_type == Credentials.CredentialTypes.GOOGLE_TTS: - credentials_data = {"service_account_json": request.POST.get("service_account_json")} - - if not all(credentials_data.values()): - return HttpResponse("Missing required credentials data", status=400) - else: - return HttpResponse("Unsupported credential type", status=400) - - # Store the encrypted credentials - credential.set_credentials(credentials_data) - - # Return the entire settings page with updated context - context = self.get_project_context(object_id, project) - context["credentials"] = credential.get_credentials() - context["credential_type"] = credential.credential_type - if credential.credential_type == Credentials.CredentialTypes.ZOOM_OAUTH: - return render(request, "projects/partials/zoom_credentials.html", context) - elif credential.credential_type == Credentials.CredentialTypes.DEEPGRAM: - return render(request, "projects/partials/deepgram_credentials.html", context) - elif credential.credential_type == Credentials.CredentialTypes.GOOGLE_TTS: - return render(request, "projects/partials/google_tts_credentials.html", context) - else: - return HttpResponse("Cannot render the partial for this credential type", status=400) - - except Exception as e: - return HttpResponse(str(e), status=400) - - -class ProjectSettingsView(LoginRequiredMixin, ProjectUrlContextMixin, View): - def get(self, request, object_id): - project = get_object_or_404(Project, object_id=object_id, organization=request.user.organization) - - # Try to get existing credentials - zoom_credentials = Credentials.objects.filter(project=project, credential_type=Credentials.CredentialTypes.ZOOM_OAUTH).first() - - deepgram_credentials = Credentials.objects.filter(project=project, credential_type=Credentials.CredentialTypes.DEEPGRAM).first() - - google_tts_credentials = Credentials.objects.filter(project=project, credential_type=Credentials.CredentialTypes.GOOGLE_TTS).first() - - context = self.get_project_context(object_id, project) - context.update( - { - "zoom_credentials": zoom_credentials.get_credentials() if zoom_credentials else None, - "zoom_credential_type": Credentials.CredentialTypes.ZOOM_OAUTH, - "deepgram_credentials": deepgram_credentials.get_credentials() if deepgram_credentials else None, - "deepgram_credential_type": Credentials.CredentialTypes.DEEPGRAM, - "google_tts_credentials": google_tts_credentials.get_credentials() if google_tts_credentials else None, - "google_tts_credential_type": Credentials.CredentialTypes.GOOGLE_TTS, - } - ) - - return render(request, "projects/project_settings.html", context) - - -class ProjectBotsView(LoginRequiredMixin, ProjectUrlContextMixin, View): - def get(self, request, object_id): - project = get_object_or_404(Project, object_id=object_id, organization=request.user.organization) - - bots = Bot.objects.filter(project=project).order_by("-created_at") - - context = self.get_project_context(object_id, project) - context.update( - { - "bots": bots, - "BotStates": BotStates, - } - ) - - return render(request, "projects/project_bots.html", context) - - -class ProjectBotDetailView(LoginRequiredMixin, ProjectUrlContextMixin, View): - def get(self, request, object_id, bot_object_id): - project = get_object_or_404(Project, object_id=object_id, organization=request.user.organization) - - bot = get_object_or_404(Bot, object_id=bot_object_id, project=project) - - # Prefetch recordings with their utterances and participants - bot.recordings.all().prefetch_related(models.Prefetch("utterances", queryset=Utterance.objects.select_related("participant"))) - - # Prefetch bot events with their debug screenshots - bot.bot_events.prefetch_related("debug_screenshots") - - context = self.get_project_context(object_id, project) - context.update( - { - "bot": bot, - "BotStates": BotStates, - "RecordingStates": RecordingStates, - "recordings": generate_recordings_json_for_bot_detail_view(bot), - } - ) - - return render(request, "projects/project_bot_detail.html", context) diff --git a/attendee/bots/serializers.py b/attendee/bots/serializers.py deleted file mode 100644 index a973529..0000000 --- a/attendee/bots/serializers.py +++ /dev/null @@ -1,387 +0,0 @@ -import jsonschema -from drf_spectacular.utils import ( - OpenApiExample, - extend_schema_field, - extend_schema_serializer, -) -from rest_framework import serializers - -from .models import ( - Bot, - BotEventSubTypes, - BotEventTypes, - BotStates, - Recording, - RecordingFormats, - RecordingStates, - RecordingTranscriptionStates, -) -from .utils import meeting_type_from_url - - -@extend_schema_field( - { - "type": "object", - "properties": { - "deepgram": { - "type": "object", - "properties": { - "language": { - "type": "string", - "description": "The language code for transcription (e.g. 'en'). See here for available languages: https://developers.deepgram.com/docs/models-languages-overview", - }, - "detect_language": { - "type": "boolean", - "description": "Whether to automatically detect the spoken language", - }, - }, - } - }, - "required": ["deepgram"], - } -) -class TranscriptionSettingsJSONField(serializers.JSONField): - pass - - -@extend_schema_field( - { - "type": "object", - "properties": { - "destination_url": { - "type": "string", - "description": "The URL of the RTMP server to send the stream to", - }, - "stream_key": { - "type": "string", - "description": "The stream key to use for the RTMP server", - }, - }, - "required": ["destination_url", "stream_key"], - } -) -class RTMPSettingsJSONField(serializers.JSONField): - pass - - -@extend_schema_field( - { - "type": "object", - "properties": { - "format": { - "type": "string", - "description": "The format of the recording to save. The supported formats are 'webm' and 'mp4'.", - }, - }, - "required": ["format"], - } -) -class RecordingSettingsJSONField(serializers.JSONField): - pass - - -@extend_schema_serializer( - examples=[ - OpenApiExample( - "Valid meeting URL", - value={ - "meeting_url": "https://zoom.us/j/123?pwd=456", - "bot_name": "My Bot", - }, - description="Example of a valid Zoom meeting URL", - ) - ] -) -class CreateBotSerializer(serializers.Serializer): - meeting_url = serializers.CharField(help_text="The URL of the meeting to join, e.g. https://zoom.us/j/123?pwd=456") - bot_name = serializers.CharField(help_text="The name of the bot to create, e.g. 'My Bot'") - - transcription_settings = TranscriptionSettingsJSONField( - help_text="The transcription settings for the bot, e.g. {'deepgram': {'language': 'en'}}", - required=False, - default={"deepgram": {"language": "en"}}, - ) - - TRANSCRIPTION_SETTINGS_SCHEMA = { - "type": "object", - "properties": { - "deepgram": { - "type": "object", - "properties": { - "language": { - "type": "string", - }, - "detect_language": {"type": "boolean"}, - }, - "oneOf": [ - {"required": ["language"]}, - {"required": ["detect_language"]}, - ], - "additionalProperties": False, - } - }, - "required": ["deepgram"], - "additionalProperties": False, - } - - def validate_meeting_url(self, value): - meeting_type = meeting_type_from_url(value) - if meeting_type is None: - raise serializers.ValidationError({"meeting_url": "Invalid meeting URL"}) - - return value - - def validate_transcription_settings(self, value): - if value is None: - return value - - try: - jsonschema.validate(instance=value, schema=self.TRANSCRIPTION_SETTINGS_SCHEMA) - except jsonschema.exceptions.ValidationError as e: - raise serializers.ValidationError(e.message) - - return value - - rtmp_settings = RTMPSettingsJSONField( - help_text="RTMP server to stream to, e.g. {'destination_url': 'rtmp://global-live.mux.com:5222/app', 'stream_key': 'xxxx'}.", - required=False, - default=None, - ) - - RTMP_SETTINGS_SCHEMA = { - "type": "object", - "properties": { - "destination_url": {"type": "string"}, - "stream_key": {"type": "string"}, - }, - "required": ["destination_url", "stream_key"], - } - - def validate_rtmp_settings(self, value): - if value is None: - return value - - try: - jsonschema.validate(instance=value, schema=self.RTMP_SETTINGS_SCHEMA) - except jsonschema.exceptions.ValidationError as e: - raise serializers.ValidationError(e.message) - - # Validate RTMP URL format - destination_url = value.get("destination_url", "") - if not (destination_url.lower().startswith("rtmp://") or destination_url.lower().startswith("rtmps://")): - raise serializers.ValidationError({"destination_url": "URL must start with rtmp:// or rtmps://"}) - - return value - - recording_settings = RecordingSettingsJSONField( - help_text="The settings for the bot's recording. Either {'format': 'webm'} or {'format': 'mp4'}.", - required=False, - default={"format": RecordingFormats.WEBM}, - ) - - RECORDING_SETTINGS_SCHEMA = { - "type": "object", - "properties": { - "format": {"type": "string"}, - }, - "required": ["format"], - } - - def validate_recording_settings(self, value): - if value is None: - return value - - try: - jsonschema.validate(instance=value, schema=self.RECORDING_SETTINGS_SCHEMA) - except jsonschema.exceptions.ValidationError as e: - raise serializers.ValidationError(e.message) - - # Validate format - format = value.get("format", "") - if format not in [RecordingFormats.MP4, RecordingFormats.WEBM]: - raise serializers.ValidationError({"format": "Format must be mp4 or webm"}) - - return value - - -class BotSerializer(serializers.ModelSerializer): - id = serializers.CharField(source="object_id") - state = serializers.SerializerMethodField() - events = serializers.SerializerMethodField() - transcription_state = serializers.SerializerMethodField() - recording_state = serializers.SerializerMethodField() - - @extend_schema_field( - { - "type": "string", - "enum": [BotStates.state_to_api_code(state.value) for state in BotStates], - } - ) - def get_state(self, obj): - return BotStates.state_to_api_code(obj.state) - - @extend_schema_field( - { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": {"type": "string"}, - "sub_type": {"type": "string", "nullable": True}, - "created_at": {"type": "string", "format": "date-time"}, - }, - }, - } - ) - def get_events(self, obj): - events = [] - for event in obj.bot_events.all(): - event_type = BotEventTypes.type_to_api_code(event.event_type) - event_data = {"type": event_type, "created_at": event.created_at} - - if event.event_sub_type: - event_data["sub_type"] = BotEventSubTypes.sub_type_to_api_code(event.event_sub_type) - - events.append(event_data) - return events - - @extend_schema_field( - { - "type": "string", - "enum": [RecordingTranscriptionStates.state_to_api_code(state.value) for state in RecordingTranscriptionStates], - } - ) - def get_transcription_state(self, obj): - default_recording = Recording.objects.filter(bot=obj, is_default_recording=True).first() - if not default_recording: - return None - - return RecordingTranscriptionStates.state_to_api_code(default_recording.transcription_state) - - @extend_schema_field( - { - "type": "string", - "enum": [RecordingStates.state_to_api_code(state.value) for state in RecordingStates], - } - ) - def get_recording_state(self, obj): - default_recording = Recording.objects.filter(bot=obj, is_default_recording=True).first() - if not default_recording: - return None - - return RecordingStates.state_to_api_code(default_recording.state) - - class Meta: - model = Bot - fields = [ - "id", - "meeting_url", - "state", - "events", - "transcription_state", - "recording_state", - ] - read_only_fields = fields - - -class TranscriptUtteranceSerializer(serializers.Serializer): - speaker_name = serializers.CharField() - speaker_uuid = serializers.CharField() - speaker_user_uuid = serializers.CharField(allow_null=True) - timestamp_ms = serializers.IntegerField() - duration_ms = serializers.IntegerField() - transcription = serializers.JSONField() - - -@extend_schema_serializer( - examples=[ - OpenApiExample( - "Recording Upload", - value={ - "url": "https://attendee-short-term-storage-production.s3.amazonaws.com/e4da3b7fbbce2345d7772b0674a318d5.mp4?...", - "start_timestamp_ms": 1733114771000, - }, - ) - ] -) -class RecordingSerializer(serializers.ModelSerializer): - start_timestamp_ms = serializers.IntegerField(source="first_buffer_timestamp_ms") - - class Meta: - model = Recording - fields = ["url", "start_timestamp_ms"] - - -@extend_schema_field( - { - "type": "object", - "properties": { - "google": { - "type": "object", - "properties": { - "voice_language_code": { - "type": "string", - "description": "The voice language code (e.g. 'en-US'). See https://cloud.google.com/text-to-speech/docs/voices for a list of available language codes and voices.", - }, - "voice_name": { - "type": "string", - "description": "The name of the voice to use (e.g. 'en-US-Casual-K')", - }, - }, - } - }, - "required": ["google"], - } -) -class TextToSpeechSettingsJSONField(serializers.JSONField): - pass - - -@extend_schema_serializer( - examples=[ - OpenApiExample( - "Valid speech request", - value={ - "text": "Hello, this is a bot speaking text.", - "text_to_speech_settings": { - "google": { - "voice_language_code": "en-US", - "voice_name": "en-US-Casual-K", - } - }, - }, - description="Example of a valid speech request", - ) - ] -) -class SpeechSerializer(serializers.Serializer): - text = serializers.CharField() - text_to_speech_settings = TextToSpeechSettingsJSONField() - - TEXT_TO_SPEECH_SETTINGS_SCHEMA = { - "type": "object", - "properties": { - "google": { - "type": "object", - "properties": { - "voice_language_code": {"type": "string"}, - "voice_name": {"type": "string"}, - }, - "required": ["voice_language_code", "voice_name"], - "additionalProperties": False, - } - }, - "required": ["google"], - "additionalProperties": False, - } - - def validate_text_to_speech_settings(self, value): - if value is None: - return None - - try: - jsonschema.validate(instance=value, schema=self.TEXT_TO_SPEECH_SETTINGS_SCHEMA) - except jsonschema.exceptions.ValidationError as e: - raise serializers.ValidationError(e.message) - - return value diff --git a/attendee/bots/tasks/__init__.py b/attendee/bots/tasks/__init__.py deleted file mode 100644 index 1b18dd6..0000000 --- a/attendee/bots/tasks/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .process_utterance_task import process_utterance -from .run_bot_task import run_bot - -# Expose the tasks and any necessary utilities at the module level -__all__ = [ - "process_utterance", - "run_bot", -] diff --git a/attendee/bots/tasks/process_utterance_task.py b/attendee/bots/tasks/process_utterance_task.py deleted file mode 100644 index 21ba724..0000000 --- a/attendee/bots/tasks/process_utterance_task.py +++ /dev/null @@ -1,64 +0,0 @@ -import logging - -from celery import shared_task -from django.db import DatabaseError - -logger = logging.getLogger(__name__) - -from bots.models import Credentials, RecordingManager, Utterance - - -@shared_task( - bind=True, - soft_time_limit=3600, - autoretry_for=(DatabaseError,), - retry_backoff=True, # Enable exponential backoff - max_retries=5, -) -def process_utterance(self, utterance_id): - import json - - from deepgram import ( - DeepgramClient, - FileSource, - PrerecordedOptions, - ) - - utterance = Utterance.objects.get(id=utterance_id) - logger.info(f"Processing utterance {utterance_id}") - - recording = utterance.recording - RecordingManager.set_recording_transcription_in_progress(recording) - - if utterance.transcription is None: - payload: FileSource = { - "buffer": utterance.audio_blob.tobytes(), - } - - options = PrerecordedOptions( - model="nova-3", - smart_format=True, - language=recording.bot.deepgram_language(), - detect_language=recording.bot.deepgram_detect_language(), - encoding="linear16", # for 16-bit PCM - sample_rate=utterance.sample_rate, - ) - - deepgram_credentials_record = recording.bot.project.credentials.filter(credential_type=Credentials.CredentialTypes.DEEPGRAM).first() - if not deepgram_credentials_record: - raise Exception("Deepgram credentials record not found") - - deepgram_credentials = deepgram_credentials_record.get_credentials() - if not deepgram_credentials: - raise Exception("Deepgram credentials not found") - - deepgram = DeepgramClient(deepgram_credentials["api_key"]) - - response = deepgram.listen.rest.v("1").transcribe_file(payload, options) - utterance.transcription = json.loads(response.results.channels[0].alternatives[0].to_json()) - utterance.audio_blob = b"" # set the binary field to empty byte string - utterance.save() - - # If the recording is in a terminal state and there are no more utterances to transcribe, set the recording's transcription state to complete - if RecordingManager.is_terminal_state(utterance.recording.state) and Utterance.objects.filter(recording=utterance.recording, transcription__isnull=True).count() == 0: - RecordingManager.set_recording_transcription_complete(utterance.recording) diff --git a/attendee/bots/tasks/run_bot_task.py b/attendee/bots/tasks/run_bot_task.py deleted file mode 100644 index 183cbdc..0000000 --- a/attendee/bots/tasks/run_bot_task.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging -import os -import signal - -from celery import shared_task -from celery.signals import worker_shutting_down - -from bots.bot_controller import BotController - -logger = logging.getLogger(__name__) - - -@shared_task(bind=True, soft_time_limit=3600) -def run_bot(self, bot_id): - logger.info(f"Running bot {bot_id}") - bot_controller = BotController(bot_id) - bot_controller.run() - - -def kill_child_processes(): - # Get the process group ID (PGID) of the current process - pgid = os.getpgid(os.getpid()) - - try: - # Send SIGTERM to all processes in the process group - os.killpg(pgid, signal.SIGTERM) - except ProcessLookupError: - pass # Process group may no longer exist - - -@worker_shutting_down.connect -def shutting_down_handler(sig, how, exitcode, **kwargs): - # Just adding this code so we can see how to shut down all the tasks - # when the main process is terminated. - # It's likely overkill. - logger.info("Celery worker shutting down, sending SIGTERM to all child processes") - kill_child_processes() diff --git a/attendee/bots/teams_bot_adapter/__init__.py b/attendee/bots/teams_bot_adapter/__init__.py deleted file mode 100644 index a27f4c8..0000000 --- a/attendee/bots/teams_bot_adapter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .teams_bot_adapter import TeamsBotAdapter - -__all__ = ["TeamsBotAdapter"] diff --git a/attendee/bots/teams_bot_adapter/teams_bot_adapter.py b/attendee/bots/teams_bot_adapter/teams_bot_adapter.py deleted file mode 100644 index d2191d2..0000000 --- a/attendee/bots/teams_bot_adapter/teams_bot_adapter.py +++ /dev/null @@ -1,12 +0,0 @@ -from bots.teams_bot_adapter.teams_ui_methods import ( - TeamsUIMethods, -) -from bots.web_bot_adapter import WebBotAdapter - - -class TeamsBotAdapter(WebBotAdapter, TeamsUIMethods): - def get_chromedriver_payload_file_name(self): - return "teams_bot_adapter/teams_chromedriver_payload.js" - - def get_websocket_port(self): - return 8097 diff --git a/attendee/bots/teams_bot_adapter/teams_chromedriver_payload.js b/attendee/bots/teams_bot_adapter/teams_chromedriver_payload.js deleted file mode 100644 index 1addc9e..0000000 --- a/attendee/bots/teams_bot_adapter/teams_chromedriver_payload.js +++ /dev/null @@ -1,1462 +0,0 @@ -class DominantSpeakerManager { - constructor() { - this.dominantSpeakerStreamId = null; - } - - setDominantSpeakerStreamId(dominantSpeakerStreamId) { - this.dominantSpeakerStreamId = dominantSpeakerStreamId.toString(); - } - - getDominantSpeaker() { - return virtualStreamToPhysicalStreamMappingManager.virtualStreamIdToParticipant(this.dominantSpeakerStreamId); - } -} - -// Virtual to Physical Stream Mapping Manager -// Microsoft Teams has virtual streams which are referenced by a sourceId -// An instance of the teams client has a finite number of phyisical streams which are referenced by a streamId -// This class manages the mapping between virtual and physical streams -class VirtualStreamToPhysicalStreamMappingManager { - constructor() { - this.virtualStreams = new Map(); - this.physicalStreamsByClientStreamId = new Map(); - this.physicalStreamsByServerStreamId = new Map(); - - this.physicalClientStreamIdToVirtualStreamIdMapping = {} - this.virtualStreamIdToPhysicalClientStreamIdMapping = {} - } - - getVirtualVideoStreamIdToSend() { - // If there is an active screenshare stream return that stream's virtual id - - // If there is an active dominant speaker video stream return that stream id - - // Otherwise return the first virtual stream id that has an associated physical stream - //realConsole?.log('Object.values(this.virtualStreams)', Object.values(this.virtualStreams)); - //realConsole?.log("STARTFILTER"); - const virtualSteamsThatHavePhysicalStreams = [] - for (const virtualStream of this.virtualStreams.values()) { - const hasCorrespondingPhysicalStream = this.virtualStreamIdToPhysicalClientStreamIdMapping[virtualStream.sourceId]; - const isNotVirtualStreamForBot = !this.physicalClientStreamIdToVirtualStreamIdMapping[virtualStream.sourceId]; - - //realConsole?.log('zzzphysicalClientStreamIds', physicalClientStreamIds); - //realConsole?.log('zzzvirtualStream.sourceId.toString()', virtualStream.sourceId.toString()); - //realConsole?.log('zzzthis.physicalClientStreamIdToVirtualStreamIdMapping', this.physicalClientStreamIdToVirtualStreamIdMapping); - //realConsole?.log('zzzvirtualStream', virtualStream); - //const cond1 = (virtualStream.type === 'video' || virtualStream.type === 'applicationsharing-video'); - //const cond2 = !physicalClientStreamIds.includes(virtualStream.sourceId.toString()); - //const cond3 = hasCorrespondingPhysicalStream; - //realConsole?.log('zzzcond1', cond1, 'cond2', cond2, 'cond3', cond3); - - - if ((virtualStream.isScreenShare || virtualStream.isWebcam) && isNotVirtualStreamForBot && hasCorrespondingPhysicalStream) - { - virtualSteamsThatHavePhysicalStreams.push(virtualStream); - } - }; - //realConsole?.log("ENDFILTER"); - //realConsole?.log('zzzvirtualSteamsThatHavePhysicalStreams', virtualSteamsThatHavePhysicalStreams); - //realConsole?.log('this.physicalClientStreamIdToVirtualStreamIdMapping', this.physicalClientStreamIdToVirtualStreamIdMapping); - if (virtualSteamsThatHavePhysicalStreams.length == 0) - return null; - - const firstActiveScreenShareStream = virtualSteamsThatHavePhysicalStreams.find(virtualStream => virtualStream.isScreenShare && virtualStream.isActive); - //realConsole?.log('zzzfirstActiveScreenShareStream', firstActiveScreenShareStream); - if (firstActiveScreenShareStream) - return firstActiveScreenShareStream.sourceId; - - const dominantSpeaker = dominantSpeakerManager.getDominantSpeaker(); - //realConsole?.log('zzzdominantSpeaker', dominantSpeaker); - if (dominantSpeaker) - { - const dominantSpeakerVideoStream = virtualSteamsThatHavePhysicalStreams.find(virtualStream => virtualStream.participant.id === dominantSpeaker.id && virtualStream.isWebcam && virtualStream.isActive); - if (dominantSpeakerVideoStream) - return dominantSpeakerVideoStream.sourceId; - } - - return virtualSteamsThatHavePhysicalStreams[0]?.sourceId; - } - - getVideoStreamIdToSend() { - - const virtualVideoStreamIdToSend = this.getVirtualVideoStreamIdToSend(); - if (!virtualVideoStreamIdToSend) - { - return this.physicalStreamsByServerStreamId.keys().find(physicalServerStreamId => physicalServerStreamId.includes('Video')); - } - //realConsole?.log('virtualVideoStreamIdToSend', virtualVideoStreamIdToSend); - //realConsole?.log('this.physicalClientStreamIdToVirtualStreamIdMapping', this.physicalClientStreamIdToVirtualStreamIdMapping); - - //realConsole?.log('Object.entries(this.physicalClientStreamIdToVirtualStreamIdMapping)', Object.entries(this.physicalClientStreamIdToVirtualStreamIdMapping)); - - // Find the physical client stream ID that maps to this virtual stream ID - const physicalClientStreamId = this.virtualStreamIdToPhysicalClientStreamIdMapping[virtualVideoStreamIdToSend]; - - //realConsole?.log('physicalClientStreamId', physicalClientStreamId); - //realConsole?.log('this.physicalStreamsByClientStreamId', this.physicalStreamsByClientStreamId); - - const physicalStream = this.physicalStreamsByClientStreamId.get(physicalClientStreamId); - if (!physicalStream) - return null; - - //realConsole?.log('physicalStream', physicalStream); - - return physicalStream.serverStreamId; - } - - upsertPhysicalStreams(physicalStreams) { - for (const physicalStream of physicalStreams) { - this.physicalStreamsByClientStreamId.set(physicalStream.clientStreamId, physicalStream); - this.physicalStreamsByServerStreamId.set(physicalStream.serverStreamId, physicalStream); - } - realConsole?.log('physicalStreamsByClientStreamId', this.physicalStreamsByClientStreamId); - realConsole?.log('physicalStreamsByServerStreamId', this.physicalStreamsByServerStreamId); - } - - upsertVirtualStream(virtualStream) { - realConsole?.log('upsertVirtualStream', virtualStream, 'this.virtualStreams', this.virtualStreams); - this.virtualStreams.set(virtualStream.sourceId.toString(), {...virtualStream, sourceId: virtualStream.sourceId.toString()}); - } - - removeVirtualStreamsForParticipant(participantId) { - const virtualStreamsToRemove = Array.from(this.virtualStreams.values()).filter(virtualStream => virtualStream.participant.id === participantId); - for (const virtualStream of virtualStreamsToRemove) { - this.virtualStreams.delete(virtualStream.sourceId.toString()); - } - } - - upsertPhysicalClientStreamIdToVirtualStreamIdMapping(physicalClientStreamId, virtualStreamId) { - const physicalClientStreamIdString = physicalClientStreamId.toString(); - const virtualStreamIdString = virtualStreamId.toString(); - if (virtualStreamIdString === '-1') - { - // Find and delete from the inverse mapping first - const virtualStreamIdToDelete = this.physicalClientStreamIdToVirtualStreamIdMapping[physicalClientStreamIdString]; - if (virtualStreamIdToDelete) { - delete this.virtualStreamIdToPhysicalClientStreamIdMapping[virtualStreamIdToDelete]; - } - else { - realConsole?.error('Entry for virtual stream id ', virtualStreamIdToDelete, ' not found in', this.virtualStreamIdToPhysicalClientStreamIdMapping); - } - // Then delete from the main mapping - delete this.physicalClientStreamIdToVirtualStreamIdMapping[physicalClientStreamIdString]; - } - else { - this.physicalClientStreamIdToVirtualStreamIdMapping[physicalClientStreamIdString] = virtualStreamIdString; - this.virtualStreamIdToPhysicalClientStreamIdMapping[virtualStreamIdString] = physicalClientStreamIdString; - } - realConsole?.log('physicalClientStreamId', physicalClientStreamIdString, 'virtualStreamId', virtualStreamIdString, 'physicalClientStreamIdToVirtualStreamIdMapping', this.physicalClientStreamIdToVirtualStreamIdMapping, 'virtualStreamIdToPhysicalClientStreamIdMapping', this.virtualStreamIdToPhysicalClientStreamIdMapping); - } - - virtualStreamIdToParticipant(virtualStreamId) { - return this.virtualStreams.get(virtualStreamId)?.participant; - } - - physicalServerStreamIdToParticipant(physicalServerStreamId) { - realConsole?.log('physicalServerStreamId', physicalServerStreamId); - realConsole?.log('physicalClientStreamIdToVirtualStreamIdMapping', this.physicalClientStreamIdToVirtualStreamIdMapping); - - const physicalClientStreamId = this.physicalStreamsByServerStreamId.get(physicalServerStreamId)?.clientStreamId; - realConsole?.log('physicalClientStreamId', physicalClientStreamId); - if (!physicalClientStreamId) - return null; - - const virtualStreamId = this.physicalClientStreamIdToVirtualStreamIdMapping[physicalClientStreamId]; - if (!virtualStreamId) - return null; - - const participant = this.virtualStreams.get(virtualStreamId)?.participant; - if (!participant) - return null; - - return participant; - } -} - - - -class RTCInterceptor { - constructor(callbacks) { - // Store the original RTCPeerConnection - const originalRTCPeerConnection = window.RTCPeerConnection; - - // Store callbacks - const onPeerConnectionCreate = callbacks.onPeerConnectionCreate || (() => {}); - const onDataChannelCreate = callbacks.onDataChannelCreate || (() => {}); - const onDataChannelSend = callbacks.onDataChannelSend || (() => {}); - - // Override the RTCPeerConnection constructor - window.RTCPeerConnection = function(...args) { - // Create instance using the original constructor - const peerConnection = Reflect.construct( - originalRTCPeerConnection, - args - ); - - // Notify about the creation - onPeerConnectionCreate(peerConnection); - - // Override createDataChannel - const originalCreateDataChannel = peerConnection.createDataChannel.bind(peerConnection); - peerConnection.createDataChannel = (label, options) => { - const dataChannel = originalCreateDataChannel(label, options); - - // Intercept send method - const originalSend = dataChannel.send; - dataChannel.send = function(data) { - try { - onDataChannelSend({ - channel: dataChannel, - data: data, - peerConnection: peerConnection - }); - } catch (error) { - realConsole?.error('Error in data channel send interceptor:', error); - } - return originalSend.apply(this, arguments); - }; - - onDataChannelCreate(dataChannel, peerConnection); - return dataChannel; - }; - - // Intercept createOffer - const originalCreateOffer = peerConnection.createOffer.bind(peerConnection); - peerConnection.createOffer = async function(options) { - const offer = await originalCreateOffer(options); - realConsole?.log('from peerConnection.createOffer:', offer.sdp); - /* - console.log('Created Offer SDP:', { - type: offer.type, - sdp: offer.sdp, - parsedSDP: parseSDP(offer.sdp) - }); - */ - realConsole?.log('from peerConnection.createOffer: extractStreamIdToSSRCMappingFromSDP = ', extractStreamIdToSSRCMappingFromSDP(offer.sdp)); - return offer; - }; - - // Intercept createAnswer - const originalCreateAnswer = peerConnection.createAnswer.bind(peerConnection); - peerConnection.createAnswer = async function(options) { - const answer = await originalCreateAnswer(options); - realConsole?.log('from peerConnection.createAnswer:', answer.sdp); - /* - console.log('Created Answer SDP:', { - type: answer.type, - sdp: answer.sdp, - parsedSDP: parseSDP(answer.sdp) - }); - */ - realConsole?.log('from peerConnection.createAnswer: extractStreamIdToSSRCMappingFromSDP = ', extractStreamIdToSSRCMappingFromSDP(answer.sdp)); - return answer; - }; - - - -/* - -how the mapping works: -the SDP contains x-source-streamid: -this corresponds to the stream id / source id in the participants hash -So that correspondences allows us to map a participant stream to an SDP. But how do we go from SDP to the raw low level track id? -The tracks have a streamId that looks like this mainVideo-39016. The SDP has that same streamId contained within it in the msid: header -3396 - - - - - -*/ - // Override setLocalDescription with detailed logging - const originalSetLocalDescription = peerConnection.setLocalDescription; - peerConnection.setLocalDescription = async function(description) { - realConsole?.log('from peerConnection.setLocalDescription:', description.sdp); - /* - console.log('Setting Local SDP:', { - type: description.type, - sdp: description.sdp, - parsedSDP: parseSDP(description.sdp) - }); - */ - realConsole?.log('from peerConnection.setLocalDescription: extractStreamIdToSSRCMappingFromSDP = ', extractStreamIdToSSRCMappingFromSDP(description.sdp)); - return originalSetLocalDescription.apply(this, arguments); - }; - - // Override setRemoteDescription with detailed logging - const originalSetRemoteDescription = peerConnection.setRemoteDescription; - peerConnection.setRemoteDescription = async function(description) { - realConsole?.log('from peerConnection.setRemoteDescription:', description.sdp); - /* - console.log('Setting Remote SDP:', { - type: description.type, - parsedSDP: parseSDP(description.sdp) - }); - */ - const mapping = extractStreamIdToSSRCMappingFromSDP(description.sdp); - realConsole?.log('from peerConnection.setRemoteDescription: extractStreamIdToSSRCMappingFromSDP = ', mapping); - virtualStreamToPhysicalStreamMappingManager.upsertPhysicalStreams(mapping); - return originalSetRemoteDescription.apply(this, arguments); - }; - - function extractMSID(rawSSRCEntry) { - if (!rawSSRCEntry) return null; - - const parts = rawSSRCEntry.split(' '); - for (const part of parts) { - if (part.startsWith('msid:')) { - return part.substring(5).split(' ')[0]; - } - } - return null; - } - - function extractStreamIdToSSRCMappingFromSDP(sdp) - { - const parsedSDP = parseSDP(sdp); - const mapping = []; - const sdpMediaList = parsedSDP.media || []; - - for (const sdpMediaEntry of sdpMediaList) { - const sdpMediaEntryAttributes = sdpMediaEntry.attributes || {}; - //realConsole?.log('sdpMediaEntryAttributes', sdpMediaEntryAttributes); - //realConsole?.log(sdpMediaEntry); - const sdpMediaEntrySSRCNumbersRaw = sdpMediaEntryAttributes.ssrc || []; - const sdpMediaEntrySSRCNumbers = [...new Set(sdpMediaEntrySSRCNumbersRaw.map(x => extractMSID(x)))]; - - const streamIds = sdpMediaEntryAttributes['x-source-streamid'] || []; - if (streamIds.length > 1) - console.warn('Warning: x-source-streamid has multiple stream ids'); - - const streamId = streamIds[0]; - - for(const ssrc of sdpMediaEntrySSRCNumbers) - if (ssrc && streamId) - mapping.push({clientStreamId: streamId, serverStreamId: ssrc}); - } - - return mapping; - } - - // Helper function to parse SDP into a more readable format - function parseSDP(sdp) { - const parsed = { - media: [], - attributes: {}, - version: null, - origin: null, - session: null, - connection: null, - timing: null, - bandwidth: null - }; - - const lines = sdp.split('\r\n'); - let currentMedia = null; - - for (const line of lines) { - // Handle session-level fields - if (line.startsWith('v=')) { - parsed.version = line.substr(2); - } else if (line.startsWith('o=')) { - parsed.origin = line.substr(2); - } else if (line.startsWith('s=')) { - parsed.session = line.substr(2); - } else if (line.startsWith('c=')) { - parsed.connection = line.substr(2); - } else if (line.startsWith('t=')) { - parsed.timing = line.substr(2); - } else if (line.startsWith('b=')) { - parsed.bandwidth = line.substr(2); - } else if (line.startsWith('m=')) { - // Media section - currentMedia = { - type: line.split(' ')[0].substr(2), - description: line, - attributes: {}, - connection: null, - bandwidth: null - }; - parsed.media.push(currentMedia); - } else if (line.startsWith('a=')) { - // Handle attributes that may contain multiple colons - const colonIndex = line.indexOf(':'); - let key, value; - - if (colonIndex === -1) { - // Handle flag attributes with no value - key = line.substr(2); - value = true; - } else { - key = line.substring(2, colonIndex); - value = line.substring(colonIndex + 1); - } - - if (currentMedia) { - if (!currentMedia.attributes[key]) { - currentMedia.attributes[key] = []; - } - currentMedia.attributes[key].push(value); - } else { - if (!parsed.attributes[key]) { - parsed.attributes[key] = []; - } - parsed.attributes[key].push(value); - } - } else if (line.startsWith('c=') && currentMedia) { - currentMedia.connection = line.substr(2); - } else if (line.startsWith('b=') && currentMedia) { - currentMedia.bandwidth = line.substr(2); - } - } - - return parsed; - } - - return peerConnection; - }; - } -} - -// User manager -class UserManager { - constructor(ws) { - this.allUsersMap = new Map(); - this.currentUsersMap = new Map(); - this.deviceOutputMap = new Map(); - - this.ws = ws; - } - - - getDeviceOutput(deviceId, outputType) { - return this.deviceOutputMap.get(`${deviceId}-${outputType}`); - } - - updateDeviceOutputs(deviceOutputs) { - for (const output of deviceOutputs) { - const key = `${output.deviceId}-${output.deviceOutputType}`; // Unique key combining device ID and output type - - const deviceOutput = { - deviceId: output.deviceId, - outputType: output.deviceOutputType, // 1 = audio, 2 = video - streamId: output.streamId, - disabled: output.deviceOutputStatus.disabled, - lastUpdated: Date.now() - }; - - this.deviceOutputMap.set(key, deviceOutput); - } - - // Notify websocket clients about the device output update - this.ws.sendJson({ - type: 'DeviceOutputsUpdate', - deviceOutputs: Array.from(this.deviceOutputMap.values()) - }); - } - - getUserByDeviceId(deviceId) { - return this.allUsersMap.get(deviceId); - } - - // constants for meeting status - MEETING_STATUS = { - IN_MEETING: 1, - NOT_IN_MEETING: 6 - } - - getCurrentUsersInMeeting() { - return Array.from(this.currentUsersMap.values()).filter(user => user.status === this.MEETING_STATUS.IN_MEETING); - } - - getCurrentUsersInMeetingWhoAreScreenSharing() { - return this.getCurrentUsersInMeeting().filter(user => user.parentDeviceId); - } - - convertUser(user) { - return { - deviceId: user.details.id, - displayName: user.details.displayName, - fullName: user.details.displayName, - profile: '', - status: user.state, - humanized_status: user.state === "active" ? "in_meeting" : "not_in_meeting", - } - } - - singleUserSynced(user) { - const convertedUser = this.convertUser(user); - console.log('singleUserSynced called w', convertedUser); - // Create array with new user and existing users, then filter for unique deviceIds - // keeping the first occurrence (new user takes precedence) - const allUsers = [...this.currentUsersMap.values(), convertedUser]; - console.log('allUsers', allUsers); - const uniqueUsers = Array.from( - new Map(allUsers.map(singleUser => [singleUser.deviceId, singleUser])).values() - ); - this.newUsersListSynced(uniqueUsers); - } - - newUsersListSynced(newUsersList) { - console.log('newUsersListSynced called w', newUsersList); - // Get the current user IDs before updating - const previousUserIds = new Set(this.currentUsersMap.keys()); - const newUserIds = new Set(newUsersList.map(user => user.deviceId)); - const updatedUserIds = new Set([]) - - // Update all users map - for (const user of newUsersList) { - if (previousUserIds.has(user.deviceId) && JSON.stringify(this.currentUsersMap.get(user.deviceId)) !== JSON.stringify(user)) { - updatedUserIds.add(user.deviceId); - } - - this.allUsersMap.set(user.deviceId, { - deviceId: user.deviceId, - displayName: user.displayName, - fullName: user.fullName, - profile: user.profile, - status: user.status, - humanized_status: user.humanized_status, - parentDeviceId: user.parentDeviceId - }); - } - - // Calculate new, removed, and updated users - const newUsers = newUsersList.filter(user => !previousUserIds.has(user.deviceId)); - const removedUsers = Array.from(previousUserIds) - .filter(id => !newUserIds.has(id)) - .map(id => this.currentUsersMap.get(id)); - - if (removedUsers.length > 0) { - console.log('removedUsers', removedUsers); - } - - // Clear current users map and update with new list - this.currentUsersMap.clear(); - for (const user of newUsersList) { - this.currentUsersMap.set(user.deviceId, { - deviceId: user.deviceId, - displayName: user.displayName, - fullName: user.fullName, - profilePicture: user.profilePicture, - status: user.status, - humanized_status: user.humanized_status, - parentDeviceId: user.parentDeviceId - }); - } - - const updatedUsers = Array.from(updatedUserIds).map(id => this.currentUsersMap.get(id)); - - if (newUsers.length > 0 || removedUsers.length > 0 || updatedUsers.length > 0) { - this.ws.sendJson({ - type: 'UsersUpdate', - newUsers: newUsers, - removedUsers: removedUsers, - updatedUsers: updatedUsers - }); - } - } -} -var realConsole; -// Websocket client -class WebSocketClient { - // Message types - static MESSAGE_TYPES = { - JSON: 1, - VIDEO: 2, // Reserved for future use - AUDIO: 3 // Reserved for future use - }; - - constructor() { - const url = `ws://localhost:${window.initialData.websocketPort}`; - console.log('WebSocketClient url', url); - this.ws = new WebSocket(url); - this.ws.binaryType = 'arraybuffer'; - - this.ws.onopen = () => { - console.log('WebSocket Connected'); - }; - - this.ws.onmessage = (event) => { - this.handleMessage(event.data); - }; - - this.ws.onerror = (error) => { - console.error('WebSocket Error:', error); - }; - - this.ws.onclose = () => { - console.log('WebSocket Disconnected'); - }; - - this.mediaSendingEnabled = false; - this.lastVideoFrameTime = performance.now(); - this.blackFrameInterval = null; - } - - startBlackFrameTimer() { - if (this.blackFrameInterval) return; // Don't start if already running - - this.blackFrameInterval = setInterval(() => { - try { - const currentTime = performance.now(); - if (currentTime - this.lastVideoFrameTime >= 500 && this.mediaSendingEnabled) { - // Create black frame data (I420 format) - const width = 1920, height = 1080; - const yPlaneSize = width * height; - const uvPlaneSize = (width * height) / 4; - - const frameData = new Uint8Array(yPlaneSize + 2 * uvPlaneSize); - // Y plane (black = 0) - frameData.fill(0, 0, yPlaneSize); - // U and V planes (black = 128) - frameData.fill(128, yPlaneSize); - - // Fix: Math.floor() the milliseconds before converting to BigInt - const currentTimeMicros = BigInt(Math.floor(currentTime) * 1000); - this.sendVideo(currentTimeMicros, '0', width, height, frameData); - } - } catch (error) { - console.error('Error in black frame timer:', error); - } - }, 250); - } - - stopBlackFrameTimer() { - if (this.blackFrameInterval) { - clearInterval(this.blackFrameInterval); - this.blackFrameInterval = null; - } - } - - enableMediaSending() { - this.mediaSendingEnabled = true; - this.startBlackFrameTimer(); - } - - disableMediaSending() { - this.mediaSendingEnabled = false; - this.stopBlackFrameTimer(); - } - - handleMessage(data) { - const view = new DataView(data); - const messageType = view.getInt32(0, true); // true for little-endian - - // Handle different message types - switch (messageType) { - case WebSocketClient.MESSAGE_TYPES.JSON: - const jsonData = new TextDecoder().decode(new Uint8Array(data, 4)); - console.log('Received JSON message:', JSON.parse(jsonData)); - break; - // Add future message type handlers here - default: - console.warn('Unknown message type:', messageType); - } - } - - sendJson(data) { - if (this.ws.readyState !== originalWebSocket.OPEN) { - realConsole?.error('WebSocket is not connected'); - return; - } - - try { - // Convert JSON to string then to Uint8Array - const jsonString = JSON.stringify(data); - const jsonBytes = new TextEncoder().encode(jsonString); - - // Create final message: type (4 bytes) + json data - const message = new Uint8Array(4 + jsonBytes.length); - - // Set message type (1 for JSON) - new DataView(message.buffer).setInt32(0, WebSocketClient.MESSAGE_TYPES.JSON, true); - - // Copy JSON data after type - message.set(jsonBytes, 4); - - // Send the binary message - this.ws.send(message.buffer); - } catch (error) { - console.error('Error sending WebSocket message:', error); - console.error('Message data:', data); - } - } - - sendClosedCaptionUpdate(item) { - if (!this.mediaSendingEnabled) - return; - - this.sendJson({ - type: 'CaptionUpdate', - caption: item - }); - } - - sendAudio(timestamp, streamId, audioData) { - if (this.ws.readyState !== originalWebSocket.OPEN) { - realConsole?.error('WebSocket is not connected for audio send', this.ws.readyState); - return; - } - - if (!this.mediaSendingEnabled) { - return; - } - - try { - // Create final message: type (4 bytes) + timestamp (8 bytes) + audio data - const message = new Uint8Array(4 + 8 + 4 + audioData.buffer.byteLength); - const dataView = new DataView(message.buffer); - - // Set message type (3 for AUDIO) - dataView.setInt32(0, WebSocketClient.MESSAGE_TYPES.AUDIO, true); - - // Set timestamp as BigInt64 - dataView.setBigInt64(4, BigInt(timestamp), true); - - // Set streamId length and bytes - dataView.setInt32(12, streamId, true); - - // Copy audio data after type and timestamp - message.set(new Uint8Array(audioData.buffer), 16); - - // Send the binary message - this.ws.send(message.buffer); - } catch (error) { - realConsole?.error('Error sending WebSocket audio message:', error); - } - } - - sendVideo(timestamp, streamId, width, height, videoData) { - if (this.ws.readyState !== originalWebSocket.OPEN) { - console.error('WebSocket is not connected for video send', this.ws.readyState); - return; - } - - if (!this.mediaSendingEnabled) { - return; - } - - this.lastVideoFrameTime = performance.now(); - - try { - // Convert streamId to UTF-8 bytes - const streamIdBytes = new TextEncoder().encode(streamId); - - // Create final message: type (4 bytes) + timestamp (8 bytes) + streamId length (4 bytes) + - // streamId bytes + width (4 bytes) + height (4 bytes) + video data - const message = new Uint8Array(4 + 8 + 4 + streamIdBytes.length + 4 + 4 + videoData.buffer.byteLength); - const dataView = new DataView(message.buffer); - - // Set message type (2 for VIDEO) - dataView.setInt32(0, WebSocketClient.MESSAGE_TYPES.VIDEO, true); - - // Set timestamp as BigInt64 - dataView.setBigInt64(4, BigInt(timestamp), true); - - // Set streamId length and bytes - dataView.setInt32(12, streamIdBytes.length, true); - message.set(streamIdBytes, 16); - - // Set width and height - const streamIdOffset = 16 + streamIdBytes.length; - dataView.setInt32(streamIdOffset, width, true); - dataView.setInt32(streamIdOffset + 4, height, true); - - // Copy video data after headers - message.set(new Uint8Array(videoData.buffer), streamIdOffset + 8); - - // Send the binary message - this.ws.send(message.buffer); - } catch (error) { - console.error('Error sending WebSocket video message:', error); - } - } - } - -class WebSocketInterceptor { - constructor(callbacks = {}) { - this.originalWebSocket = window.WebSocket; - this.callbacks = { - onSend: callbacks.onSend || (() => {}), - onMessage: callbacks.onMessage || (() => {}), - onOpen: callbacks.onOpen || (() => {}), - onClose: callbacks.onClose || (() => {}), - onError: callbacks.onError || (() => {}) - }; - - window.WebSocket = this.createWebSocketProxy(); - } - - createWebSocketProxy() { - const OriginalWebSocket = this.originalWebSocket; - const callbacks = this.callbacks; - - return function(url, protocols) { - const ws = new OriginalWebSocket(url, protocols); - - // Intercept send - const originalSend = ws.send; - ws.send = function(data) { - try { - callbacks.onSend({ - url, - data, - ws - }); - } catch (error) { - realConsole?.log('Error in WebSocket send callback:'); - realConsole?.log(error); - } - - return originalSend.apply(ws, arguments); - }; - - // Intercept onmessage - ws.addEventListener('message', function(event) { - try { - callbacks.onMessage({ - url, - data: event.data, - event, - ws - }); - } catch (error) { - realConsole?.log('Error in WebSocket message callback:'); - realConsole?.log(error); - } - }); - - // Intercept connection events - ws.addEventListener('open', (event) => { - callbacks.onOpen({ url, event, ws }); - }); - - ws.addEventListener('close', (event) => { - callbacks.onClose({ - url, - code: event.code, - reason: event.reason, - event, - ws - }); - }); - - ws.addEventListener('error', (event) => { - callbacks.onError({ url, event, ws }); - }); - - return ws; - }; - } -} - -function decodeWebSocketBody(encodedData) { - const byteArray = Uint8Array.from(atob(encodedData), c => c.charCodeAt(0)); - return JSON.parse(pako.inflate(byteArray, { to: "string" })); -} - -function syncVirtualStreamsFromParticipant(participant) { - if (participant.state === 'inactive') { - virtualStreamToPhysicalStreamMappingManager.removeVirtualStreamsForParticipant(participant.details?.id); - return; - } - - const mediaStreams = []; - - // Check if participant has endpoints - if (participant.endpoints) { - // Iterate through all endpoints - Object.values(participant.endpoints).forEach(endpoint => { - // Check if endpoint has call and mediaStreams - if (endpoint.call && Array.isArray(endpoint.call.mediaStreams)) { - // Add all mediaStreams from this endpoint to our array - mediaStreams.push(...endpoint.call.mediaStreams); - } - }); - } - - for (const mediaStream of mediaStreams) { - const isScreenShare = mediaStream.type === 'applicationsharing-video'; - const isWebcam = mediaStream.type === 'video'; - const isActive = mediaStream.direction === 'sendrecv' || mediaStream.direction === 'sendonly'; - virtualStreamToPhysicalStreamMappingManager.upsertVirtualStream( - {...mediaStream, participant: {displayName: participant.details?.displayName, id: participant.details?.id}, isScreenShare, isWebcam, isActive} - ); - } -} - -function handleRosterUpdate(eventDataObject) { - try { - const decodedBody = decodeWebSocketBody(eventDataObject.body); - realConsole?.log('handleRosterUpdate decodedBody', decodedBody); - // Teams includes a user with no display name. Not sure what this user is but we don't want to sync that user. - const participants = Object.values(decodedBody.participants).filter(participant => participant.details?.displayName); - - for (const participant of participants) { - window.userManager.singleUserSynced(participant); - syncVirtualStreamsFromParticipant(participant); - } - } catch (error) { - realConsole?.error('Error handling roster update:'); - realConsole?.error(error); - } -} - -const originalWebSocket = window.WebSocket; -// Example usage: -const wsInterceptor = new WebSocketInterceptor({ - /* - onSend: ({ url, data }) => { - if (url.startsWith('ws://localhost:8097')) - return; - - //realConsole?.log('websocket onSend', url, data); - }, - */ - onMessage: ({ url, data }) => { - realConsole?.log('onMessage', url, data); - if (data.startsWith("3:::")) { - const eventDataObject = JSON.parse(data.slice(4)); - - realConsole?.log('Event Data Object:', eventDataObject); - if (eventDataObject.url.endsWith("rosterUpdate/") || eventDataObject.url.endsWith("rosterUpdate")) { - handleRosterUpdate(eventDataObject); - } - /* - Not sure if this is needed - if (eventDataObject.url.endsWith("controlVideoStreaming/")) { - handleControlVideoStreaming(eventDataObject); - } - */ - } - } -}); - - -const ws = new WebSocketClient(); -window.ws = ws; -const userManager = new UserManager(ws); -window.userManager = userManager; - -//const videoTrackManager = new VideoTrackManager(ws); -const virtualStreamToPhysicalStreamMappingManager = new VirtualStreamToPhysicalStreamMappingManager(); -const dominantSpeakerManager = new DominantSpeakerManager(); - -if (!realConsole) { - if (document.readyState === 'complete') { - createIframe(); - } else { - document.addEventListener('DOMContentLoaded', createIframe); - } - function createIframe() { - const iframe = document.createElement('iframe'); - iframe.src = 'about:blank'; - document.body.appendChild(iframe); - realConsole = iframe.contentWindow.console; - } -} - -const processDominantSpeakerHistoryMessage = (item) => { - realConsole?.log('processDominantSpeakerHistoryMessage', item); - const newDominantSpeakerAudioVirtualStreamId = item.history[0]; - dominantSpeakerManager.setDominantSpeakerStreamId(newDominantSpeakerAudioVirtualStreamId); - realConsole?.log('newDominantSpeakerParticipant', dominantSpeakerManager.getDominantSpeaker()); -} - -const processClosedCaptionData = (item) => { - realConsole?.log('processClosedCaptionData', item); - if (!window.ws) { - return; - } - - const captionId = item.id.split("/")[0] + ":" + item.timestampAudioSent.toString(); - - const itemConverted = { - deviceId: item.userId, - captionId: captionId, - text: item.text, - audioTimestamp: item.timestampAudioSent - }; - - window.ws.sendClosedCaptionUpdate(itemConverted); -} - -const handleMainChannelEvent = (event) => { - //realConsole?.log('handleMainChannelEvent', event); - const decodedData = new Uint8Array(event.data); - - const jsonRawString = new TextDecoder().decode(decodedData); - //realConsole?.log('handleMainChannelEvent jsonRawString', jsonRawString); - - // Find the start of the JSON data (looking for '[' or '{' character) - let jsonStart = 0; - for (let i = 0; i < decodedData.length; i++) { - if (decodedData[i] === 91 || decodedData[i] === 123) { // ASCII code for '[' or '{' - jsonStart = i; - break; - } - } - - // Extract and parse the JSON portion - const jsonString = new TextDecoder().decode(decodedData.slice(jsonStart)); - try { - const parsedData = JSON.parse(jsonString); - //realConsole?.log('handleMainChannelEvent parsedData', parsedData); - // When you see this parsedData [{"history":[1053,2331],"type":"dsh"}] - // it corresponds to active speaker - if (Array.isArray(parsedData)) { - for (const item of parsedData) { - // This is a dominant speaker history message - if (item.type === 'dsh') { - processDominantSpeakerHistoryMessage(item); - } - } - } - else - { - if (parsedData.recognitionResults) { - for(const item of parsedData.recognitionResults) { - processClosedCaptionData(item); - } - } - } - } catch (e) { - realConsole?.error('Failed to parse main channel data:', e); - } -} - -const processSourceRequest = (item) => { - const sourceId = item?.controlVideoStreaming?.controlInfo?.sourceId; - const streamMsid = item?.controlVideoStreaming?.controlInfo?.streamMsid; - - if (!sourceId || !streamMsid) { - return; - } - - virtualStreamToPhysicalStreamMappingManager.upsertPhysicalClientStreamIdToVirtualStreamIdMapping(streamMsid.toString(), sourceId.toString()); -} - -const handleMainChannelSend = (data) => { - const decodedData = new Uint8Array(data); - - const jsonRawString = new TextDecoder().decode(decodedData); - - // Find the start of the JSON data (looking for '[' or '{' character) - let jsonStart = 0; - for (let i = 0; i < decodedData.length; i++) { - if (decodedData[i] === 91 || decodedData[i] === 123) { // ASCII code for '[' or '{' - jsonStart = i; - break; - } - } - - // Extract and parse the JSON portion - const jsonString = new TextDecoder().decode(decodedData.slice(jsonStart)); - try { - const parsedData = JSON.parse(jsonString); - realConsole?.log('handleMainChannelSend parsedData', parsedData); - // if it is an array - if (Array.isArray(parsedData)) { - for (const item of parsedData) { - // This is a source request. It means the teams client is asking for the server to start serving a source from one of the streams - // that the server provides to the client - if (item.type === 'sr') { - processSourceRequest(item); - } - } - } - } catch (e) { - realConsole?.error('Failed to parse main channel data:', e); - } -} - -const handleVideoTrack = async (event) => { - try { - // Create processor to get raw frames - const processor = new MediaStreamTrackProcessor({ track: event.track }); - const generator = new MediaStreamTrackGenerator({ kind: 'video' }); - - // Add track ended listener - event.track.addEventListener('ended', () => { - console.log('Video track ended:', event.track.id); - //videoTrackManager.deleteVideoTrack(event.track); - }); - - // Get readable stream of video frames - const readable = processor.readable; - const writable = generator.writable; - - const firstStreamId = event.streams[0]?.id; - - console.log('firstStreamId', firstStreamId); - - // Check if of the users who are in the meeting and screensharers - // if any of them have an associated device output with the first stream ID of this video track - /* - const isScreenShare = userManager - .getCurrentUsersInMeetingWhoAreScreenSharing() - .some(user => firstStreamId && userManager.getDeviceOutput(user.deviceId, DEVICE_OUTPUT_TYPE.VIDEO).streamId === firstStreamId); - if (firstStreamId) { - videoTrackManager.upsertVideoTrack(event.track, firstStreamId, isScreenShare); - } - */ - - // Add frame rate control variables - const targetFPS = 24; - const frameInterval = 1000 / targetFPS; // milliseconds between frames - let lastFrameTime = 0; - - const transformStream = new TransformStream({ - async transform(frame, controller) { - if (!frame) { - return; - } - - try { - // Check if controller is still active - if (controller.desiredSize === null) { - frame.close(); - return; - } - - const currentTime = performance.now(); - - // Add SSRC logging - // - /* - if (event.track.getSettings) { - //console.log('Track settings:', event.track.getSettings()); - } - //console.log('Track ID:', event.track.id); - - if (event.streams && event.streams[0]) { - //console.log('Stream ID:', event.streams[0].id); - event.streams[0].getTracks().forEach(track => { - if (track.getStats) { - track.getStats().then(stats => { - stats.forEach(report => { - if (report.type === 'outbound-rtp' || report.type === 'inbound-rtp') { - console.log('RTP Stats (including SSRC):', report); - } - }); - }); - } - }); - }*/ - - /* - if (Math.random() < 0.00025) { - //const participant = virtualStreamToPhysicalStreamMappingManager.physicalServerStreamIdToParticipant(firstStreamId); - //realConsole?.log('videoframe from stream id', firstStreamId, ' corresponding to participant', participant); - //realConsole?.log('frame', frame); - //realConsole?.log('handleVideoTrack, randomsample', event); - } - */ - // if (Math.random() < 0.02) - //realConsole?.log('firstStreamId', firstStreamId, 'streamIdToSend', virtualStreamToPhysicalStreamMappingManager.getVideoStreamIdToSend()); - - if (firstStreamId && firstStreamId === virtualStreamToPhysicalStreamMappingManager.getVideoStreamIdToSend()) { - // Check if enough time has passed since the last frame - if (currentTime - lastFrameTime >= frameInterval) { - // Copy the frame to get access to raw data - const rawFrame = new VideoFrame(frame, { - format: 'I420' - }); - - // Get the raw data from the frame - const data = new Uint8Array(rawFrame.allocationSize()); - rawFrame.copyTo(data); - - /* - const currentFormat = { - width: frame.displayWidth, - height: frame.displayHeight, - dataSize: data.length, - format: rawFrame.format, - duration: frame.duration, - colorSpace: frame.colorSpace, - codedWidth: frame.codedWidth, - codedHeight: frame.codedHeight - }; - */ - // Get current time in microseconds (multiply milliseconds by 1000) - const currentTimeMicros = BigInt(Math.floor(currentTime * 1000)); - ws.sendVideo(currentTimeMicros, firstStreamId, frame.displayWidth, frame.displayHeight, data); - - rawFrame.close(); - lastFrameTime = currentTime; - } - } - - // Always enqueue the frame for the video element - controller.enqueue(frame); - } catch (error) { - realConsole?.error('Error processing frame:', error); - frame.close(); - } - }, - flush() { - realConsole?.log('Transform stream flush called'); - } - }); - - // Create an abort controller for cleanup - const abortController = new AbortController(); - - try { - // Connect the streams - await readable - .pipeThrough(transformStream) - .pipeTo(writable, { - signal: abortController.signal - }) - .catch(error => { - if (error.name !== 'AbortError') { - realConsole?.error('Pipeline error:', error); - } - }); - } catch (error) { - realConsole?.error('Stream pipeline error:', error); - abortController.abort(); - } - - } catch (error) { - realConsole?.error('Error setting up video interceptor:', error); - } - }; - -const handleAudioTrack = async (event) => { - let lastAudioFormat = null; // Track last seen format - - try { - // Create processor to get raw frames - const processor = new MediaStreamTrackProcessor({ track: event.track }); - const generator = new MediaStreamTrackGenerator({ kind: 'audio' }); - - // Get readable stream of audio frames - const readable = processor.readable; - const writable = generator.writable; - - // Transform stream to intercept frames - const transformStream = new TransformStream({ - async transform(frame, controller) { - if (!frame) { - return; - } - - try { - // Check if controller is still active - if (controller.desiredSize === null) { - frame.close(); - return; - } - - // Copy the audio data - const numChannels = frame.numberOfChannels; - const numSamples = frame.numberOfFrames; - const audioData = new Float32Array(numChannels * numSamples); - - // Copy data from each channel - for (let channel = 0; channel < numChannels; channel++) { - frame.copyTo(audioData.subarray(channel * numSamples, (channel + 1) * numSamples), - { planeIndex: channel }); - } - - // console.log('frame', frame) - // console.log('audioData', audioData) - - // Check if audio format has changed - const currentFormat = { - numberOfChannels: frame.numberOfChannels, - numberOfFrames: frame.numberOfFrames, - sampleRate: frame.sampleRate, - format: frame.format, - duration: frame.duration - }; - //realConsole?.log('currentFormat', currentFormat); - - // If format is different from last seen format, send update - if (!lastAudioFormat || - JSON.stringify(currentFormat) !== JSON.stringify(lastAudioFormat)) { - lastAudioFormat = currentFormat; - realConsole?.log('sending audio format update'); - ws.sendJson({ - type: 'AudioFormatUpdate', - format: currentFormat - }); - } - - // If the audioData buffer is all zeros, then we don't want to send it - if (audioData.every(value => value === 0)) { - //realConsole?.log('audioData is all zeros'); - return; - } - - // Send audio data through websocket - const currentTimeMicros = BigInt(Math.floor(performance.now() * 1000)); - ws.sendAudio(currentTimeMicros, 0, audioData); - - // Pass through the original frame - controller.enqueue(frame); - } catch (error) { - console.error('Error processing frame:', error); - frame.close(); - } - }, - flush() { - console.log('Transform stream flush called'); - } - }); - - // Create an abort controller for cleanup - const abortController = new AbortController(); - - try { - // Connect the streams - await readable - .pipeThrough(transformStream) - .pipeTo(writable, { - signal: abortController.signal - }) - .catch(error => { - if (error.name !== 'AbortError') { - console.error('Pipeline error:', error); - } - }); - } catch (error) { - console.error('Stream pipeline error:', error); - abortController.abort(); - } - - } catch (error) { - console.error('Error setting up audio interceptor:', error); - } - }; - -// LOOK FOR https://api.flightproxy.skype.com/api/v2/cpconv - -// LOOK FOR https://teams.live.com/api/chatsvc/consumer/v1/threads?view=msnp24Equivalent&threadIds=19%3Ameeting_Y2U4ZDk5NzgtOWQwYS00YzNjLTg2ODktYmU5MmY2MGEyNzJj%40thread.v2 -new RTCInterceptor({ - onPeerConnectionCreate: (peerConnection) => { - realConsole?.log('New RTCPeerConnection created:', peerConnection); - peerConnection.addEventListener('datachannel', (event) => { - realConsole?.log('datachannel', event); - realConsole?.log('datachannel label', event.channel.label); - - if (event.channel.label === "collections") { - event.channel.addEventListener("message", (messageEvent) => { - console.log('RAWcollectionsevent', messageEvent); - handleCollectionEvent(messageEvent); - }); - } - }); - - peerConnection.addEventListener('track', (event) => { - // Log the track and its associated streams - - if (event.track.kind === 'audio') { - realConsole?.log('got audio track'); - realConsole?.log(event); - try { - handleAudioTrack(event); - } catch (e) { - realConsole?.log('Error handling audio track:', e); - } - } - if (event.track.kind === 'video') { - realConsole?.log('got video track'); - realConsole?.log(event); - try { - handleVideoTrack(event); - } catch (e) { - realConsole?.log('Error handling video track:', e); - } - } - }); - - peerConnection.addEventListener('connectionstatechange', (event) => { - realConsole?.log('connectionstatechange', event); - }); - - - - // This is called when the browser detects that the SDP has changed - peerConnection.addEventListener('negotiationneeded', (event) => { - realConsole?.log('negotiationneeded', event); - }); - - peerConnection.addEventListener('onnegotiationneeded', (event) => { - realConsole?.log('onnegotiationneeded', event); - }); - - // Log the signaling state changes - peerConnection.addEventListener('signalingstatechange', () => { - console.log('Signaling State:', peerConnection.signalingState); - }); - - // Log the SDP being exchanged - const originalSetLocalDescription = peerConnection.setLocalDescription; - peerConnection.setLocalDescription = function(description) { - realConsole?.log('Local SDP:', description); - return originalSetLocalDescription.apply(this, arguments); - }; - - const originalSetRemoteDescription = peerConnection.setRemoteDescription; - peerConnection.setRemoteDescription = function(description) { - realConsole?.log('Remote SDP:', description); - return originalSetRemoteDescription.apply(this, arguments); - }; - - // Log ICE candidates - peerConnection.addEventListener('icecandidate', (event) => { - if (event.candidate) { - //console.log('ICE Candidate:', event.candidate); - } - }); - }, - onDataChannelCreate: (dataChannel, peerConnection) => { - realConsole?.log('New DataChannel created:', dataChannel); - realConsole?.log('On PeerConnection:', peerConnection); - realConsole?.log('Channel label:', dataChannel.label); - realConsole?.log('Channel keys:', typeof dataChannel); - - //if (dataChannel.label === 'collections') { - // dataChannel.addEventListener("message", (event) => { - // console.log('collectionsevent', event) - // }); - //} - - - if (dataChannel.label === 'main-channel') { - dataChannel.addEventListener("message", (mainChannelEvent) => { - handleMainChannelEvent(mainChannelEvent); - }); - } - }, - onDataChannelSend: ({channel, data, peerConnection}) => { - if (channel.label === 'main-channel') { - handleMainChannelSend(data); - } - - - /* - realConsole?.log('DataChannel send intercepted:', { - channelLabel: channel.label, - data: data, - readyState: channel.readyState - });*/ - - // It looks like it sends a payload like this: - /* - [{"type":"sr","controlVideoStreaming":{"sequenceNumber":11,"controlInfo":{"sourceId":1267,"streamMsid":1694,"fmtParams":[{"max-fs":920,"max-mbps":33750,"max-fps":3000,"profile-level-id":"64001f"}]}}}] - - The streamMsid corresponds to the streamId in the streamIdToSSRCMapping object. We can use it to get the actual stream's id by putting it through the mapping and getting the ssrc. - The sourceId corresponds to sourceId of the participant that you get from the roster update event. - Annoyingly complicated, but it seems to work. - - - - */ - } -}); \ No newline at end of file diff --git a/attendee/bots/teams_bot_adapter/teams_ui_methods.py b/attendee/bots/teams_bot_adapter/teams_ui_methods.py deleted file mode 100644 index a844405..0000000 --- a/attendee/bots/teams_bot_adapter/teams_ui_methods.py +++ /dev/null @@ -1,149 +0,0 @@ -import logging - -from selenium.common.exceptions import NoSuchElementException, TimeoutException -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait - -from bots.web_bot_adapter.ui_methods import UiCouldNotClickElementException, UiCouldNotLocateElementException, UiRequestToJoinDeniedException - -logger = logging.getLogger(__name__) - - -class TeamsUIMethods: - def __init__(self, driver, meeting_url, display_name): - self.driver = driver - self.meeting_url = meeting_url - self.display_name = display_name - - def locate_element(self, step, condition, wait_time_seconds=60): - try: - element = WebDriverWait(self.driver, wait_time_seconds).until(condition) - return element - except Exception as e: - logger.info(f"Exception raised in locate_element for {step}") - raise UiCouldNotLocateElementException(f"Exception raised in locate_element for {step}", step, e) - - def find_element_by_selector(self, selector_type, selector): - try: - return self.driver.find_element(selector_type, selector) - except NoSuchElementException: - return None - except Exception as e: - logger.info(f"Unknown error occurred in find_element_by_selector. Exception type = {type(e)}") - return None - - def click_element(self, element, step): - try: - element.click() - except Exception as e: - logger.info(f"Error occurred when clicking element {step}, will retry") - raise UiCouldNotClickElementException("Error occurred when clicking element", step, e) - - def look_for_denied_request_element(self, step): - denied_request_element = self.find_element_by_selector(By.XPATH, '//*[contains(text(), "Your request to join was declined")]') - if denied_request_element: - logger.info("The request to join the Teams meeting was declined. Raising UiRequestToJoinDeniedException") - raise UiRequestToJoinDeniedException("The request to join the Teams meeting was declined", step) - - def look_for_waiting_to_be_admitted_element(self, step): - waiting_element = self.find_element_by_selector(By.XPATH, '//*[contains(text(), "Someone will let you in soon")]') - if waiting_element: - # Check if we've been waiting too long - logger.info("Still waiting to be admitted to the meeting after waiting period expired. Raising UiRequestToJoinDeniedException") - raise UiRequestToJoinDeniedException("Bot was not let in after waiting period expired", step) - - def fill_out_name_input(self): - num_attempts = 30 - logger.info("Waiting for the name input field...") - for attempt_index in range(num_attempts): - try: - name_input = WebDriverWait(self.driver, 1).until(EC.presence_of_element_located((By.CSS_SELECTOR, '[data-tid="prejoin-display-name-input"]'))) - logger.info("Name input found") - name_input.send_keys(self.display_name) - return - except TimeoutException as e: - last_check_timed_out = attempt_index == num_attempts - 1 - if last_check_timed_out: - logger.info("Could not find name input. Timed out. Raising UiCouldNotLocateElementException") - raise UiCouldNotLocateElementException("Could not find name input. Timed out.", "name_input", e) - except Exception as e: - logger.info(f"Could not find name input. Unknown error {e} of type {type(e)}. Raising UiCouldNotLocateElementException") - raise UiCouldNotLocateElementException("Could not find name input. Unknown error.", "name_input", e) - - def click_captions_button(self): - logger.info("Waiting for the show more button...") - show_more_button = self.locate_element(step="show_more_button", condition=EC.presence_of_element_located((By.ID, "callingButtons-showMoreBtn")), wait_time_seconds=60) - logger.info("Clicking the show more button...") - self.click_element(show_more_button, "show_more_button") - - logger.info("Waiting for the Language and Speech button...") - language_and_speech_button = self.locate_element(step="language_and_speech_button", condition=EC.presence_of_element_located((By.ID, "LanguageSpeechMenuControl-id")), wait_time_seconds=10) - logger.info("Clicking the language and speech button...") - self.click_element(language_and_speech_button, "language_and_speech_button") - - logger.info("Waiting for the closed captions button...") - closed_captions_button = self.locate_element(step="closed_captions_button", condition=EC.presence_of_element_located((By.ID, "closed-captions-button")), wait_time_seconds=10) - logger.info("Clicking the closed captions button...") - self.click_element(closed_captions_button, "closed_captions_button") - - def select_speaker_view(self): - logger.info("Waiting for the view button...") - view_button = self.locate_element(step="view_button", condition=EC.presence_of_element_located((By.ID, "view-mode-button")), wait_time_seconds=60) - logger.info("Clicking the view button...") - self.click_element(view_button, "view_button") - - logger.info("Waiting for the speaker view button...") - speaker_view_button = self.locate_element(step="speaker_view_button", condition=EC.presence_of_element_located((By.ID, "custom-view-button-SpeakerViewButton")), wait_time_seconds=10) - logger.info("Clicking the speaker view button...") - self.click_element(speaker_view_button, "speaker_view_button") - - # Returns nothing if succeeded, raises an exception if failed - def attempt_to_join_meeting(self): - self.driver.get(self.meeting_url) - - self.driver.execute_cdp_cmd( - "Browser.grantPermissions", - { - "origin": self.meeting_url, - "permissions": [ - "geolocation", - "audioCapture", - "displayCapture", - "videoCapture", - ], - }, - ) - - self.fill_out_name_input() - - logger.info("Waiting for the Join now button...") - join_button = self.locate_element(step="join_button", condition=EC.presence_of_element_located((By.CSS_SELECTOR, '[data-tid="prejoin-join-button"]')), wait_time_seconds=10) - logger.info("Clicking the Join now button...") - self.click_element(join_button, "join_button") - - # Check if we were denied entry - try: - WebDriverWait(self.driver, 10).until(lambda d: self.look_for_denied_request_element("join_meeting") or False) - except TimeoutException: - pass # This is expected if we're not denied - - # Wait for meeting to load and enable captions - self.click_captions_button() - - # Select speaker view - self.select_speaker_view() - - def click_leave_button(self): - logger.info("Waiting for the leave button") - leave_button = WebDriverWait(self.driver, 6).until( - EC.presence_of_element_located( - ( - By.CSS_SELECTOR, - '[data-inp="hangup-button"]', - ) - ) - ) - - logger.info("Clicking the leave button") - leave_button.click() diff --git a/attendee/bots/templates/projects/partials/api_key_created_modal.html b/attendee/bots/templates/projects/partials/api_key_created_modal.html deleted file mode 100644 index b24e7de..0000000 --- a/attendee/bots/templates/projects/partials/api_key_created_modal.html +++ /dev/null @@ -1,58 +0,0 @@ - - - - - \ No newline at end of file diff --git a/attendee/bots/templates/projects/partials/deepgram_credentials.html b/attendee/bots/templates/projects/partials/deepgram_credentials.html deleted file mode 100644 index 184be0b..0000000 --- a/attendee/bots/templates/projects/partials/deepgram_credentials.html +++ /dev/null @@ -1,79 +0,0 @@ -
-
-
Deepgram Credentials
-
-
-
- {% if credentials %} -

API Key: •••••••••{{ credentials.api_key|slice:"-3:" }}

- - {% else %} -

No Deepgram credentials configured.

- - {% endif %} -
-
-
- - - - - \ No newline at end of file diff --git a/attendee/bots/templates/projects/partials/google_tts_credentials.html b/attendee/bots/templates/projects/partials/google_tts_credentials.html deleted file mode 100644 index 47f4b78..0000000 --- a/attendee/bots/templates/projects/partials/google_tts_credentials.html +++ /dev/null @@ -1,87 +0,0 @@ -
-
-
Google Text-to-Speech Credentials
-
-
-
- {% if credentials %} - - {% else %} - - {% endif %} -
-
-
- - - - - \ No newline at end of file diff --git a/attendee/bots/templates/projects/partials/video_tutorial_modal.html b/attendee/bots/templates/projects/partials/video_tutorial_modal.html deleted file mode 100644 index 88f58a0..0000000 --- a/attendee/bots/templates/projects/partials/video_tutorial_modal.html +++ /dev/null @@ -1,33 +0,0 @@ - - - diff --git a/attendee/bots/templates/projects/partials/zoom_credentials.html b/attendee/bots/templates/projects/partials/zoom_credentials.html deleted file mode 100644 index 65ee63d..0000000 --- a/attendee/bots/templates/projects/partials/zoom_credentials.html +++ /dev/null @@ -1,90 +0,0 @@ -
-
-
Zoom Credentials
-
-
-
- {% if credentials %} -

Client ID: •••••••••{{ credentials.client_id|slice:"-3:" }}

-

Client Secret: •••••••••{{ credentials.client_secret|slice:"-3:" }}

- - {% else %} -

No Zoom credentials configured.

- - {% endif %} -
-
-
- - - - - diff --git a/attendee/bots/templates/projects/project_api_keys.html b/attendee/bots/templates/projects/project_api_keys.html deleted file mode 100644 index cc91b02..0000000 --- a/attendee/bots/templates/projects/project_api_keys.html +++ /dev/null @@ -1,124 +0,0 @@ -{% extends 'projects/sidebar.html' %} - -{% block content %} - - - - - - - - -
- -
- {% if api_keys %} - -

API Keys

- - - - - - - - - - - - - {% for key in api_keys %} - - - - - - {% endfor %} - -
NameCreatedActions
{{ key.name }}{{ key.created_at|date:"M d, Y H:i" }} - - - - -
- {% else %} -

API Keys

-

No API keys found

-

- -

- {% endif %} -
-
- - {% include 'projects/partials/video_tutorial_modal.html' with modal_title="Calling the API" loom_url="https://www.loom.com/embed/b738d02aabf84f489f0bfbadf71605e3?sid=ea605ea9-8961-4cc3-9ba9-10b7dbbb8034" %} -{% endblock %} \ No newline at end of file diff --git a/attendee/bots/templates/projects/project_bot_detail.html b/attendee/bots/templates/projects/project_bot_detail.html deleted file mode 100644 index 1552b0e..0000000 --- a/attendee/bots/templates/projects/project_bot_detail.html +++ /dev/null @@ -1,300 +0,0 @@ -{% extends 'projects/sidebar.html' %} - -{% load bot_filters %} - -{% block content %} - - -
-
-

Bot Details

- - Back to Bots - -
- -
-
-

ID: {{ bot.object_id }}

-

Name: {{ bot.name }}

-

Created: {{ bot.created_at|date:"M d, Y H:i" }}

-
-
-

Meeting URL: {{ bot.meeting_url }}

-

Status: - - {{ bot.get_state_display }} - {% if bot.sub_state %} - - {{ bot.get_sub_state_display }} - {% endif %} - -

-
-
- - -
- -
- {% if recordings %} - {% for recording in recordings %} -
- {% if recording.state == RecordingStates.COMPLETE %} -
-
- {% for utterance in recording.utterances %} -
-
-
-
- {{ utterance.timestamp_display }} | - {{ utterance.participant.full_name }} -
-
- {% if utterance.words %} - {% for word_data in utterance.words %} - {{ word_data.word }} - - {% endfor %} - {% else %} - {{ utterance.transcript }} - {% endif %} -
-
-
- {% empty %} -
No transcripts available.
- {% endfor %} -
-
- -
-
- {% else %} -
- Recording status: - - {{ recording.get_state_display }} - -
- {% endif %} -
- {% endfor %} - {% else %} -
No recordings available.
- {% endif %} -
- - -
- {% if bot.bot_events.all %} -
- {% for event in bot.bot_events.all %} -
-
- {% if event.event_sub_type %} - {{ event.get_event_sub_type_display }} - {% else %} - {{ event.get_event_type_display }} - {% endif %} -
-

{{ event.created_at|date:"M d, Y H:i:s" }}

-

- State changed from - {{ event.get_old_state_display }} - → - {{ event.get_new_state_display }} -

- {% if event.metadata %} -
metadata: {{ event.metadata|pprint }}
- {% endif %} - {% if event.debug_screenshots.all %} -
-
Debug Screenshots:
- {% for screenshot in event.debug_screenshots.all %} - Debug Screenshot - {% if screenshot.metadata %} -
{{ screenshot.metadata|pprint }}
- {% endif %} - {% endfor %} -
- {% endif %} -
- {% endfor %} -
- {% else %} -
No events recorded.
- {% endif %} -
-
-
- -{% endblock %} diff --git a/attendee/bots/templates/projects/project_bots.html b/attendee/bots/templates/projects/project_bots.html deleted file mode 100644 index a4a4304..0000000 --- a/attendee/bots/templates/projects/project_bots.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends 'projects/sidebar.html' %} - -{% block content %} - - -
-

Bots

- -
- {% if bots %} - - - - - - - - - - - {% for bot in bots %} - - - - - - - {% endfor %} - -
IDMeeting URLStatusCreated
{{ bot.object_id }}{{ bot.meeting_url }} - - {{ bot.get_state_display }} - {% if bot.sub_state %} - - {{ bot.get_sub_state_display }} - {% endif %} - - {{ bot.created_at|date:"M d, Y H:i" }}
- {% else %} - - {% endif %} -
-
-{% endblock %} \ No newline at end of file diff --git a/attendee/bots/templates/projects/project_dashboard.html b/attendee/bots/templates/projects/project_dashboard.html deleted file mode 100644 index 3b7443e..0000000 --- a/attendee/bots/templates/projects/project_dashboard.html +++ /dev/null @@ -1,75 +0,0 @@ -{% extends 'projects/sidebar.html' %} - -{% block content %} -
-
-
-
Hi {{ request.user.email }}! 👋
-

Welcome to Attendee - the open source meeting bot API. We're in public beta and our hosted instance is free to use.

-

- If you're building something that needs meeting bots, we'd love to chat! - - Join our Slack or send us an email. And please ⭐ us on Github if you find us useful. - -

-
-
- -
-

Follow these steps to get started 🚀

-
    - -
  1. - 1. Add credentials - {% if not quick_start.has_credentials %} - - {% endif %} -
  2. - - -
  3. - 2. Create an API key - {% if not quick_start.has_api_keys %} - - {% endif %} -
  4. - - -
  5. - 3. Call the API - {% if not quick_start.has_ended_bots %} - -
    - Create bot -
    -curl -X POST \
    -https://app.attendee.dev/api/v1/bots \
    --H 'Authorization: Token <YOUR_API_KEY>' \
    --H 'Content-Type: application/json' \
    --d '{"meeting_url": "https://us05web.zoom.us/j/xxxxxxx?pwd=xxxxxx", "bot_name": "My Bot"}'
    -                                
    -
    -
    - Retrieve transcript -
    -curl -X GET \
    -https://app.attendee.dev/api/v1/bots/<BOT_ID>/transcript \
    --H 'Authorization: Token <YOUR_API_KEY>' \
    --H 'Content-Type: application/json'
    -                                
    -
    - {% endif %} -
  6. -
-
-
- -{% endblock %} \ No newline at end of file diff --git a/attendee/bots/templates/projects/project_settings.html b/attendee/bots/templates/projects/project_settings.html deleted file mode 100644 index cebb8b7..0000000 --- a/attendee/bots/templates/projects/project_settings.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'projects/sidebar.html' %} - -{% block content %} -
-

Project Settings

- - {% include 'projects/partials/zoom_credentials.html' with credential_type=zoom_credential_type credentials=zoom_credentials %} - {% include 'projects/partials/deepgram_credentials.html' with credential_type=deepgram_credential_type credentials=deepgram_credentials %} - {% include 'projects/partials/google_tts_credentials.html' with credential_type=google_tts_credential_type credentials=google_tts_credentials %} - {% include 'projects/partials/video_tutorial_modal.html' with modal_title="Getting Zoom & Deepgram credentials" loom_url="https://www.loom.com/embed/7cbd3eab1bc4438fb1badcb3787996d6?sid=825a92b5-51ca-447c-86c1-c45f5294ec9d" %} -
-{% endblock %} \ No newline at end of file diff --git a/attendee/bots/templates/projects/sidebar.html b/attendee/bots/templates/projects/sidebar.html deleted file mode 100644 index 05e35b3..0000000 --- a/attendee/bots/templates/projects/sidebar.html +++ /dev/null @@ -1,116 +0,0 @@ -{% extends 'base.html' %} -{% load static %} - -{% block body_content %} -
-
- - - - -
- - -
- {% block content %} - {% endblock %} -
-
-
- - - - -{% endblock %} \ No newline at end of file diff --git a/attendee/bots/templatetags/__init__.py b/attendee/bots/templatetags/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/attendee/bots/templatetags/bot_filters.py b/attendee/bots/templatetags/bot_filters.py deleted file mode 100644 index d84b5a0..0000000 --- a/attendee/bots/templatetags/bot_filters.py +++ /dev/null @@ -1,59 +0,0 @@ -import hashlib - -from django import template - -register = template.Library() - - -@register.filter -def modulo(num, val): - return int(num) % val - - -@register.filter -def integer_divide(num, val): - return int(num) // val - - -@register.filter -def get_next(value, current_index): - try: - return value[current_index + 1] - except IndexError: - return value[current_index] # fallback to current item if next doesn't exist - - -@register.filter -def participant_color(uuid): - """Generate a consistent color from a participant's UUID""" - if not uuid: - return "#808080" # Default gray for participants without UUID - - # Generate a hash of the UUID - hash_object = hashlib.md5(str(uuid).encode()) - hash_hex = hash_object.hexdigest() - - # Use the first 6 characters of the hash as a color code - # Adjust brightness to ensure readable colors (avoiding too light or dark) - r = int(hash_hex[:2], 16) - g = int(hash_hex[2:4], 16) - b = int(hash_hex[4:6], 16) - - # Ensure minimum brightness - min_brightness = 64 - r = max(r, min_brightness) - g = max(g, min_brightness) - b = max(b, min_brightness) - - # Ensure maximum brightness - max_brightness = 200 - r = min(r, max_brightness) - g = min(g, max_brightness) - b = min(b, max_brightness) - - return f"#{r:02x}{g:02x}{b:02x}" - - -@register.filter -def md5(value): - return hashlib.md5(str(value).encode()).hexdigest() diff --git a/attendee/bots/tests/__init__.py b/attendee/bots/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/attendee/bots/tests/mock_data.py b/attendee/bots/tests/mock_data.py deleted file mode 100644 index 183a2ea..0000000 --- a/attendee/bots/tests/mock_data.py +++ /dev/null @@ -1,79 +0,0 @@ -import time - - -class MockVideoFrame: - def __init__(self): - width = 640 - height = 360 - - # Create separate Y, U, and V planes - self.y_buffer = b"\x00" * (width * height) # Y plane (black) - self.u_buffer = b"\x80" * (width * height // 4) # U plane (128 for black) - self.v_buffer = b"\x80" * (width * height // 4) # V plane (128 for black) - - self.size = len(self.y_buffer) + len(self.u_buffer) + len(self.v_buffer) - self.timestamp = int(time.time() * 1000) # Current time in milliseconds - - def GetBuffer(self): - return self.y_buffer + self.u_buffer + self.v_buffer - - def GetYBuffer(self): - return self.y_buffer - - def GetUBuffer(self): - return self.u_buffer - - def GetVBuffer(self): - return self.v_buffer - - def GetStreamWidth(self): - return 640 - - def GetStreamHeight(self): - return 360 - - -class MockPCMAudioFrame: - def __init__(self): - # Create 10ms of a 440Hz sine wave at 32000Hz mono - # 32000 samples/sec * 0.01 sec = 320 samples - # Each sample is 2 bytes (unsigned 16-bit) - import math - - samples = [] - for i in range(320): # 10ms worth of samples at 32kHz - # Generate sine wave with frequency 440Hz - t = i / 32000.0 # time in seconds - # Generate value between 0 and 65535 (unsigned 16-bit) - # Center at 32768, use amplitude of 16384 to avoid clipping - value = int(32768 + 16384 * math.sin(2 * math.pi * 440 * t)) - # Ensure value stays within valid range - value = max(0, min(65535, value)) - # Convert to two bytes (little-endian) - samples.extend([value & 0xFF, (value >> 8) & 0xFF]) - self.buffer = bytes(samples) - - def GetBuffer(self): - return self.buffer - - -class MockF32AudioFrame: - def __init__(self): - # Create 10ms of a 440Hz sine wave at 48000Hz mono - # 48000 samples/sec * 0.01 sec = 480 samples - # Each sample is a 32-bit float between -1.0 and 1.0 - import math - import struct - - samples = [] - for i in range(480): # 10ms worth of samples at 48kHz - # Generate sine wave with frequency 440Hz - t = i / 48000.0 # time in seconds - # Generate value between -1.0 and 1.0 - value = 0.8 * math.sin(2 * math.pi * 440 * t) # Using 0.8 amplitude to avoid clipping - # Pack float to bytes (4 bytes per sample) - samples.extend(struct.pack("f", value)) - self.buffer = bytes(samples) - - def GetBuffer(self): - return self.buffer diff --git a/attendee/bots/tests/test_can_open_chrome.py b/attendee/bots/tests/test_can_open_chrome.py deleted file mode 100644 index d5a4cc8..0000000 --- a/attendee/bots/tests/test_can_open_chrome.py +++ /dev/null @@ -1,43 +0,0 @@ -import os - -import undetected_chromedriver as uc -from django.test.testcases import TransactionTestCase -from pyvirtualdisplay import Display - - -class TestChromeDriver(TransactionTestCase): - def test_can_open_google(self): - # Create virtual display if no real display is available - if os.environ.get("DISPLAY") is None: - display = Display(visible=0, size=(1920, 1080)) - display.start() - - try: - # Set up Chrome options - options = uc.ChromeOptions() - options.add_argument("--no-sandbox") - options.add_argument("--disable-setuid-sandbox") - options.add_argument("--disable-gpu") - options.add_argument("--disable-extensions") - options.add_argument("--disable-application-cache") - options.add_argument("--disable-dev-shm-usage") - - # Initialize Chrome driver - driver = uc.Chrome(use_subprocess=True, options=options, version_main=133) - - try: - # Load Google - driver.get("https://www.google.com") - - # Verify we can find the Google search box - search_box = driver.find_element("name", "q") - - # Basic assertion that we found the search box - self.assertIsNotNone(search_box) - - finally: - # Clean up driver - driver.quit() - - except Exception as e: - self.fail(f"Failed to open Chrome and load Google: {str(e)}") diff --git a/attendee/bots/tests/test_google_meet_bot.py b/attendee/bots/tests/test_google_meet_bot.py deleted file mode 100644 index 875a6c4..0000000 --- a/attendee/bots/tests/test_google_meet_bot.py +++ /dev/null @@ -1,361 +0,0 @@ -import os -import threading -import time -from unittest.mock import MagicMock, call, patch - -import kubernetes -import numpy as np -from django.db import connection -from django.test.testcases import TransactionTestCase -from django.utils import timezone - -from bots.bot_adapter import BotAdapter -from bots.bot_controller import BotController -from bots.models import ( - Bot, - BotEventManager, - BotEventSubTypes, - BotEventTypes, - BotStates, - Organization, - Project, - Recording, - RecordingStates, - RecordingTypes, - TranscriptionProviders, - TranscriptionTypes, - Utterance, -) - -from .mock_data import MockF32AudioFrame - - -def create_mock_file_uploader(): - mock_file_uploader = MagicMock() - mock_file_uploader.upload_file.return_value = None - mock_file_uploader.wait_for_upload.return_value = None - mock_file_uploader.delete_file.return_value = None - mock_file_uploader.key = "test-recording-key" - return mock_file_uploader - - -def create_mock_google_meet_driver(): - mock_driver = MagicMock() - mock_driver.set_window_size.return_value = None - mock_driver.execute_script.side_effect = [ - None, # First call (window.ws.enableMediaSending()) - 12345, # Second call (performance.timeOrigin) - ] - mock_driver.save_screenshot.return_value = None - return mock_driver - - -class TestGoogleMeetBot(TransactionTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Set required environment variables - os.environ["AWS_RECORDING_STORAGE_BUCKET_NAME"] = "test-bucket" - - def setUp(self): - # Recreate organization and project for each test - self.organization = Organization.objects.create(name="Test Org") - self.project = Project.objects.create(name="Test Project", organization=self.organization) - - # Create a bot for each test - self.bot = Bot.objects.create( - project=self.project, - name="Test Bot", - meeting_url="https://meet.google.com/abc-defg-hij", - ) - - # Create default recording - self.recording = Recording.objects.create( - bot=self.bot, - recording_type=RecordingTypes.AUDIO_AND_VIDEO, - transcription_type=TranscriptionTypes.NON_REALTIME, - transcription_provider=TranscriptionProviders.DEEPGRAM, - is_default_recording=True, - ) - - # Try to transition the state from READY to JOINING - BotEventManager.create_event(self.bot, BotEventTypes.JOIN_REQUESTED) - - # Configure Celery to run tasks eagerly (synchronously) - from django.conf import settings - - settings.CELERY_TASK_ALWAYS_EAGER = True - settings.CELERY_TASK_EAGER_PROPAGATES = True - - @patch("bots.web_bot_adapter.web_bot_adapter.Display") - @patch("bots.web_bot_adapter.web_bot_adapter.uc.Chrome") - @patch("bots.bot_controller.bot_controller.FileUploader") - def test_google_meet_bot_can_join_meeting_and_record_audio_and_video( - self, - MockFileUploader, - MockChromeDriver, - MockDisplay, - ): - # Configure the mock uploader - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the Chrome driver - mock_driver = create_mock_google_meet_driver() - MockChromeDriver.return_value = mock_driver - - # Mock virtual display - mock_display = MagicMock() - MockDisplay.return_value = mock_display - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_join_flow(): - # Sleep to allow initialization - time.sleep(2) - - # Add participants - simulate websocket message processing - controller.adapter.participants_info["user1"] = {"deviceId": "user1", "fullName": "Test User", "active": True} - - # Simulate audio data arrival - fake a float32 array of 1000 samples - # Create a mock audio message in the format expected by process_audio_frame - mock_audio_message = bytearray() - # Add message type (3 for AUDIO) as first 4 bytes - mock_audio_message.extend((3).to_bytes(4, byteorder="little")) - # Add timestamp (12345) as next 8 bytes - mock_audio_message.extend((12345).to_bytes(8, byteorder="little")) - # Add stream ID (0) as next 4 bytes - mock_audio_message.extend((0).to_bytes(4, byteorder="little")) - # Add mock audio data (1000 float32 samples) - mock_audio_message.extend(MockF32AudioFrame().GetBuffer()) - - controller.adapter.process_audio_frame(mock_audio_message) - - # Simulate video data arrival - # Create a mock video message in the format expected by process_video_frame - mock_video_frame = create_mock_video_frame() - controller.adapter.process_video_frame(mock_video_frame) - - # Simulate caption data arrival - caption_data = {"captionId": "caption1", "deviceId": "user1", "text": "This is a test caption"} - controller.closed_caption_manager.upsert_caption(caption_data) - - # Process these events - time.sleep(2) - - # Simulate flushing captions - normally done before leaving - controller.closed_caption_manager.flush_captions() - - # Simulate meeting ended - controller.on_message_from_adapter({"message": BotAdapter.Messages.MEETING_ENDED}) - - # Clean up connections in thread - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(2, simulate_join_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Assert that the bot is in the ENDED state - self.assertEqual(self.bot.state, BotStates.ENDED) - - # Verify bot events in sequence - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 5) # We expect 5 events in total - - # Verify join_requested_event (Event 1) - join_requested_event = bot_events[0] - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - - # Verify bot_joined_meeting_event (Event 2) - bot_joined_meeting_event = bot_events[1] - self.assertEqual(bot_joined_meeting_event.event_type, BotEventTypes.BOT_JOINED_MEETING) - self.assertEqual(bot_joined_meeting_event.old_state, BotStates.JOINING) - self.assertEqual(bot_joined_meeting_event.new_state, BotStates.JOINED_NOT_RECORDING) - - # Verify recording_permission_granted_event (Event 3) - recording_permission_granted_event = bot_events[2] - self.assertEqual( - recording_permission_granted_event.event_type, - BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED, - ) - self.assertEqual(recording_permission_granted_event.old_state, BotStates.JOINED_NOT_RECORDING) - self.assertEqual(recording_permission_granted_event.new_state, BotStates.JOINED_RECORDING) - - # Verify meeting_ended_event (Event 4) - meeting_ended_event = bot_events[3] - self.assertEqual(meeting_ended_event.event_type, BotEventTypes.MEETING_ENDED) - self.assertEqual(meeting_ended_event.old_state, BotStates.JOINED_RECORDING) - self.assertEqual(meeting_ended_event.new_state, BotStates.POST_PROCESSING) - - # Verify post_processing_completed_event (Event 5) - post_processing_completed_event = bot_events[4] - self.assertEqual(post_processing_completed_event.event_type, BotEventTypes.POST_PROCESSING_COMPLETED) - self.assertEqual(post_processing_completed_event.old_state, BotStates.POST_PROCESSING) - self.assertEqual(post_processing_completed_event.new_state, BotStates.ENDED) - - # Verify that the recording was finished - self.recording.refresh_from_db() - self.assertEqual(self.recording.state, RecordingStates.COMPLETE) - - # Verify captions were processed - utterances = Utterance.objects.filter(recording=self.recording) - self.assertGreater(utterances.count(), 0) - - # Verify a caption utterance exists with the correct text - caption_utterance = utterances.filter(source=Utterance.Sources.CLOSED_CAPTION_FROM_PLATFORM).first() - self.assertIsNotNone(caption_utterance) - self.assertEqual(caption_utterance.transcription.get("transcript"), "This is a test caption") - - # Verify driver was set up with the correct window size - mock_driver.set_window_size.assert_called_with(1920 / 2, 1080 / 2) - - # Verify WebSocket media sending was enabled and performance.timeOrigin was queried - mock_driver.execute_script.assert_has_calls([call("window.ws?.enableMediaSending();"), call("return performance.timeOrigin;")]) - - # Verify first_buffer_timestamp_ms_offset was set correctly - self.assertEqual(controller.adapter.get_first_buffer_timestamp_ms_offset(), 12345) - - # Verify file uploader was used - mock_uploader.upload_file.assert_called_once() - self.assertGreater(mock_uploader.upload_file.call_count, 0) - mock_uploader.wait_for_upload.assert_called_once() - mock_uploader.delete_file.assert_called_once() - # Cleanup - controller.cleanup() - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch("kubernetes.client.CoreV1Api") - @patch("kubernetes.config.load_incluster_config") - @patch("kubernetes.config.load_kube_config") - def test_terminate_bots_with_heartbeat_timeout(self, mock_load_kube_config, mock_load_incluster_config, MockCoreV1Api): - # Set up mock Kubernetes API - mock_k8s_api = MagicMock() - MockCoreV1Api.return_value = mock_k8s_api - - # Set up config.load_incluster_config to raise ConfigException so load_kube_config gets called - mock_load_incluster_config.side_effect = kubernetes.config.config_exception.ConfigException("Mock ConfigException") - - # Create a bot with a stale heartbeat (more than 10 minutes old) - current_time = int(timezone.now().timestamp()) - eleven_minutes_ago = current_time - 660 # 11 minutes ago - - # Set the bot's heartbeat timestamps - self.bot.first_heartbeat_timestamp = eleven_minutes_ago - self.bot.last_heartbeat_timestamp = eleven_minutes_ago - self.bot.state = BotStates.JOINED_RECORDING # Set to a non-terminal state - self.bot.save() - - # Set bot launch method to kubernetes - with patch.dict(os.environ, {"LAUNCH_BOT_METHOD": "kubernetes"}): - # Import and run the command - from bots.management.commands.terminate_bots_with_heartbeat_timeout import Command - - command = Command() - command.handle() - - # Refresh the bot state from the database - self.bot.refresh_from_db() - - # Verify the bot was moved to FATAL_ERROR state - self.assertEqual(self.bot.state, BotStates.FATAL_ERROR) - - # Verify that a FATAL_ERROR event was created with the correct sub type - fatal_error_event = self.bot.bot_events.filter(event_type=BotEventTypes.FATAL_ERROR, event_sub_type=BotEventSubTypes.FATAL_ERROR_HEARTBEAT_TIMEOUT).first() - self.assertIsNotNone(fatal_error_event) - self.assertEqual(fatal_error_event.old_state, BotStates.JOINED_RECORDING) - self.assertEqual(fatal_error_event.new_state, BotStates.FATAL_ERROR) - - # Verify Kubernetes pod deletion was attempted with the correct pod name - pod_name = self.bot.k8s_pod_name() - mock_k8s_api.delete_namespaced_pod.assert_called_once_with(name=pod_name, namespace="attendee", grace_period_seconds=0) - - def test_bots_with_recent_heartbeat_not_terminated(self): - # Create a bot with a recent heartbeat (9 minutes old) - current_time = int(timezone.now().timestamp()) - nine_minutes_ago = current_time - 540 # 9 minutes ago - - # Set the bot's heartbeat timestamps - self.bot.first_heartbeat_timestamp = nine_minutes_ago - self.bot.last_heartbeat_timestamp = nine_minutes_ago - self.bot.state = BotStates.JOINED_RECORDING # Set to a non-terminal state - self.bot.save() - - # Import and run the command - from bots.management.commands.terminate_bots_with_heartbeat_timeout import Command - - command = Command() - command.handle() - - # Refresh the bot state from the database - self.bot.refresh_from_db() - - # Verify the bot was NOT moved to FATAL_ERROR state - self.assertEqual(self.bot.state, BotStates.JOINED_RECORDING) - - # Verify that no FATAL_ERROR event was created with heartbeat timeout subtype - fatal_error_event = self.bot.bot_events.filter(event_type=BotEventTypes.FATAL_ERROR, event_sub_type=BotEventSubTypes.FATAL_ERROR_HEARTBEAT_TIMEOUT).first() - self.assertIsNone(fatal_error_event) - - -# Simulate video data arrival -# Create a mock video message in the format expected by process_video_frame -def create_mock_video_frame(width=640, height=480): - # Create a bytearray for the message - mock_video_message = bytearray() - - # Add message type (2 for VIDEO) as first 4 bytes - mock_video_message.extend((2).to_bytes(4, byteorder="little")) - - # Add timestamp (12345) as next 8 bytes - mock_video_message.extend((12345).to_bytes(8, byteorder="little")) - - # Add stream ID length (4) and stream ID ("main") - total 8 bytes - stream_id = "main" - mock_video_message.extend(len(stream_id).to_bytes(4, byteorder="little")) - mock_video_message.extend(stream_id.encode("utf-8")) - - # Add width and height - 8 bytes - mock_video_message.extend(width.to_bytes(4, byteorder="little")) - mock_video_message.extend(height.to_bytes(4, byteorder="little")) - - # Create I420 frame data (Y, U, V planes) - # Y plane: width * height bytes - y_plane_size = width * height - y_plane = np.ones(y_plane_size, dtype=np.uint8) * 128 # mid-gray - - # U and V planes: (width//2 * height//2) bytes each - uv_width = (width + 1) // 2 # half_ceil implementation - uv_height = (height + 1) // 2 - uv_plane_size = uv_width * uv_height - - u_plane = np.ones(uv_plane_size, dtype=np.uint8) * 128 # no color tint - v_plane = np.ones(uv_plane_size, dtype=np.uint8) * 128 # no color tint - - # Add the frame data to the message - mock_video_message.extend(y_plane.tobytes()) - mock_video_message.extend(u_plane.tobytes()) - mock_video_message.extend(v_plane.tobytes()) - - return mock_video_message diff --git a/attendee/bots/tests/test_zoom_bot.py b/attendee/bots/tests/test_zoom_bot.py deleted file mode 100644 index 5199b2c..0000000 --- a/attendee/bots/tests/test_zoom_bot.py +++ /dev/null @@ -1,1965 +0,0 @@ -import base64 -import json -import os -import threading -import time -from unittest.mock import MagicMock, call, patch - -import zoom_meeting_sdk as zoom -from django.db import connection -from django.test.testcases import TransactionTestCase - -from bots.bot_controller import BotController -from bots.bot_controller.automatic_leave_configuration import AutomaticLeaveConfiguration -from bots.bot_controller.file_uploader import FileUploader -from bots.bot_controller.pipeline_configuration import PipelineConfiguration -from bots.bots_api_views import send_sync_command -from bots.models import ( - Bot, - BotEventManager, - BotEventSubTypes, - BotEventTypes, - BotMediaRequest, - BotMediaRequestMediaTypes, - BotMediaRequestStates, - BotStates, - Credentials, - MediaBlob, - Organization, - Project, - Recording, - RecordingFormats, - RecordingStates, - RecordingTranscriptionStates, - RecordingTypes, - TranscriptionProviders, - TranscriptionTypes, -) -from bots.utils import mp3_to_pcm, png_to_yuv420_frame - -from .mock_data import MockPCMAudioFrame, MockVideoFrame - - -def create_mock_file_uploader(): - mock_file_uploader = MagicMock(spec=FileUploader) - mock_file_uploader.upload_file.return_value = None - mock_file_uploader.wait_for_upload.return_value = None - mock_file_uploader.delete_file.return_value = None - mock_file_uploader.key = "test-recording-key" # Simple string attribute - return mock_file_uploader - - -def create_mock_zoom_sdk(): - # Create mock zoom_meeting_sdk module with proper callback handling - base_mock = MagicMock() - - class MeetingFailCode: - MEETING_FAIL_BLOCKED_BY_ACCOUNT_ADMIN = "100" - MEETING_FAIL_UNABLE_TO_JOIN_EXTERNAL_MEETING = zoom.MeetingFailCode.MEETING_FAIL_UNABLE_TO_JOIN_EXTERNAL_MEETING - - base_mock.MeetingFailCode = MeetingFailCode - - # Create a custom ZoomSDKRendererDelegateCallbacks class that actually stores the callback - class MockZoomSDKRendererDelegateCallbacks: - def __init__( - self, - onRawDataFrameReceivedCallback, - onRendererBeDestroyedCallback, - onRawDataStatusChangedCallback, - ): - self.stored_callback = onRawDataFrameReceivedCallback - self.stored_renderer_destroyed_callback = onRendererBeDestroyedCallback - self.stored_raw_data_status_changed_callback = onRawDataStatusChangedCallback - - def onRawDataFrameReceivedCallback(self, data): - return self.stored_callback(data) - - def onRendererBeDestroyedCallback(self): - return self.stored_renderer_destroyed_callback() - - def onRawDataStatusChangedCallback(self, status): - return self.stored_raw_data_status_changed_callback(status) - - base_mock.ZoomSDKRendererDelegateCallbacks = MockZoomSDKRendererDelegateCallbacks - - # Create a custom MeetingRecordingCtrlEventCallbacks class that actually stores the callback - class MockMeetingRecordingCtrlEventCallbacks: - def __init__(self, onRecordPrivilegeChangedCallback): - self.stored_callback = onRecordPrivilegeChangedCallback - - def onRecordPrivilegeChangedCallback(self, can_record): - return self.stored_callback(can_record) - - base_mock.MeetingRecordingCtrlEventCallbacks = MockMeetingRecordingCtrlEventCallbacks - - # Create a custom AuthServiceEventCallbacks class that actually stores the callback - class MockAuthServiceEventCallbacks: - def __init__(self, onAuthenticationReturnCallback): - self.stored_callback = onAuthenticationReturnCallback - - def onAuthenticationReturnCallback(self, result): - return self.stored_callback(result) - - # Replace the mock's AuthServiceEventCallbacks with our custom version - base_mock.AuthServiceEventCallbacks = MockAuthServiceEventCallbacks - - # Create a custom MeetingServiceEventCallbacks class that actually stores the callback - class MockMeetingServiceEventCallbacks: - def __init__(self, onMeetingStatusChangedCallback): - self.stored_callback = onMeetingStatusChangedCallback - - def onMeetingStatusChangedCallback(self, status, result): - return self.stored_callback(status, result) - - # Replace the mock's MeetingServiceEventCallbacks with our custom version - base_mock.MeetingServiceEventCallbacks = MockMeetingServiceEventCallbacks - - # Set up constants - base_mock.SDKERR_SUCCESS = zoom.SDKError.SDKERR_SUCCESS - base_mock.AUTHRET_SUCCESS = zoom.AuthResult.AUTHRET_SUCCESS - base_mock.MEETING_STATUS_IDLE = zoom.MeetingStatus.MEETING_STATUS_IDLE - base_mock.MEETING_STATUS_CONNECTING = zoom.MeetingStatus.MEETING_STATUS_CONNECTING - base_mock.MEETING_STATUS_INMEETING = zoom.MeetingStatus.MEETING_STATUS_INMEETING - base_mock.MEETING_STATUS_ENDED = zoom.MeetingStatus.MEETING_STATUS_ENDED - base_mock.LEAVE_MEETING = zoom.LeaveMeetingCmd.LEAVE_MEETING - base_mock.AUTHRET_JWTTOKENWRONG = zoom.AuthResult.AUTHRET_JWTTOKENWRONG - - # Mock SDK_LANGUAGE_ID - base_mock.SDK_LANGUAGE_ID = MagicMock() - base_mock.SDK_LANGUAGE_ID.LANGUAGE_English = zoom.SDK_LANGUAGE_ID.LANGUAGE_English - - # Mock SDKAudioChannel - base_mock.ZoomSDKAudioChannel_Mono = zoom.ZoomSDKAudioChannel.ZoomSDKAudioChannel_Mono - - # Mock SDKUserType - base_mock.SDKUserType = MagicMock() - base_mock.SDKUserType.SDK_UT_WITHOUT_LOGIN = zoom.SDKUserType.SDK_UT_WITHOUT_LOGIN - - # Create mock services - mock_meeting_service = MagicMock() - mock_auth_service = MagicMock() - mock_setting_service = MagicMock() - mock_zoom_sdk_renderer = MagicMock() - - # Configure mock services - mock_meeting_service.SetEvent.return_value = base_mock.SDKERR_SUCCESS - mock_meeting_service.Join.return_value = base_mock.SDKERR_SUCCESS - mock_meeting_service.GetMeetingStatus.return_value = base_mock.MEETING_STATUS_IDLE - mock_meeting_service.Leave.return_value = base_mock.SDKERR_SUCCESS - - # Add mock recording controller - mock_recording_controller = MagicMock() - mock_recording_controller.CanStartRawRecording.return_value = base_mock.SDKERR_SUCCESS - mock_recording_controller.StartRawRecording.return_value = base_mock.SDKERR_SUCCESS - mock_meeting_service.GetMeetingRecordingController.return_value = mock_recording_controller - - mock_auth_service.SetEvent.return_value = base_mock.SDKERR_SUCCESS - mock_auth_service.SDKAuth.return_value = base_mock.SDKERR_SUCCESS - - # Configure service creation functions - base_mock.CreateMeetingService.return_value = mock_meeting_service - base_mock.CreateAuthService.return_value = mock_auth_service - base_mock.CreateSettingService.return_value = mock_setting_service - base_mock.CreateRenderer.return_value = mock_zoom_sdk_renderer - - # Configure InitSDK - base_mock.InitSDK.return_value = base_mock.SDKERR_SUCCESS - - # Add SDKError class mock with SDKERR_SUCCESS - base_mock.SDKError = MagicMock() - base_mock.SDKError.SDKERR_SUCCESS = zoom.SDKError.SDKERR_SUCCESS - base_mock.SDKError.SDKERR_INTERNAL_ERROR = zoom.SDKError.SDKERR_INTERNAL_ERROR - - # Create a mock PerformanceData class - class MockPerformanceData: - def __init__(self): - self.totalProcessingTimeMicroseconds = 1000 - self.numCalls = 100 - self.maxProcessingTimeMicroseconds = 20 - self.minProcessingTimeMicroseconds = 5 - self.processingTimeBinMin = 0 - self.processingTimeBinMax = 100 - self.processingTimeBinCounts = [ - 10, - 20, - 30, - 20, - 10, - 5, - 3, - 2, - ] # Example distribution - - # Create a custom ZoomSDKAudioRawDataDelegateCallbacks class that actually stores the callback - class MockZoomSDKAudioRawDataDelegateCallbacks: - def __init__( - self, - onOneWayAudioRawDataReceivedCallback, - onMixedAudioRawDataReceivedCallback, - collectPerformanceData=False, - ): - self.stored_one_way_callback = onOneWayAudioRawDataReceivedCallback - self.stored_mixed_callback = onMixedAudioRawDataReceivedCallback - self.collect_performance_data = collectPerformanceData - - def onOneWayAudioRawDataReceivedCallback(self, data, node_id): - return self.stored_one_way_callback(data, node_id) - - def onMixedAudioRawDataReceivedCallback(self, data): - return self.stored_mixed_callback(data) - - def getPerformanceData(self): - return MockPerformanceData() - - base_mock.ZoomSDKAudioRawDataDelegateCallbacks = MockZoomSDKAudioRawDataDelegateCallbacks - - class MockZoomSDKVirtualAudioMicEventCallbacks: - def __init__(self, onMicInitializeCallback, onMicStartSendCallback): - self.stored_initialize_callback = onMicInitializeCallback - self.stored_start_send_callback = onMicStartSendCallback - - def onMicInitializeCallback(self, sender): - return self.stored_initialize_callback(sender) - - def onMicStartSendCallback(self): - return self.stored_start_send_callback() - - base_mock.ZoomSDKVirtualAudioMicEventCallbacks = MockZoomSDKVirtualAudioMicEventCallbacks - - class MockZoomSDKVideoSourceCallbacks: - def __init__(self, onInitializeCallback, onStartSendCallback): - self.stored_initialize_callback = onInitializeCallback - self.stored_start_send_callback = onStartSendCallback - - def onInitializeCallback(self, sender, support_cap_list, suggest_cap): - return self.stored_initialize_callback(sender, support_cap_list, suggest_cap) - - def onStartSendCallback(self): - return self.stored_start_send_callback() - - base_mock.ZoomSDKVideoSourceCallbacks = MockZoomSDKVideoSourceCallbacks - - # Create a mock participant class - class MockParticipant: - def __init__(self, user_id, user_name, persistent_id): - self._user_id = user_id - self._user_name = user_name - self._persistent_id = persistent_id - - def GetUserID(self): - return self._user_id - - def GetUserName(self): - return self._user_name - - def GetPersistentId(self): - return self._persistent_id - - # Create a mock participants controller - mock_participants_controller = MagicMock() - mock_participants_controller.GetParticipantsList.return_value = [2] # Return test user ID - mock_participants_controller.GetUserByUserID.return_value = MockParticipant(2, "Test User", "test_persistent_id_123") - mock_participants_controller.GetMySelfUser.return_value = MockParticipant(1, "Bot User", "bot_persistent_id") - - # Add participants controller to meeting service - mock_meeting_service.GetMeetingParticipantsController.return_value = mock_participants_controller - - return base_mock - - -def create_mock_deepgram(): - # Create mock objects - mock_deepgram = MagicMock() - mock_response = MagicMock() - mock_results = MagicMock() - mock_channel = MagicMock() - mock_alternative = MagicMock() - - # Set up the mock response structure - mock_alternative.to_json.return_value = json.dumps( - { - "transcript": "This is a test transcript", - "confidence": 0.95, - "words": [ - {"word": "This", "start": 0.0, "end": 0.2, "confidence": 0.98}, - {"word": "is", "start": 0.2, "end": 0.4, "confidence": 0.97}, - {"word": "a", "start": 0.4, "end": 0.5, "confidence": 0.99}, - {"word": "test", "start": 0.5, "end": 0.8, "confidence": 0.96}, - {"word": "transcript", "start": 0.8, "end": 1.2, "confidence": 0.94}, - ], - } - ) - mock_channel.alternatives = [mock_alternative] - mock_results.channels = [mock_channel] - mock_response.results = mock_results - - # Set up the mock client - mock_deepgram.listen.rest.v.return_value.transcribe_file.return_value = mock_response - return mock_deepgram - - -class TestZoomBot(TransactionTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - - # Set required environment variables - os.environ["AWS_RECORDING_STORAGE_BUCKET_NAME"] = "test-bucket" - - def setUp(self): - # Recreate organization and project for each test - self.organization = Organization.objects.create(name="Test Org") - self.project = Project.objects.create(name="Test Project", organization=self.organization) - - # Recreate credentials - self.credentials = Credentials.objects.create(project=self.project, credential_type=Credentials.CredentialTypes.ZOOM_OAUTH) - self.credentials.set_credentials({"client_id": "test_client_id", "client_secret": "test_client_secret"}) - self.deepgram_credentials = Credentials.objects.create(project=self.project, credential_type=Credentials.CredentialTypes.DEEPGRAM) - self.deepgram_credentials.set_credentials({"api_key": "test_api_key"}) - self.google_credentials = Credentials.objects.create(project=self.project, credential_type=Credentials.CredentialTypes.GOOGLE_TTS) - self.google_credentials.set_credentials({"service_account_json": '{"type": "service_account", "project_id": "test-project", "private_key_id": "test-private-key-id", "private_key": "test-private-key", "client_email": "test-client-email", "client_id": "test-client-id", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test-client-email"}'}) - - # Create a bot for each test - self.bot = Bot.objects.create( - project=self.project, - name="Test Bot", - meeting_url="https://zoom.us/j/123456789?pwd=password123", - ) - - # Create default recording - self.recording = Recording.objects.create( - bot=self.bot, - recording_type=RecordingTypes.AUDIO_AND_VIDEO, - transcription_type=TranscriptionTypes.NON_REALTIME, - transcription_provider=TranscriptionProviders.DEEPGRAM, - is_default_recording=True, - ) - - # Try to transition the state from READY to JOINING - BotEventManager.create_event(self.bot, BotEventTypes.JOIN_REQUESTED) - - self.test_mp3_bytes = base64.b64decode("SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU2LjM2LjEwMAAAAAAAAAAAAAAA//OEAAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAAEAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU2LjQxAAAAAAAAAAAAAAAAJAAAAAAAAAAAASDs90hvAAAAAAAAAAAAAAAAAAAA//MUZAAAAAGkAAAAAAAAA0gAAAAATEFN//MUZAMAAAGkAAAAAAAAA0gAAAAARTMu//MUZAYAAAGkAAAAAAAAA0gAAAAAOTku//MUZAkAAAGkAAAAAAAAA0gAAAAANVVV") - self.test_png_bytes = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==") - - self.audio_blob = MediaBlob.get_or_create_from_blob(project=self.bot.project, blob=self.test_mp3_bytes, content_type="audio/mp3") - - self.image_blob = MediaBlob.get_or_create_from_blob(project=self.bot.project, blob=self.test_png_bytes, content_type="image/png") - - # Configure Celery to run tasks eagerly (synchronously) - from django.conf import settings - - settings.CELERY_TASK_ALWAYS_EAGER = True - settings.CELERY_TASK_EAGER_PROPAGATES = True - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - @patch("deepgram.DeepgramClient") - def test_bot_can_wait_for_host_then_join_meeting( - self, - MockDeepgramClient, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Set up Deepgram mock - MockDeepgramClient.return_value = create_mock_deepgram() - - # Configure the mock uploader - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Create bot controller with a very short wait time - controller = BotController(self.bot.id) - controller.automatic_leave_configuration = AutomaticLeaveConfiguration(wait_for_host_to_start_meeting_timeout_seconds=2) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_join_flow(): - adapter = controller.adapter - # Simulate successful auth - adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - - # Configure GetMeetingStatus to return waiting for host status - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_WAITINGFORHOST - - # Simulate waiting for host - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_WAITINGFORHOST, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Sleep for 1 second (less than the timeout) - time.sleep(1) - - # Update GetMeetingStatus to return connecting status - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING - - # Simulate connecting - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Update GetMeetingStatus to return in-meeting status - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING - - # Simulate successful join - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Wait for the video input manager to be set up - time.sleep(2) - - # Simulate meeting ended - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_ENDED, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Clean up connections in thread - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(2, simulate_join_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Assert that the bot is in the ENDED state - self.assertEqual(self.bot.state, BotStates.ENDED) - - # Verify all bot events in sequence - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 5) # We expect 5 events in total - - # Verify join_requested_event (Event 1) - join_requested_event = bot_events[0] - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - - # Verify bot_joined_meeting_event (Event 2) - bot_joined_meeting_event = bot_events[1] - self.assertEqual(bot_joined_meeting_event.event_type, BotEventTypes.BOT_JOINED_MEETING) - self.assertEqual(bot_joined_meeting_event.old_state, BotStates.JOINING) - self.assertEqual(bot_joined_meeting_event.new_state, BotStates.JOINED_NOT_RECORDING) - - # Verify recording_permission_granted_event (Event 3) - recording_permission_granted_event = bot_events[2] - self.assertEqual(recording_permission_granted_event.event_type, BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED) - self.assertEqual(recording_permission_granted_event.old_state, BotStates.JOINED_NOT_RECORDING) - self.assertEqual(recording_permission_granted_event.new_state, BotStates.JOINED_RECORDING) - - # Verify meeting_ended_event (Event 4) - meeting_ended_event = bot_events[3] - self.assertEqual(meeting_ended_event.event_type, BotEventTypes.MEETING_ENDED) - self.assertEqual(meeting_ended_event.old_state, BotStates.JOINED_RECORDING) - self.assertEqual(meeting_ended_event.new_state, BotStates.POST_PROCESSING) - - # Verify post_processing_completed_event (Event 5) - post_processing_completed_event = bot_events[4] - self.assertEqual(post_processing_completed_event.event_type, BotEventTypes.POST_PROCESSING_COMPLETED) - self.assertEqual(post_processing_completed_event.old_state, BotStates.POST_PROCESSING) - self.assertEqual(post_processing_completed_event.new_state, BotStates.ENDED) - - # Verify expected SDK calls - mock_zoom_sdk_adapter.InitSDK.assert_called_once() - mock_zoom_sdk_adapter.CreateMeetingService.assert_called_once() - mock_zoom_sdk_adapter.CreateAuthService.assert_called_once() - controller.adapter.meeting_service.Join.assert_called_once() - - # Cleanup - controller.cleanup() - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - @patch("deepgram.DeepgramClient") - @patch("time.time") - def test_bot_auto_leaves_meeting_after_silence_threshold( - self, - mock_time, - MockDeepgramClient, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Set up Deepgram mock - MockDeepgramClient.return_value = create_mock_deepgram() - - # Configure the mock uploader - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Set initial time - current_time = 1000.0 - mock_time.return_value = current_time - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_join_flow(): - adapter = controller.adapter - # Simulate successful auth - adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - - # Configure GetMeetingStatus to return the correct status - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING - - # Simulate connecting - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Update GetMeetingStatus to return in-meeting status - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING - - # Simulate successful join - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Wait for the video input manager to be set up - time.sleep(2) - - # Simulate receiving some initial audio - adapter.audio_source.onOneWayAudioRawDataReceivedCallback( - MockPCMAudioFrame(), - 2, # Simulated participant ID that's not the bot - ) - - # Advance time past silence threshold (300 seconds) - nonlocal current_time - current_time += 301 - mock_time.return_value = current_time - - # Trigger check of auto-leave conditions - adapter.check_auto_leave_conditions() - - # Sleep to allow for event processing - time.sleep(2) - - # Update GetMeetingStatus to return ended status when meeting ends - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_ENDED - - # Simulate meeting ended after auto-leave - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_ENDED, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Clean up connections in thread - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(2, simulate_join_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Assert that the bot is in the ENDED state - self.assertEqual(self.bot.state, BotStates.ENDED) - - # Verify bot events in sequence - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 6) # We expect 6 events in total - - # Verify join_requested_event (Event 1) - join_requested_event = bot_events[0] - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - - # Verify bot_joined_meeting_event (Event 2) - bot_joined_meeting_event = bot_events[1] - self.assertEqual(bot_joined_meeting_event.event_type, BotEventTypes.BOT_JOINED_MEETING) - self.assertEqual(bot_joined_meeting_event.old_state, BotStates.JOINING) - self.assertEqual(bot_joined_meeting_event.new_state, BotStates.JOINED_NOT_RECORDING) - - # Verify recording_permission_granted_event (Event 3) - recording_permission_granted_event = bot_events[2] - self.assertEqual( - recording_permission_granted_event.event_type, - BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED, - ) - self.assertEqual(recording_permission_granted_event.old_state, BotStates.JOINED_NOT_RECORDING) - self.assertEqual(recording_permission_granted_event.new_state, BotStates.JOINED_RECORDING) - - # Verify leave_requested_event (Event 4) - leave_requested_event = bot_events[3] - self.assertEqual(leave_requested_event.event_type, BotEventTypes.LEAVE_REQUESTED) - self.assertEqual(leave_requested_event.old_state, BotStates.JOINED_RECORDING) - self.assertEqual(leave_requested_event.new_state, BotStates.LEAVING) - self.assertEqual( - leave_requested_event.event_sub_type, - BotEventSubTypes.LEAVE_REQUESTED_AUTO_LEAVE_SILENCE, - ) - - # Verify bot_left_meeting_event (Event 5) - bot_left_meeting_event = bot_events[4] - self.assertEqual(bot_left_meeting_event.event_type, BotEventTypes.BOT_LEFT_MEETING) - self.assertEqual(bot_left_meeting_event.old_state, BotStates.LEAVING) - self.assertEqual(bot_left_meeting_event.new_state, BotStates.POST_PROCESSING) - self.assertIsNone(bot_left_meeting_event.event_sub_type) - - # Verify post_processing_completed_event (Event 6) - post_processing_completed_event = bot_events[5] - self.assertEqual(post_processing_completed_event.event_type, BotEventTypes.POST_PROCESSING_COMPLETED) - self.assertEqual(post_processing_completed_event.old_state, BotStates.POST_PROCESSING) - self.assertEqual(post_processing_completed_event.new_state, BotStates.ENDED) - - # Verify that the adapter's leave method was called with the correct reason - controller.adapter.meeting_service.Leave.assert_called_once_with(mock_zoom_sdk_adapter.LEAVE_MEETING) - - # Cleanup - controller.cleanup() - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - @patch("deepgram.DeepgramClient") - @patch("google.cloud.texttospeech.TextToSpeechClient") - def test_bot_can_join_meeting_and_record_audio_and_video( - self, - MockTextToSpeechClient, - MockDeepgramClient, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - self.bot.settings = { - "recording_settings": { - "format": RecordingFormats.MP4, - } - } - self.bot.save() - - # Set up Google TTS mock - mock_tts_client = MagicMock() - mock_tts_response = MagicMock() - - # Create fake PCM audio data (1 second of 44.1kHz audio) - # WAV header (44 bytes) + PCM data - wav_header = ( - b"RIFF" # ChunkID (4 bytes) - b"\x24\x00\x00\x00" # ChunkSize (4 bytes) - b"WAVE" # Format (4 bytes) - b"fmt " # Subchunk1ID (4 bytes) - b"\x10\x00\x00\x00" # Subchunk1Size (4 bytes) - b"\x01\x00" # AudioFormat (2 bytes) - b"\x01\x00" # NumChannels (2 bytes) - b"\x44\xac\x00\x00" # SampleRate (4 bytes) - b"\x88\x58\x01\x00" # ByteRate (4 bytes) - b"\x02\x00" # BlockAlign (2 bytes) - b"\x10\x00" # BitsPerSample (2 bytes) - b"data" # Subchunk2ID (4 bytes) - b"\x50\x00\x00\x00" # Subchunk2Size (4 bytes) - size of audio data - ) - pcm_speech_data = b"\x00\x00" * (40) # small period of silence at 44.1kHz - mock_tts_response.audio_content = wav_header + pcm_speech_data - - # Configure the mock client to return our mock response - mock_tts_client.synthesize_speech.return_value = mock_tts_response - MockTextToSpeechClient.from_service_account_info.return_value = mock_tts_client - - # Set up Deepgram mock - MockDeepgramClient.return_value = create_mock_deepgram() - - # Store uploaded data for verification - uploaded_data = bytearray() - - # Configure the mock uploader to capture uploaded data - mock_uploader = create_mock_file_uploader() - - def capture_upload_part(file_path): - uploaded_data.extend(open(file_path, "rb").read()) - - mock_uploader.upload_file.side_effect = capture_upload_part - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - audio_request = None - image_request = None - speech_request = None - - def simulate_join_flow(): - nonlocal audio_request, image_request, speech_request - - adapter = controller.adapter - # Simulate successful auth - adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - - # Simulate connecting - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Simulate successful join - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Wait for the video input manager to be set up - time.sleep(2) - - # Simulate video frame received - adapter.video_input_manager.input_streams[0].renderer_delegate.onRawDataFrameReceivedCallback(MockVideoFrame()) - - # Simulate audio frame received - adapter.audio_source.onOneWayAudioRawDataReceivedCallback( - MockPCMAudioFrame(), - 2, # Simulated participant ID that's not the bot - ) - adapter.audio_source.onMixedAudioRawDataReceivedCallback(MockPCMAudioFrame()) - - # simulate audio mic initialized - adapter.virtual_audio_mic_event_passthrough.onMicInitializeCallback(MagicMock()) - - # simulate audio mic started - adapter.virtual_audio_mic_event_passthrough.onMicStartSendCallback() - - # simulate video source initialized - adapter.virtual_camera_video_source.onInitializeCallback(MagicMock(), None, None) - - # simulate video source started - adapter.virtual_camera_video_source.onStartSendCallback() - - # simulate sending audio and image - # Create media requests - audio_request = BotMediaRequest.objects.create( - bot=self.bot, - media_blob=self.audio_blob, - media_type=BotMediaRequestMediaTypes.AUDIO, - ) - - image_request = BotMediaRequest.objects.create( - bot=self.bot, - media_blob=self.image_blob, - media_type=BotMediaRequestMediaTypes.IMAGE, - ) - - send_sync_command(self.bot, "sync_media_requests") - - # Sleep to give audio output manager time to play the audio - time.sleep(2.0) - - # Create text-to-speech request - speech_request = BotMediaRequest.objects.create( - bot=self.bot, - text_to_speak="Hello, this is a test speech", - text_to_speech_settings={ - "google": { - "voice_language_code": "en-US", - "voice_name": "en-US-Standard-A", - } - }, - media_type=BotMediaRequestMediaTypes.AUDIO, - ) - - send_sync_command(self.bot, "sync_media_requests") - - # Sleep to give audio output manager time to play the speech audio - time.sleep(2.0) - - # Simulate meeting ended - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_ENDED, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(3, simulate_join_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Verify that we received some data - self.assertGreater(len(uploaded_data), 100, "Uploaded data length is not correct") - - # Check for MP4 file signature (starts with 'ftyp') - mp4_signature_found = b"ftyp" in uploaded_data[:1000] - self.assertTrue(mp4_signature_found, "MP4 file signature not found in uploaded data") - - # Additional verification for FileUploader - mock_uploader.upload_file.assert_called_once() - self.assertGreater(mock_uploader.upload_file.call_count, 0, "upload_file was never called") - mock_uploader.wait_for_upload.assert_called_once() - mock_uploader.delete_file.assert_called_once() - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Assert that the bot is in the ENDED state - self.assertEqual(self.bot.state, BotStates.ENDED) - - # Verify all bot events in sequence - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 5) # We expect 5 events in total - - # Verify join_requested_event (Event 1) - join_requested_event = bot_events[0] - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - self.assertIsNone(join_requested_event.event_sub_type) - self.assertEqual(join_requested_event.metadata, {}) - self.assertIsNotNone(join_requested_event.requested_bot_action_taken_at) - - # Verify bot_joined_meeting_event (Event 2) - bot_joined_meeting_event = bot_events[1] - self.assertEqual(bot_joined_meeting_event.event_type, BotEventTypes.BOT_JOINED_MEETING) - self.assertEqual(bot_joined_meeting_event.old_state, BotStates.JOINING) - self.assertEqual(bot_joined_meeting_event.new_state, BotStates.JOINED_NOT_RECORDING) - self.assertIsNone(bot_joined_meeting_event.event_sub_type) - self.assertEqual(bot_joined_meeting_event.metadata, {}) - self.assertIsNone(bot_joined_meeting_event.requested_bot_action_taken_at) - - # Verify recording_permission_granted_event (Event 3) - recording_permission_granted_event = bot_events[2] - self.assertEqual( - recording_permission_granted_event.event_type, - BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED, - ) - self.assertEqual(recording_permission_granted_event.old_state, BotStates.JOINED_NOT_RECORDING) - self.assertEqual(recording_permission_granted_event.new_state, BotStates.JOINED_RECORDING) - self.assertIsNone(recording_permission_granted_event.event_sub_type) - self.assertEqual(recording_permission_granted_event.metadata, {}) - self.assertIsNone(recording_permission_granted_event.requested_bot_action_taken_at) - - # Verify meeting_ended_event (Event 4) - meeting_ended_event = bot_events[3] - self.assertEqual(meeting_ended_event.event_type, BotEventTypes.MEETING_ENDED) - self.assertEqual(meeting_ended_event.old_state, BotStates.JOINED_RECORDING) - self.assertEqual(meeting_ended_event.new_state, BotStates.POST_PROCESSING) - self.assertIsNone(meeting_ended_event.event_sub_type) - self.assertEqual(meeting_ended_event.metadata, {}) - self.assertIsNone(meeting_ended_event.requested_bot_action_taken_at) - - # Verify post_processing_completed_event (Event 5) - post_processing_completed_event = bot_events[4] - self.assertEqual(post_processing_completed_event.event_type, BotEventTypes.POST_PROCESSING_COMPLETED) - self.assertEqual(post_processing_completed_event.old_state, BotStates.POST_PROCESSING) - self.assertEqual(post_processing_completed_event.new_state, BotStates.ENDED) - - # Verify expected SDK calls - mock_zoom_sdk_adapter.InitSDK.assert_called_once() - mock_zoom_sdk_adapter.CreateMeetingService.assert_called_once() - mock_zoom_sdk_adapter.CreateAuthService.assert_called_once() - controller.adapter.meeting_service.Join.assert_called_once() - - # Verify audio request was processed - audio_request.refresh_from_db() - self.assertEqual(audio_request.state, BotMediaRequestStates.FINISHED) - - # Verify speech request was processed - speech_request.refresh_from_db() - self.assertEqual(speech_request.state, BotMediaRequestStates.FINISHED) - - # Verify image request was processed - image_request.refresh_from_db() - self.assertEqual(image_request.state, BotMediaRequestStates.FINISHED) - - # Verify that the recording was finished - self.recording.refresh_from_db() - self.assertEqual(self.recording.state, RecordingStates.COMPLETE) - self.assertEqual(self.recording.transcription_state, RecordingTranscriptionStates.COMPLETE) - - # Verify that the recording has an utterance - utterance = self.recording.utterances.first() - self.assertEqual(self.recording.utterances.count(), 1) - self.assertIsNotNone(utterance.transcription) - print("utterance.transcription = ", utterance.transcription) - - # Verify the bot adapter received the media - controller.adapter.audio_raw_data_sender.send.assert_has_calls( - [ - # First call from audio request - call( - mp3_to_pcm(self.test_mp3_bytes, sample_rate=44100), - 44100, - mock_zoom_sdk_adapter.ZoomSDKAudioChannel_Mono, - ), - # Second call from text-to-speech - call( - pcm_speech_data, - 44100, - mock_zoom_sdk_adapter.ZoomSDKAudioChannel_Mono, - ), - ], - any_order=True, - ) - - controller.adapter.video_sender.sendVideoFrame.assert_has_calls( - [ - call( - png_to_yuv420_frame(self.test_png_bytes), - 640, - 360, - 0, - mock_zoom_sdk_adapter.FrameDataFormat_I420_FULL, - ) - ], - any_order=True, - ) - - # Cleanup - controller.cleanup() - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - @patch("deepgram.DeepgramClient") - def test_bot_can_join_meeting_and_record_audio_when_in_voice_agent_configuration( - self, - MockDeepgramClient, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - self.bot.settings = { - "recording_settings": { - "format": RecordingFormats.MP4, - } - } - self.bot.save() - - # Set up Deepgram mock - MockDeepgramClient.return_value = create_mock_deepgram() - - # Store uploaded data for verification - uploaded_data = bytearray() - - # Configure the mock uploader to capture uploaded data - mock_uploader = create_mock_file_uploader() - - def capture_upload_part(file_path): - uploaded_data.extend(open(file_path, "rb").read()) - - mock_uploader.upload_file.side_effect = capture_upload_part - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Create bot controller - controller = BotController(self.bot.id) - controller.pipeline_configuration = PipelineConfiguration.voice_agent() - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - audio_request = None - image_request = None - speech_request = None - - def simulate_join_flow(): - nonlocal audio_request, image_request, speech_request - - adapter = controller.adapter - # Simulate successful auth - adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - - # Simulate connecting - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Simulate successful join - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - time.sleep(2) - - # Simulate audio frame received - adapter.audio_source.onOneWayAudioRawDataReceivedCallback( - MockPCMAudioFrame(), - 2, # Simulated participant ID that's not the bot - ) - - # simulate audio mic initialized - adapter.virtual_audio_mic_event_passthrough.onMicInitializeCallback(MagicMock()) - - # simulate audio mic started - adapter.virtual_audio_mic_event_passthrough.onMicStartSendCallback() - - # simulate video source initialized - adapter.virtual_camera_video_source.onInitializeCallback(MagicMock(), None, None) - - # simulate video source started - adapter.virtual_camera_video_source.onStartSendCallback() - - time.sleep(2) - - # Simulate meeting ended - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_ENDED, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # sleep a bit for the utterance to be saved - time.sleep(5) - - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(3, simulate_join_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Verify that we received no data - self.assertEqual(len(uploaded_data), 993, "Uploaded data length is not correct") - - # Additional verification for FileUploader - mock_uploader.upload_file.assert_called_once() - self.assertGreater(mock_uploader.upload_file.call_count, 0, "upload_file was never called") - mock_uploader.wait_for_upload.assert_called_once() - mock_uploader.delete_file.assert_called_once() - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Assert that the bot is in the ENDED state - self.assertEqual(self.bot.state, BotStates.ENDED) - - # Verify all bot events in sequence - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 5) # We expect 5 events in total - - # Verify join_requested_event (Event 1) - join_requested_event = bot_events[0] - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - self.assertIsNone(join_requested_event.event_sub_type) - self.assertEqual(join_requested_event.metadata, {}) - self.assertIsNotNone(join_requested_event.requested_bot_action_taken_at) - - # Verify bot_joined_meeting_event (Event 2) - bot_joined_meeting_event = bot_events[1] - self.assertEqual(bot_joined_meeting_event.event_type, BotEventTypes.BOT_JOINED_MEETING) - self.assertEqual(bot_joined_meeting_event.old_state, BotStates.JOINING) - self.assertEqual(bot_joined_meeting_event.new_state, BotStates.JOINED_NOT_RECORDING) - self.assertIsNone(bot_joined_meeting_event.event_sub_type) - self.assertEqual(bot_joined_meeting_event.metadata, {}) - self.assertIsNone(bot_joined_meeting_event.requested_bot_action_taken_at) - - # Verify recording_permission_granted_event (Event 3) - recording_permission_granted_event = bot_events[2] - self.assertEqual( - recording_permission_granted_event.event_type, - BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED, - ) - self.assertEqual(recording_permission_granted_event.old_state, BotStates.JOINED_NOT_RECORDING) - self.assertEqual(recording_permission_granted_event.new_state, BotStates.JOINED_RECORDING) - self.assertIsNone(recording_permission_granted_event.event_sub_type) - self.assertEqual(recording_permission_granted_event.metadata, {}) - self.assertIsNone(recording_permission_granted_event.requested_bot_action_taken_at) - - # Verify meeting_ended_event (Event 4) - meeting_ended_event = bot_events[3] - self.assertEqual(meeting_ended_event.event_type, BotEventTypes.MEETING_ENDED) - self.assertEqual(meeting_ended_event.old_state, BotStates.JOINED_RECORDING) - self.assertEqual(meeting_ended_event.new_state, BotStates.POST_PROCESSING) - self.assertIsNone(meeting_ended_event.event_sub_type) - self.assertEqual(meeting_ended_event.metadata, {}) - self.assertIsNone(meeting_ended_event.requested_bot_action_taken_at) - - # Verify post_processing_completed_event (Event 5) - post_processing_completed_event = bot_events[4] - self.assertEqual(post_processing_completed_event.event_type, BotEventTypes.POST_PROCESSING_COMPLETED) - self.assertEqual(post_processing_completed_event.old_state, BotStates.POST_PROCESSING) - self.assertEqual(post_processing_completed_event.new_state, BotStates.ENDED) - - # Verify expected SDK calls - mock_zoom_sdk_adapter.InitSDK.assert_called_once() - mock_zoom_sdk_adapter.CreateMeetingService.assert_called_once() - mock_zoom_sdk_adapter.CreateAuthService.assert_called_once() - controller.adapter.meeting_service.Join.assert_called_once() - - # Verify that the recording was finished - self.recording.refresh_from_db() - self.assertEqual(self.recording.state, RecordingStates.COMPLETE) - self.assertEqual(self.recording.transcription_state, RecordingTranscriptionStates.COMPLETE) - - # Verify that the recording has an utterance - utterance = self.recording.utterances.first() - self.assertEqual(self.recording.utterances.count(), 1) - self.assertIsNotNone(utterance.transcription) - - # Cleanup - controller.cleanup() - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - def test_bot_can_handle_failed_zoom_auth( - self, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Configure the mock class to return our mock instance - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_failed_auth_flow(): - # Simulate failed auth - controller.adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_JWTTOKENWRONG) - # Clean up connections in thread - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(2, simulate_failed_auth_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Check that the bot joined successfully - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 2) - join_requested_event = bot_events[0] - could_not_join_event = bot_events[1] - - # Verify join_requested_event properties - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - self.assertIsNone(join_requested_event.event_sub_type) - self.assertEqual(join_requested_event.metadata, {}) - self.assertIsNotNone(join_requested_event.requested_bot_action_taken_at) - - # Verify could_not_join_event properties - self.assertEqual(could_not_join_event.event_type, BotEventTypes.COULD_NOT_JOIN) - self.assertEqual(could_not_join_event.old_state, BotStates.JOINING) - self.assertEqual(could_not_join_event.new_state, BotStates.FATAL_ERROR) - self.assertEqual( - could_not_join_event.event_sub_type, - BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_AUTHORIZATION_FAILED, - ) - self.assertEqual( - could_not_join_event.metadata, - {"zoom_result_code": str(mock_zoom_sdk_adapter.AUTHRET_JWTTOKENWRONG)}, - ) - self.assertIsNone(could_not_join_event.requested_bot_action_taken_at) - - # Verify expected SDK calls - mock_zoom_sdk_adapter.InitSDK.assert_called_once() - mock_zoom_sdk_adapter.CreateMeetingService.assert_called_once() - mock_zoom_sdk_adapter.CreateAuthService.assert_called_once() - controller.adapter.meeting_service.Join.assert_not_called() - - # Additional verification for FileUploader - # Probably should not be called, but it currently is - # controller.file_uploader.upload_file.assert_not_called() - - # Cleanup - # no need to cleanup since we already hit error - # controller.cleanup() will be called by the bot controller - - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - def test_bot_can_handle_waiting_for_host( - self, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Configure the mock class to return our mock instance - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Create bot controller - controller = BotController(self.bot.id) - controller.automatic_leave_configuration = AutomaticLeaveConfiguration(wait_for_host_to_start_meeting_timeout_seconds=1) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_waiting_for_host_flow(): - # Simulate successful auth - controller.adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - # Simulate waiting for host status - controller.adapter.meeting_service_event.onMeetingStatusChangedCallback(mock_zoom_sdk_adapter.MEETING_STATUS_WAITINGFORHOST, 0) - # Clean up connections in thread - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(2, simulate_waiting_for_host_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Check the bot events - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 2) - join_requested_event = bot_events[0] - could_not_join_event = bot_events[1] - - # Verify join_requested_event properties - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - self.assertIsNone(join_requested_event.event_sub_type) - self.assertEqual(join_requested_event.metadata, {}) - self.assertIsNotNone(join_requested_event.requested_bot_action_taken_at) - - # Verify could_not_join_event properties - self.assertEqual(could_not_join_event.event_type, BotEventTypes.COULD_NOT_JOIN) - self.assertEqual(could_not_join_event.old_state, BotStates.JOINING) - self.assertEqual(could_not_join_event.new_state, BotStates.FATAL_ERROR) - self.assertEqual( - could_not_join_event.event_sub_type, - BotEventSubTypes.COULD_NOT_JOIN_MEETING_NOT_STARTED_WAITING_FOR_HOST, - ) - self.assertEqual(could_not_join_event.metadata, {}) - self.assertIsNone(could_not_join_event.requested_bot_action_taken_at) - - # Verify expected SDK calls - mock_zoom_sdk_adapter.InitSDK.assert_called_once() - mock_zoom_sdk_adapter.CreateMeetingService.assert_called_once() - mock_zoom_sdk_adapter.CreateAuthService.assert_called_once() - controller.adapter.meeting_service.Join.assert_called_once() - - # Cleanup - # no need to cleanup since we already hit error - # controller.cleanup() will be called by the bot controller - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - def test_bot_can_handle_unable_to_join_external_meeting( - self, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Configure the mock class to return our mock instance - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_unable_to_join_external_meeting_flow(): - # Simulate successful auth - controller.adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - # Simulate meeting failed status with unable to join external meeting code - controller.adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_FAILED, - mock_zoom_sdk_adapter.MeetingFailCode.MEETING_FAIL_UNABLE_TO_JOIN_EXTERNAL_MEETING, - ) - # Clean up connections in thread - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(2, simulate_unable_to_join_external_meeting_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Check the bot events - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 2) - join_requested_event = bot_events[0] - could_not_join_event = bot_events[1] - - # Verify join_requested_event properties - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - self.assertIsNone(join_requested_event.event_sub_type) - self.assertEqual(join_requested_event.metadata, {}) - self.assertIsNotNone(join_requested_event.requested_bot_action_taken_at) - - # Verify could_not_join_event properties - self.assertEqual(could_not_join_event.event_type, BotEventTypes.COULD_NOT_JOIN) - self.assertEqual(could_not_join_event.old_state, BotStates.JOINING) - self.assertEqual(could_not_join_event.new_state, BotStates.FATAL_ERROR) - self.assertEqual( - could_not_join_event.event_sub_type, - BotEventSubTypes.COULD_NOT_JOIN_MEETING_UNPUBLISHED_ZOOM_APP, - ) - self.assertEqual( - could_not_join_event.metadata, - {"zoom_result_code": str(mock_zoom_sdk_adapter.MeetingFailCode.MEETING_FAIL_UNABLE_TO_JOIN_EXTERNAL_MEETING)}, - ) - self.assertIsNone(could_not_join_event.requested_bot_action_taken_at) - - # Verify expected SDK calls - mock_zoom_sdk_adapter.InitSDK.assert_called_once() - mock_zoom_sdk_adapter.CreateMeetingService.assert_called_once() - mock_zoom_sdk_adapter.CreateAuthService.assert_called_once() - controller.adapter.meeting_service.Join.assert_called_once() - - # Cleanup - # no need to cleanup since we already hit error - # controller.cleanup() will be called by the bot controller - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - def test_bot_can_handle_meeting_failed_blocked_by_admin( - self, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Configure the mock class to return our mock instance - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_meeting_failed_flow(): - # Simulate successful auth - controller.adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - # Simulate meeting failed status with blocked by admin code - controller.adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_FAILED, - mock_zoom_sdk_adapter.MeetingFailCode.MEETING_FAIL_BLOCKED_BY_ACCOUNT_ADMIN, - ) - # Clean up connections in thread - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(2, simulate_meeting_failed_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Check the bot events - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 2) - join_requested_event = bot_events[0] - could_not_join_event = bot_events[1] - - # Verify join_requested_event properties - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - self.assertIsNone(join_requested_event.event_sub_type) - self.assertEqual(join_requested_event.metadata, {}) - self.assertIsNotNone(join_requested_event.requested_bot_action_taken_at) - - # Verify could_not_join_event properties - self.assertEqual(could_not_join_event.event_type, BotEventTypes.COULD_NOT_JOIN) - self.assertEqual(could_not_join_event.old_state, BotStates.JOINING) - self.assertEqual(could_not_join_event.new_state, BotStates.FATAL_ERROR) - self.assertEqual( - could_not_join_event.event_sub_type, - BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_MEETING_STATUS_FAILED, - ) - self.assertEqual( - could_not_join_event.metadata, - {"zoom_result_code": str(mock_zoom_sdk_adapter.MeetingFailCode.MEETING_FAIL_BLOCKED_BY_ACCOUNT_ADMIN)}, - ) - self.assertIsNone(could_not_join_event.requested_bot_action_taken_at) - - # Verify expected SDK calls - mock_zoom_sdk_adapter.InitSDK.assert_called_once() - mock_zoom_sdk_adapter.CreateMeetingService.assert_called_once() - mock_zoom_sdk_adapter.CreateAuthService.assert_called_once() - controller.adapter.meeting_service.Join.assert_called_once() - - # Cleanup - # no need to cleanup since we already hit error - # controller.cleanup() will be called by the bot controller - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - @patch("deepgram.DeepgramClient") - - # We need run this test last because if the process isn't killed properly some weird behavior ensues - # where the thread is still running even after the test is over. It's due to the fact that multiple tests - # are run in a single process. - # So we put a 'z' in the test name to run it last. - # This is a temporary hack, but it's ok for now IMO. In production, the process would be killed - def test_bot_z_handles_rtmp_connection_failure( - self, - MockDeepgramClient, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Set up Deepgram mock - MockDeepgramClient.return_value = create_mock_deepgram() - - # Configure the mock uploader - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Set RTMP URL for the bot - self.bot.settings = { - "rtmp_settings": { - "destination_url": "rtmp://example.com/live/stream", - "stream_key": "1234", - } - } - self.bot.save() - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_join_flow(): - adapter = controller.adapter - # Simulate successful auth - adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - - # Simulate connecting - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Simulate successful join - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Wait for the video input manager to be set up - time.sleep(2) - - # Send a bunch of frames to the bot it takes some time to recognize the rtmp failure - for i in range(5): - # Simulate video frame received - adapter.video_input_manager.input_streams[0].renderer_delegate.onRawDataFrameReceivedCallback(MockVideoFrame()) - - # Simulate audio frame received - adapter.audio_source.onOneWayAudioRawDataReceivedCallback( - MockPCMAudioFrame(), - 2, # Simulated participant ID that's not the bot - ) - adapter.audio_source.onMixedAudioRawDataReceivedCallback(MockPCMAudioFrame()) - - time.sleep(5.0) - - # Error will be triggered because the rtmp url we gave was bad - # This will trigger the GStreamer pipeline to send a message to the bot - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(3, simulate_join_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=40) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Assert that the bot is in the FATAL_ERROR state - self.assertEqual(self.bot.state, BotStates.FATAL_ERROR) - - # Verify bot events in sequence - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 4) # We expect 4 events in total - - # Verify join_requested_event (Event 1) - join_requested_event = bot_events[0] - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - self.assertIsNone(join_requested_event.event_sub_type) - self.assertEqual(join_requested_event.metadata, {}) - self.assertIsNotNone(join_requested_event.requested_bot_action_taken_at) - - # Verify bot_joined_meeting_event (Event 2) - bot_joined_meeting_event = bot_events[1] - self.assertEqual(bot_joined_meeting_event.event_type, BotEventTypes.BOT_JOINED_MEETING) - self.assertEqual(bot_joined_meeting_event.old_state, BotStates.JOINING) - self.assertEqual(bot_joined_meeting_event.new_state, BotStates.JOINED_NOT_RECORDING) - self.assertIsNone(bot_joined_meeting_event.event_sub_type) - self.assertEqual(bot_joined_meeting_event.metadata, {}) - self.assertIsNone(bot_joined_meeting_event.requested_bot_action_taken_at) - - # Verify recording_permission_granted_event (Event 3) - recording_permission_granted_event = bot_events[2] - self.assertEqual( - recording_permission_granted_event.event_type, - BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED, - ) - self.assertEqual(recording_permission_granted_event.old_state, BotStates.JOINED_NOT_RECORDING) - self.assertEqual(recording_permission_granted_event.new_state, BotStates.JOINED_RECORDING) - self.assertIsNone(recording_permission_granted_event.event_sub_type) - self.assertEqual(recording_permission_granted_event.metadata, {}) - self.assertIsNone(recording_permission_granted_event.requested_bot_action_taken_at) - - # Verify fatal_error_event (Event 4) - fatal_error_event = bot_events[3] - self.assertEqual(fatal_error_event.event_type, BotEventTypes.FATAL_ERROR) - self.assertEqual(fatal_error_event.old_state, BotStates.JOINED_RECORDING) - self.assertEqual(fatal_error_event.new_state, BotStates.FATAL_ERROR) - self.assertEqual( - fatal_error_event.event_sub_type, - BotEventSubTypes.FATAL_ERROR_RTMP_CONNECTION_FAILED, - ) - self.assertEqual( - fatal_error_event.metadata, - {"rtmp_destination_url": "rtmp://example.com/live/stream/1234"}, - ) - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - def test_bot_can_handle_zoom_sdk_internal_error( - self, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Configure the mock class to return our mock instance - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Configure the auth service to return an error - mock_zoom_sdk_adapter.CreateAuthService.return_value.SDKAuth.return_value = mock_zoom_sdk_adapter.SDKError.SDKERR_INTERNAL_ERROR - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Check the bot events - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 2) - join_requested_event = bot_events[0] - could_not_join_event = bot_events[1] - - # Verify join_requested_event properties - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - self.assertIsNone(join_requested_event.event_sub_type) - self.assertEqual(join_requested_event.metadata, {}) - self.assertIsNotNone(join_requested_event.requested_bot_action_taken_at) - - # Verify could_not_join_event properties - self.assertEqual(could_not_join_event.event_type, BotEventTypes.COULD_NOT_JOIN) - self.assertEqual(could_not_join_event.old_state, BotStates.JOINING) - self.assertEqual(could_not_join_event.new_state, BotStates.FATAL_ERROR) - self.assertEqual( - could_not_join_event.event_sub_type, - BotEventSubTypes.COULD_NOT_JOIN_MEETING_ZOOM_SDK_INTERNAL_ERROR, - ) - self.assertEqual( - could_not_join_event.metadata, - {"zoom_result_code": str(mock_zoom_sdk_adapter.SDKError.SDKERR_INTERNAL_ERROR)}, - ) - self.assertIsNone(could_not_join_event.requested_bot_action_taken_at) - - # Verify expected SDK calls - mock_zoom_sdk_adapter.InitSDK.assert_called_once() - mock_zoom_sdk_adapter.CreateMeetingService.assert_called_once() - mock_zoom_sdk_adapter.CreateAuthService.assert_called_once() - controller.adapter.meeting_service.Join.assert_not_called() - - # Cleanup - # no need to cleanup since we already hit error - # controller.cleanup() will be called by the bot controller - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() - - @patch( - "bots.zoom_bot_adapter.video_input_manager.zoom", - new_callable=create_mock_zoom_sdk, - ) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.zoom", new_callable=create_mock_zoom_sdk) - @patch("bots.zoom_bot_adapter.zoom_bot_adapter.jwt") - @patch("bots.bot_controller.bot_controller.FileUploader") - @patch("deepgram.DeepgramClient") - def test_bot_leaves_meeting_when_requested( - self, - MockDeepgramClient, - MockFileUploader, - mock_jwt, - mock_zoom_sdk_adapter, - mock_zoom_sdk_video, - ): - # Set up Deepgram mock - MockDeepgramClient.return_value = create_mock_deepgram() - - # Configure the mock uploader - mock_uploader = create_mock_file_uploader() - MockFileUploader.return_value = mock_uploader - - # Mock the JWT token generation - mock_jwt.encode.return_value = "fake_jwt_token" - - # Create bot controller - controller = BotController(self.bot.id) - - # Run the bot in a separate thread since it has an event loop - bot_thread = threading.Thread(target=controller.run) - bot_thread.daemon = True - bot_thread.start() - - def simulate_join_flow(): - adapter = controller.adapter - # Simulate successful auth - adapter.auth_event.onAuthenticationReturnCallback(mock_zoom_sdk_adapter.AUTHRET_SUCCESS) - - # Configure GetMeetingStatus to return the correct status - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING - - # Simulate connecting - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_CONNECTING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Update GetMeetingStatus to return in-meeting status - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING - - # Simulate successful join - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_INMEETING, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Wait for the video input manager to be set up - time.sleep(2) - - # Simulate audio frame received to trigger transcription - adapter.audio_source.onOneWayAudioRawDataReceivedCallback( - MockPCMAudioFrame(), - 2, # Simulated participant ID that's not the bot - ) - - # Give no time for the transcription to be processed - time.sleep(0.1) - - # Simulate user requesting bot to leave - BotEventManager.create_event(bot=self.bot, event_type=BotEventTypes.LEAVE_REQUESTED, event_sub_type=BotEventSubTypes.LEAVE_REQUESTED_USER_REQUESTED) - controller.handle_redis_message({"type": "message", "data": json.dumps({"command": "sync"}).encode("utf-8")}) - - # Update GetMeetingStatus to return ended status when meeting ends - adapter.meeting_service.GetMeetingStatus.return_value = mock_zoom_sdk_adapter.MEETING_STATUS_ENDED - - # Simulate meeting ended after leave - adapter.meeting_service_event.onMeetingStatusChangedCallback( - mock_zoom_sdk_adapter.MEETING_STATUS_ENDED, - mock_zoom_sdk_adapter.SDKERR_SUCCESS, - ) - - # Clean up connections in thread - connection.close() - - # Run join flow simulation after a short delay - threading.Timer(2, simulate_join_flow).start() - - # Give the bot some time to process - bot_thread.join(timeout=10) - - # Refresh the bot from the database - self.bot.refresh_from_db() - - # Assert that the heartbeat timestamp was set - self.assertIsNotNone(self.bot.first_heartbeat_timestamp) - self.assertIsNotNone(self.bot.last_heartbeat_timestamp) - - # Assert that the bot is in the ENDED state - self.assertEqual(self.bot.state, BotStates.ENDED) - - # Verify bot events in sequence - bot_events = self.bot.bot_events.all() - self.assertEqual(len(bot_events), 6) # We expect 6 events in total - - # Verify join_requested_event (Event 1) - join_requested_event = bot_events[0] - self.assertEqual(join_requested_event.event_type, BotEventTypes.JOIN_REQUESTED) - self.assertEqual(join_requested_event.old_state, BotStates.READY) - self.assertEqual(join_requested_event.new_state, BotStates.JOINING) - - # Verify bot_joined_meeting_event (Event 2) - bot_joined_meeting_event = bot_events[1] - self.assertEqual(bot_joined_meeting_event.event_type, BotEventTypes.BOT_JOINED_MEETING) - self.assertEqual(bot_joined_meeting_event.old_state, BotStates.JOINING) - self.assertEqual(bot_joined_meeting_event.new_state, BotStates.JOINED_NOT_RECORDING) - - # Verify recording_permission_granted_event (Event 3) - recording_permission_granted_event = bot_events[2] - self.assertEqual( - recording_permission_granted_event.event_type, - BotEventTypes.BOT_RECORDING_PERMISSION_GRANTED, - ) - self.assertEqual(recording_permission_granted_event.old_state, BotStates.JOINED_NOT_RECORDING) - self.assertEqual(recording_permission_granted_event.new_state, BotStates.JOINED_RECORDING) - - # Verify leave_requested_event (Event 4) - leave_requested_event = bot_events[3] - self.assertEqual(leave_requested_event.event_type, BotEventTypes.LEAVE_REQUESTED) - self.assertEqual(leave_requested_event.old_state, BotStates.JOINED_RECORDING) - self.assertEqual(leave_requested_event.new_state, BotStates.LEAVING) - self.assertEqual(leave_requested_event.metadata, {}) # No metadata for user-requested leave - self.assertEqual( - leave_requested_event.event_sub_type, - BotEventSubTypes.LEAVE_REQUESTED_USER_REQUESTED, - ) - - # Verify bot_left_meeting_event (Event 5) - bot_left_meeting_event = bot_events[4] - self.assertEqual(bot_left_meeting_event.event_type, BotEventTypes.BOT_LEFT_MEETING) - self.assertEqual(bot_left_meeting_event.old_state, BotStates.LEAVING) - self.assertEqual(bot_left_meeting_event.new_state, BotStates.POST_PROCESSING) - - # Verify post_processing_completed_event (Event 6) - post_processing_completed_event = bot_events[5] - self.assertEqual(post_processing_completed_event.event_type, BotEventTypes.POST_PROCESSING_COMPLETED) - self.assertEqual(post_processing_completed_event.old_state, BotStates.POST_PROCESSING) - self.assertEqual(post_processing_completed_event.new_state, BotStates.ENDED) - - # Verify that the adapter's leave method was called with the correct reason - controller.adapter.meeting_service.Leave.assert_called_once_with(mock_zoom_sdk_adapter.LEAVE_MEETING) - - # Verify that the recording has an utterance - self.recording.refresh_from_db() - utterances = self.recording.utterances.all() - self.assertEqual(utterances.count(), 1) - utterance = utterances.first() - self.assertEqual(utterance.transcription.get("transcript"), "This is a test transcript") - self.assertEqual(utterance.participant.uuid, "2") # The simulated participant ID - self.assertEqual(utterance.participant.full_name, "Test User") - - # Cleanup - controller.cleanup() - bot_thread.join(timeout=5) - - # Close the database connection since we're in a thread - connection.close() diff --git a/attendee/bots/utils.py b/attendee/bots/utils.py deleted file mode 100644 index 8f73ecf..0000000 --- a/attendee/bots/utils.py +++ /dev/null @@ -1,290 +0,0 @@ -import io - -import cv2 -import numpy as np -from pydub import AudioSegment - -from .models import MeetingTypes, RecordingStates - - -def pcm_to_mp3( - pcm_data: bytes, - sample_rate: int = 32000, - channels: int = 1, - sample_width: int = 2, - bitrate: str = "128k", -) -> bytes: - """ - Convert PCM audio data to MP3 format. - - Args: - pcm_data (bytes): Raw PCM audio data - sample_rate (int): Sample rate in Hz (default: 32000) - channels (int): Number of audio channels (default: 1) - sample_width (int): Sample width in bytes (default: 2) - bitrate (str): MP3 encoding bitrate (default: "128k") - - Returns: - bytes: MP3 encoded audio data - """ - # Create AudioSegment from raw PCM data - audio_segment = AudioSegment( - data=pcm_data, - sample_width=sample_width, - frame_rate=sample_rate, - channels=channels, - ) - - # Create a bytes buffer to store the MP3 data - buffer = io.BytesIO() - - # Export the audio segment as MP3 to the buffer with specified bitrate - audio_segment.export(buffer, format="mp3", parameters=["-b:a", bitrate]) - - # Get the MP3 data as bytes - mp3_data = buffer.getvalue() - buffer.close() - - return mp3_data - - -def mp3_to_pcm(mp3_data: bytes, sample_rate: int = 32000, channels: int = 1, sample_width: int = 2) -> bytes: - """ - Convert MP3 audio data to PCM format. - - Args: - mp3_data (bytes): MP3 audio data - sample_rate (int): Desired sample rate in Hz (default: 32000) - channels (int): Desired number of audio channels (default: 1) - sample_width (int): Desired sample width in bytes (default: 2) - - Returns: - bytes: Raw PCM audio data - """ - # Create a bytes buffer from the MP3 data - buffer = io.BytesIO(mp3_data) - - # Load the MP3 data into an AudioSegment - audio_segment = AudioSegment.from_mp3(buffer) - - # Convert to the desired format - audio_segment = audio_segment.set_frame_rate(sample_rate) - audio_segment = audio_segment.set_channels(channels) - audio_segment = audio_segment.set_sample_width(sample_width) - - # Get the raw PCM data - pcm_data = audio_segment.raw_data - buffer.close() - - return pcm_data - - -def calculate_audio_duration_ms(audio_data: bytes, content_type: str) -> int: - """ - Calculate the duration of audio data in milliseconds. - - Args: - audio_data (bytes): Audio data in either PCM or MP3 format - content_type (str): Content type of the audio data (e.g., 'audio/mp3') - - Returns: - int: Duration in milliseconds - """ - buffer = io.BytesIO(audio_data) - - if content_type == "audio/mp3": - audio = AudioSegment.from_mp3(buffer) - else: - raise ValueError(f"Unsupported content type for duration calculation: {content_type}") - - buffer.close() - # len(audio) returns duration in milliseconds for pydub AudioSegment objects - duration_ms = len(audio) - return duration_ms - - -def png_to_yuv420_frame(png_bytes: bytes, width: int = 640, height: int = 360) -> bytes: - """ - Convert PNG image bytes to YUV420 (I420) format and resize to specified dimensions. - - Args: - png_bytes (bytes): Input PNG image as bytes - width (int): Desired width of output frame (default: 640) - height (int): Desired height of output frame (default: 360) - - Returns: - bytes: YUV420 formatted frame data - """ - # Convert PNG bytes to numpy array - png_array = np.frombuffer(png_bytes, np.uint8) - bgr_frame = cv2.imdecode(png_array, cv2.IMREAD_COLOR) - - # Resize the frame to desired dimensions - bgr_frame = cv2.resize(bgr_frame, (width, height), interpolation=cv2.INTER_AREA) - - # Convert BGR to YUV420 (I420) - yuv_frame = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2YUV_I420) - - # Return as bytes - return yuv_frame.tobytes() - - -def utterance_words(utterance, offset=0.0): - if "words" in utterance.transcription: - return utterance.transcription["words"] - - return [ - { - "start": offset, - "end": offset + utterance.duration_ms / 1000.0, - "punctuated_word": utterance.transcription["transcript"], - "word": utterance.transcription["transcript"], - } - ] - - -class AggregatedUtterance: - def __init__(self, utterance): - self.participant = utterance.participant - self.transcription = utterance.transcription.copy() - self.timestamp_ms = utterance.timestamp_ms - self.duration_ms = utterance.duration_ms - self.id = utterance.id - self.transcription["words"] = utterance_words(utterance) - - def aggregate(self, utterance): - self.transcription["words"].extend(utterance_words(utterance, offset=(utterance.timestamp_ms - self.timestamp_ms) / 1000.0)) - self.transcription["transcript"] += " " + utterance.transcription["transcript"] - self.duration_ms += utterance.duration_ms - - -def generate_aggregated_utterances(recording): - utterances_sorted = recording.utterances.all().order_by("timestamp_ms") - - aggregated_utterances = [] - current_aggregated_utterance = None - for utterance in utterances_sorted: - if not utterance.transcription: - continue - if not utterance.transcription.get("transcript"): - continue - - if current_aggregated_utterance is None: - current_aggregated_utterance = AggregatedUtterance(utterance) - else: - if utterance.transcription.get("words") is None and utterance.participant.id == current_aggregated_utterance.participant.id and utterance.timestamp_ms - (current_aggregated_utterance.timestamp_ms + current_aggregated_utterance.duration_ms) < 3000: - current_aggregated_utterance.aggregate(utterance) - else: - aggregated_utterances.append(current_aggregated_utterance) - current_aggregated_utterance = AggregatedUtterance(utterance) - - if current_aggregated_utterance: - aggregated_utterances.append(current_aggregated_utterance) - return aggregated_utterances - - -def generate_utterance_json_for_bot_detail_view(recording): - utterances_data = [] - recording_first_buffer_timestamp_ms = recording.first_buffer_timestamp_ms - - aggregated_utterances = generate_aggregated_utterances(recording) - for utterance in aggregated_utterances: - if not utterance.transcription: - continue - if not utterance.transcription.get("transcript"): - continue - - if recording_first_buffer_timestamp_ms: - if utterance.transcription.get("words"): - first_word_start_relative_ms = int(utterance.transcription.get("words")[0].get("start") * 1000) - else: - first_word_start_relative_ms = 0 - - relative_timestamp_ms = utterance.timestamp_ms - recording_first_buffer_timestamp_ms + first_word_start_relative_ms - else: - # If we don't have a first buffer timestamp, we use the absolute timestamp - relative_timestamp_ms = utterance.timestamp_ms - - relative_words_data = [] - if utterance.transcription.get("words"): - if recording_first_buffer_timestamp_ms: - utterance_start_relative_ms = utterance.timestamp_ms - recording_first_buffer_timestamp_ms - else: - # If we don't have a first buffer timestamp, we use the absolute timestamp - utterance_start_relative_ms = utterance.timestamp_ms - - for word in utterance.transcription["words"]: - relative_word = word.copy() - relative_word["start"] = utterance_start_relative_ms + int(word["start"] * 1000) - relative_word["end"] = utterance_start_relative_ms + int(word["end"] * 1000) - relative_words_data.append(relative_word) - - relative_words_data_with_spaces = [] - for i, word in enumerate(relative_words_data): - relative_words_data_with_spaces.append( - { - "word": word["punctuated_word"], - "start": word["start"], - "end": word["end"], - "utterance_id": utterance.id, - } - ) - # Add space between words - if i < len(relative_words_data) - 1: - next_word = relative_words_data[i + 1] - relative_words_data_with_spaces.append( - { - "word": " ", - "start": next_word["start"], - "end": next_word["start"], - "utterance_id": utterance.id, - "is_space": True, - } - ) - - timestamp_ms = relative_timestamp_ms if recording_first_buffer_timestamp_ms is not None else utterance.timestamp_ms - seconds = timestamp_ms // 1000 - timestamp_display = f"{seconds // 60}:{seconds % 60:02d}" - - utterance_data = { - "id": utterance.id, - "participant": utterance.participant, - "relative_timestamp_ms": relative_timestamp_ms, - "words": relative_words_data_with_spaces, - "transcript": utterance.transcription.get("transcript"), - "timestamp_display": timestamp_display, - } - utterances_data.append(utterance_data) - - return utterances_data - - -def meeting_type_from_url(url): - if not url: - return None - - if "zoom.us" in url: - return MeetingTypes.ZOOM - elif "meet.google.com" in url: - return MeetingTypes.GOOGLE_MEET - elif "teams.microsoft.com" in url or "teams.live.com" in url: - return MeetingTypes.TEAMS - else: - return None - - -def generate_recordings_json_for_bot_detail_view(bot): - # Process recordings and utterances - recordings_data = [] - for recording in bot.recordings.all(): - if recording.state != RecordingStates.COMPLETE: - continue - recordings_data.append( - { - "state": recording.state, - "url": recording.url, - "utterances": generate_utterance_json_for_bot_detail_view(recording), - } - ) - - return recordings_data diff --git a/attendee/bots/web_bot_adapter/__init__.py b/attendee/bots/web_bot_adapter/__init__.py deleted file mode 100644 index 9445493..0000000 --- a/attendee/bots/web_bot_adapter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .web_bot_adapter import WebBotAdapter - -__all__ = ["WebBotAdapter"] diff --git a/attendee/bots/web_bot_adapter/ui_methods.py b/attendee/bots/web_bot_adapter/ui_methods.py deleted file mode 100644 index 4113e2f..0000000 --- a/attendee/bots/web_bot_adapter/ui_methods.py +++ /dev/null @@ -1,26 +0,0 @@ -class UiException(Exception): - def __init__(self, message, step, inner_exception): - self.step = step - self.inner_exception = inner_exception - super().__init__(message) - - -# When this exception is raised, the bot will stop running and log that it was denied access to the meeting -class UiRequestToJoinDeniedException(UiException): - def __init__(self, message, step=None, inner_exception=None): - super().__init__(message, step, inner_exception) - - -class UiRetryableException(UiException): - def __init__(self, message, step=None, inner_exception=None): - super().__init__(message, step, inner_exception) - - -class UiCouldNotLocateElementException(UiRetryableException): - def __init__(self, message, step=None, inner_exception=None): - super().__init__(message, step, inner_exception) - - -class UiCouldNotClickElementException(UiRetryableException): - def __init__(self, message, step=None, inner_exception=None): - super().__init__(message, step, inner_exception) diff --git a/attendee/bots/web_bot_adapter/web_bot_adapter.py b/attendee/bots/web_bot_adapter/web_bot_adapter.py deleted file mode 100644 index b1c7d8e..0000000 --- a/attendee/bots/web_bot_adapter/web_bot_adapter.py +++ /dev/null @@ -1,556 +0,0 @@ -import asyncio -import datetime -import json -import logging -import os -import threading -import time -from time import sleep - -import cv2 -import numpy as np -import requests -import undetected_chromedriver as uc -from pyvirtualdisplay import Display -from websockets.sync.server import serve - -from bots.bot_adapter import BotAdapter -from bots.bot_controller.automatic_leave_configuration import AutomaticLeaveConfiguration - -from .ui_methods import UiRequestToJoinDeniedException, UiRetryableException - -logger = logging.getLogger(__name__) - - -def half_ceil(x): - return (x + 1) // 2 - - -def scale_i420(frame, frame_size, new_size): - """ - Scales an I420 (YUV 4:2:0) frame from 'frame_size' to 'new_size', - handling odd frame widths/heights by using 'ceil' in the chroma planes. - - :param frame: A bytes object containing the raw I420 frame data. - :param frame_size: (orig_width, orig_height) - :param new_size: (new_width, new_height) - :return: A bytes object with the scaled I420 frame. - """ - - # 1) Unpack source / destination dimensions - orig_width, orig_height = frame_size - new_width, new_height = new_size - - # 2) Compute source plane sizes with rounding up for chroma - orig_chroma_width = half_ceil(orig_width) - orig_chroma_height = half_ceil(orig_height) - - y_plane_size = orig_width * orig_height - uv_plane_size = orig_chroma_width * orig_chroma_height # for each U or V - - # 3) Extract Y, U, V planes from the byte array - y = np.frombuffer(frame[0:y_plane_size], dtype=np.uint8) - u = np.frombuffer(frame[y_plane_size : y_plane_size + uv_plane_size], dtype=np.uint8) - v = np.frombuffer( - frame[y_plane_size + uv_plane_size : y_plane_size + 2 * uv_plane_size], - dtype=np.uint8, - ) - - # 4) Reshape planes - y = y.reshape(orig_height, orig_width) - u = u.reshape(orig_chroma_height, orig_chroma_width) - v = v.reshape(orig_chroma_height, orig_chroma_width) - - # --------------------------------------------------------- - # Scale preserving aspect ratio or do letterbox/pillarbox - # --------------------------------------------------------- - input_aspect = orig_width / orig_height - output_aspect = new_width / new_height - - if abs(input_aspect - output_aspect) < 1e-6: - # Same aspect ratio; do a straightforward resize - scaled_y = cv2.resize(y, (new_width, new_height), interpolation=cv2.INTER_LINEAR) - - # For U, V we should scale to half-dimensions (rounded up) - # of the new size. But OpenCV requires exact (int) dims, so: - target_u_width = half_ceil(new_width) - target_u_height = half_ceil(new_height) - - scaled_u = cv2.resize(u, (target_u_width, target_u_height), interpolation=cv2.INTER_LINEAR) - scaled_v = cv2.resize(v, (target_u_width, target_u_height), interpolation=cv2.INTER_LINEAR) - - # Flatten and return - return np.concatenate([scaled_y.flatten(), scaled_u.flatten(), scaled_v.flatten()]).astype(np.uint8).tobytes() - - # Otherwise, the aspect ratios differ => letterbox or pillarbox - if input_aspect > output_aspect: - # The image is relatively wider => match width, shrink height - scaled_width = new_width - scaled_height = int(round(new_width / input_aspect)) - else: - # The image is relatively taller => match height, shrink width - scaled_height = new_height - scaled_width = int(round(new_height * input_aspect)) - - # 5) Resize Y, U, and V to the scaled dimensions - scaled_y = cv2.resize(y, (scaled_width, scaled_height), interpolation=cv2.INTER_LINEAR) - - # For U, V, use half-dimensions of the scaled result, rounding up. - scaled_u_width = half_ceil(scaled_width) - scaled_u_height = half_ceil(scaled_height) - scaled_u = cv2.resize(u, (scaled_u_width, scaled_u_height), interpolation=cv2.INTER_LINEAR) - scaled_v = cv2.resize(v, (scaled_u_width, scaled_u_height), interpolation=cv2.INTER_LINEAR) - - # 6) Create the output buffers. For "dark" black: - # Y=0, U=128, V=128. - final_y = np.zeros((new_height, new_width), dtype=np.uint8) - final_u = np.full((half_ceil(new_height), half_ceil(new_width)), 128, dtype=np.uint8) - final_v = np.full((half_ceil(new_height), half_ceil(new_width)), 128, dtype=np.uint8) - - # 7) Compute centering offsets for each plane (Y first) - offset_y = (new_height - scaled_height) // 2 - offset_x = (new_width - scaled_width) // 2 - - final_y[offset_y : offset_y + scaled_height, offset_x : offset_x + scaled_width] = scaled_y - - # Offsets for U and V planes are half of the Y offsets (integer floor) - offset_y_uv = offset_y // 2 - offset_x_uv = offset_x // 2 - - final_u[ - offset_y_uv : offset_y_uv + scaled_u_height, - offset_x_uv : offset_x_uv + scaled_u_width, - ] = scaled_u - final_v[ - offset_y_uv : offset_y_uv + scaled_u_height, - offset_x_uv : offset_x_uv + scaled_u_width, - ] = scaled_v - - # 8) Flatten back to I420 layout and return bytes - - return np.concatenate([final_y.flatten(), final_u.flatten(), final_v.flatten()]).astype(np.uint8).tobytes() - - -class WebBotAdapter(BotAdapter): - def __init__( - self, - *, - display_name, - send_message_callback, - meeting_url, - add_video_frame_callback, - wants_any_video_frames_callback, - add_mixed_audio_chunk_callback, - upsert_caption_callback, - automatic_leave_configuration: AutomaticLeaveConfiguration, - ): - self.display_name = display_name - self.send_message_callback = send_message_callback - self.add_mixed_audio_chunk_callback = add_mixed_audio_chunk_callback - self.add_video_frame_callback = add_video_frame_callback - self.wants_any_video_frames_callback = wants_any_video_frames_callback - self.upsert_caption_callback = upsert_caption_callback - - self.meeting_url = meeting_url - - self.video_frame_size = (1920, 1080) - - self.driver = None - - self.send_frames = True - - self.left_meeting = False - self.was_removed_from_meeting = False - self.cleaned_up = False - - self.websocket_port = None - self.websocket_server = None - self.websocket_thread = None - self.last_websocket_message_processed_time = None - self.last_media_message_processed_time = None - self.last_audio_message_processed_time = None - self.first_buffer_timestamp_ms_offset = time.time() * 1000 - - self.participants_info = {} - self.only_one_participant_in_meeting_at = None - self.video_frame_ticker = 0 - - self.automatic_leave_configuration = automatic_leave_configuration - - def get_participant(self, participant_id): - if participant_id in self.participants_info: - return { - "participant_uuid": participant_id, - "participant_full_name": self.participants_info[participant_id]["fullName"], - "participant_user_uuid": None, - } - - return None - - def process_video_frame(self, message): - self.last_media_message_processed_time = time.time() - if len(message) > 24: # Minimum length check - # Bytes 4-12 contain the timestamp - timestamp = int.from_bytes(message[4:12], byteorder="little") - - # Get stream ID length and string - stream_id_length = int.from_bytes(message[12:16], byteorder="little") - message[16 : 16 + stream_id_length].decode("utf-8") - - # Get width and height after stream ID - offset = 16 + stream_id_length - width = int.from_bytes(message[offset : offset + 4], byteorder="little") - height = int.from_bytes(message[offset + 4 : offset + 8], byteorder="little") - - # Keep track of the video frame dimensions - if self.video_frame_ticker % 300 == 0: - logger.info(f"video dimensions {width} {height} message length {len(message) - offset - 8}") - self.video_frame_ticker += 1 - - # Scale frame to 1920x1080 - expected_video_data_length = width * height + 2 * half_ceil(width) * half_ceil(height) - video_data = np.frombuffer(message[offset + 8 :], dtype=np.uint8) - - # Check if len(video_data) does not agree with width and height - if len(video_data) == expected_video_data_length: # I420 format uses 1.5 bytes per pixel - scaled_i420_frame = scale_i420(video_data, (width, height), (1920, 1080)) - if self.wants_any_video_frames_callback() and self.send_frames: - self.add_video_frame_callback(scaled_i420_frame, timestamp * 1000) - - else: - logger.info(f"video data length does not agree with width and height {len(video_data)} {width} {height}") - - def process_audio_frame(self, message): - self.last_media_message_processed_time = time.time() - if len(message) > 12: - # Bytes 4-12 contain the timestamp - timestamp = int.from_bytes(message[4:12], byteorder="little") - - # Bytes 12-16 contain the stream ID - stream_id = int.from_bytes(message[12:16], byteorder="little") - - # Convert the float32 audio data to numpy array - audio_data = np.frombuffer(message[16:], dtype=np.float32) - - # Only mark last_audio_message_processed_time if the audio data has at least one non-zero value - if np.any(audio_data): - self.last_audio_message_processed_time = time.time() - - if self.wants_any_video_frames_callback() and self.send_frames: - self.add_mixed_audio_chunk_callback(audio_data.tobytes(), timestamp * 1000, stream_id % 3) - - def handle_websocket(self, websocket): - audio_format = None - output_dir = "frames" # Add output directory - - # Create frames directory if it doesn't exist - os.makedirs(output_dir, exist_ok=True) - - try: - for message in websocket: - # Get first 4 bytes as message type - message_type = int.from_bytes(message[:4], byteorder="little") - - if message_type == 1: # JSON - json_data = json.loads(message[4:].decode("utf-8")) - logger.info("Received JSON message: %s", json_data) - - # Handle audio format information - if isinstance(json_data, dict): - if json_data.get("type") == "AudioFormatUpdate": - audio_format = json_data["format"] - logger.info(f"audio format {audio_format}") - - elif json_data.get("type") == "CaptionUpdate": - self.upsert_caption_callback(json_data["caption"]) - - elif json_data.get("type") == "UsersUpdate": - for user in json_data["newUsers"]: - user["active"] = user["humanized_status"] == "in_meeting" - self.participants_info[user["deviceId"]] = user - for user in json_data["removedUsers"]: - user["active"] = False - self.participants_info[user["deviceId"]] = user - for user in json_data["updatedUsers"]: - user["active"] = user["humanized_status"] == "in_meeting" - self.participants_info[user["deviceId"]] = user - - if user["humanized_status"] == "removed_from_meeting" and user["fullName"] == self.display_name: - # if this is the only participant with that name in the meeting, then we can assume that it was us who was removed - if len([x for x in self.participants_info.values() if x["fullName"] == self.display_name]) == 1: - self.was_removed_from_meeting = True - self.send_message_callback({"message": self.Messages.MEETING_ENDED}) - - all_participants_in_meeting = [x for x in self.participants_info.values() if x["active"]] - if len(all_participants_in_meeting) == 1 and all_participants_in_meeting[0]["fullName"] == self.display_name: - if self.only_one_participant_in_meeting_at is None: - self.only_one_participant_in_meeting_at = time.time() - else: - self.only_one_participant_in_meeting_at = None - - elif message_type == 2: # VIDEO - self.process_video_frame(message) - elif message_type == 3: # AUDIO - self.process_audio_frame(message) - - self.last_websocket_message_processed_time = time.time() - except Exception as e: - logger.info(f"Websocket error: {e}") - - def run_websocket_server(self): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - port = self.get_websocket_port() - max_retries = 10 - - for attempt in range(max_retries): - try: - self.websocket_server = serve( - self.handle_websocket, - "localhost", - port, - compression=None, - max_size=None, - ) - logger.info(f"Websocket server started on ws://localhost:{port}") - self.websocket_port = port - self.websocket_server.serve_forever() - break - except OSError as e: - if e.errno == 98: # Address already in use - logger.info(f"Port {port} is already in use, trying next port...") - port += 1 - if attempt == max_retries - 1: - raise Exception(f"Could not find available port after {max_retries} attempts") - continue - raise # Re-raise other OSErrors - - def send_request_to_join_denied_message(self): - self.send_message_callback({"message": self.Messages.REQUEST_TO_JOIN_DENIED}) - - def send_debug_screenshot_message(self, step, exception, inner_exception): - current_time = datetime.datetime.now() - timestamp = current_time.strftime("%Y%m%d_%H%M%S") - screenshot_path = f"/tmp/ui_element_not_found_{timestamp}.png" - try: - self.driver.save_screenshot(screenshot_path) - except Exception as e: - logger.info(f"Error saving screenshot: {e}") - screenshot_path = None - - self.send_message_callback( - { - "message": self.Messages.UI_ELEMENT_NOT_FOUND, - "step": step, - "current_time": current_time, - "screenshot_path": screenshot_path, - "exception_type": exception.__class__.__name__ if exception else "exception_not_available", - "exception_message": exception.__str__() if exception else "exception_message_not_available", - "inner_exception_type": inner_exception.__class__.__name__ if inner_exception else "inner_exception_not_available", - "inner_exception_message": inner_exception.__str__() if inner_exception else "inner_exception_message_not_available", - } - ) - - def init_driver(self): - log_path = "chromedriver.log" - - options = uc.ChromeOptions() - - options.add_argument("--use-fake-ui-for-media-stream") - options.add_argument("--window-size=1920x1080") - options.add_argument("--no-sandbox") - # options.add_argument('--headless=new') - options.add_argument("--disable-gpu") - options.add_argument("--disable-extensions") - options.add_argument("--disable-application-cache") - options.add_argument("--disable-setuid-sandbox") - options.add_argument("--disable-dev-shm-usage") - - if self.driver: - # Simulate closing browser window - try: - self.driver.close() - except Exception as e: - logger.info(f"Error closing driver: {e}") - - try: - self.driver.quit() - except Exception as e: - logger.info(f"Error closing existing driver: {e}") - self.driver = None - - self.driver = uc.Chrome( - service_log_path=log_path, - use_subprocess=True, - options=options, - version_main=133, - ) - - initial_data_code = f"window.initialData = {{websocketPort: {self.websocket_port}}}" - - # Define the CDN libraries needed - CDN_LIBRARIES = ["https://cdnjs.cloudflare.com/ajax/libs/protobufjs/7.4.0/protobuf.min.js", "https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js"] - - # Download all library code - libraries_code = "" - for url in CDN_LIBRARIES: - response = requests.get(url) - if response.status_code == 200: - libraries_code += response.text + "\n" - else: - raise Exception(f"Failed to download library from {url}") - - # Get directory of current file - current_dir = os.path.dirname(os.path.abspath(__file__)) - # Read your payload using path relative to current file - with open(os.path.join(current_dir, "..", self.get_chromedriver_payload_file_name()), "r") as file: - payload_code = file.read() - - # Combine them ensuring libraries load first - combined_code = f""" - {initial_data_code} - {libraries_code} - {payload_code} - """ - - # Add the combined script to execute on new document - self.driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {"source": combined_code}) - - def init(self): - if os.environ.get("DISPLAY") is None: - # Create virtual display only if no real display is available - display = Display(visible=0, size=(1920, 1080)) - display.start() - - # Start websocket server in a separate thread - websocket_thread = threading.Thread(target=self.run_websocket_server, daemon=True) - websocket_thread.start() - - sleep(0.5) # Give the websocketserver time to start - if not self.websocket_port: - raise Exception("WebSocket server failed to start") - - repeatedly_attempt_to_join_meeting_thread = threading.Thread(target=self.repeatedly_attempt_to_join_meeting, daemon=True) - repeatedly_attempt_to_join_meeting_thread.start() - - def repeatedly_attempt_to_join_meeting(self): - logger.info(f"Trying to join meeting at {self.meeting_url}") - - num_retries = 0 - max_retries = 2 - while num_retries <= max_retries: - try: - self.init_driver() - self.attempt_to_join_meeting() - logger.info("Successfully joined meeting") - break - - except UiRequestToJoinDeniedException: - self.send_request_to_join_denied_message() - return - - except UiRetryableException as e: - if num_retries >= max_retries: - logger.info(f"Failed to join meeting and the {e.__class__.__name__} exception is retryable but the number of retries exceeded the limit, so returning") - self.send_debug_screenshot_message(step=e.step, exception=e, inner_exception=e.inner_exception) - return - - if self.left_meeting or self.cleaned_up: - logger.info(f"Failed to join meeting and the {e.__class__.__name__} exception is retryable but the bot has left the meeting or cleaned up, so returning") - return - - logger.info(f"Failed to join meeting and the {e.__class__.__name__} exception is retryable so retrying") - - num_retries += 1 - sleep(1) - - # Trying making it smaller so GMeet sends smaller video frames - self.driver.set_window_size(1920 / 2, 1080 / 2) - - self.send_message_callback({"message": self.Messages.BOT_JOINED_MEETING}) - self.send_message_callback({"message": self.Messages.BOT_RECORDING_PERMISSION_GRANTED}) - - self.send_frames = True - self.driver.execute_script("window.ws?.enableMediaSending();") - self.first_buffer_timestamp_ms_offset = self.driver.execute_script("return performance.timeOrigin;") - - def leave(self): - if self.left_meeting: - return - if self.was_removed_from_meeting: - return - - try: - logger.info("disable media sending") - self.driver.execute_script("window.ws?.disableMediaSending();") - - self.click_leave_button() - except Exception as e: - logger.info(f"Error during leave: {e}") - finally: - self.send_message_callback({"message": self.Messages.MEETING_ENDED}) - self.left_meeting = True - - def cleanup(self): - try: - logger.info("disable media sending") - self.driver.execute_script("window.ws?.disableMediaSending();") - except Exception as e: - logger.info(f"Error during media sending disable: {e}") - - # Wait for websocket buffers to be processed - if self.last_websocket_message_processed_time: - time_when_shutdown_initiated = time.time() - while time.time() - self.last_websocket_message_processed_time < 2 and time.time() - time_when_shutdown_initiated < 30: - logger.info(f"Waiting until it's 2 seconds since last websockets message was processed or 30 seconds have passed. Currently it is {time.time() - self.last_websocket_message_processed_time} seconds and {time.time() - time_when_shutdown_initiated} seconds have passed") - sleep(0.5) - - try: - if self.driver: - # Simulate closing browser window - try: - self.driver.close() - except Exception as e: - logger.info(f"Error closing driver: {e}") - - # Then quit the driver - try: - self.driver.quit() - except Exception as e: - logger.info(f"Error quitting driver: {e}") - except Exception as e: - logger.info(f"Error during cleanup: {e}") - - # Properly shutdown the websocket server - if self.websocket_server: - try: - self.websocket_server.shutdown() - except Exception as e: - logger.info(f"Error shutting down websocket server: {e}") - - self.cleaned_up = True - - def get_first_buffer_timestamp_ms_offset(self): - return self.first_buffer_timestamp_ms_offset - - def check_auto_leave_conditions(self) -> None: - if self.left_meeting: - return - if self.cleaned_up: - return - - if self.only_one_participant_in_meeting_at is not None: - if time.time() - self.only_one_participant_in_meeting_at > self.automatic_leave_configuration.only_participant_in_meeting_threshold_seconds: - logger.info(f"Auto-leaving meeting because there was only one participant in the meeting for {self.automatic_leave_configuration.only_participant_in_meeting_threshold_seconds} seconds") - self.send_message_callback({"message": self.Messages.ADAPTER_REQUESTED_BOT_LEAVE_MEETING, "leave_reason": BotAdapter.LEAVE_REASON.AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING}) - return - - if self.last_audio_message_processed_time is not None: - if time.time() - self.last_audio_message_processed_time > self.automatic_leave_configuration.silence_threshold_seconds: - logger.info(f"Auto-leaving meeting because there was no media message for {self.automatic_leave_configuration.silence_threshold_seconds} seconds") - self.send_message_callback({"message": self.Messages.ADAPTER_REQUESTED_BOT_LEAVE_MEETING, "leave_reason": BotAdapter.LEAVE_REASON.AUTO_LEAVE_SILENCE}) - return - - def send_raw_audio(self, bytes, sample_rate): - logger.info("send_raw_audio not supported in google meet bots") diff --git a/attendee/bots/zoom_bot_adapter/__init__.py b/attendee/bots/zoom_bot_adapter/__init__.py deleted file mode 100644 index 8956882..0000000 --- a/attendee/bots/zoom_bot_adapter/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .zoom_bot_adapter import ZoomBotAdapter - -__all__ = ["ZoomBotAdapter"] diff --git a/attendee/bots/zoom_bot_adapter/video_input_manager.py b/attendee/bots/zoom_bot_adapter/video_input_manager.py deleted file mode 100644 index 5704f80..0000000 --- a/attendee/bots/zoom_bot_adapter/video_input_manager.py +++ /dev/null @@ -1,302 +0,0 @@ -import logging -import time - -import cv2 -import numpy as np -import zoom_meeting_sdk as zoom -from gi.repository import GLib - -logger = logging.getLogger(__name__) - - -def create_black_i420_frame(video_frame_size): - width, height = video_frame_size - # Ensure dimensions are even for proper chroma subsampling - if width % 2 != 0 or height % 2 != 0: - raise ValueError("Width and height must be even numbers for I420 format") - - # Y plane (black = 0 in Y plane) - y_plane = np.zeros((height, width), dtype=np.uint8) - - # U and V planes (black = 128 in UV planes) - # Both are quarter size of original due to 4:2:0 subsampling - u_plane = np.full((height // 2, width // 2), 128, dtype=np.uint8) - v_plane = np.full((height // 2, width // 2), 128, dtype=np.uint8) - - # Concatenate all planes - yuv_frame = np.concatenate([y_plane.flatten(), u_plane.flatten(), v_plane.flatten()]) - - return yuv_frame.astype(np.uint8).tobytes() - - -def scale_i420(frame, new_size): - new_width, new_height = new_size - """ - Scales the given frame in I420 format to new_width x new_height while - preserving aspect ratio. If the aspect ratios do not match, letterboxes/pillarboxes - the scaled image on a black background. - - :param frame: Frame object with methods: - - GetStreamWidth() - - GetStreamHeight() - - GetYBuffer() - - GetUBuffer() - - GetVBuffer() - :param new_width: Desired width. - :param new_height: Desired height. - :return: Scaled (and possibly letter/pillarboxed) I420 frame bytes. - """ - orig_width = frame.GetStreamWidth() - orig_height = frame.GetStreamHeight() - - # 1) Convert buffers to NumPy arrays without extra copies if possible. - y = np.frombuffer(frame.GetYBuffer(), dtype=np.uint8, count=orig_width * orig_height) - u = np.frombuffer(frame.GetUBuffer(), dtype=np.uint8, count=(orig_width // 2) * (orig_height // 2)) - v = np.frombuffer(frame.GetVBuffer(), dtype=np.uint8, count=(orig_width // 2) * (orig_height // 2)) - - # Reshape planes - y = y.reshape(orig_height, orig_width) - u = u.reshape(orig_height // 2, orig_width // 2) - v = v.reshape(orig_height // 2, orig_width // 2) - - # 2) Determine scale preserving aspect ratio - input_aspect = orig_width / orig_height - output_aspect = new_width / new_height - - if abs(input_aspect - output_aspect) < 1e-6: - # Aspect ratios match (or extremely close). Just do a simple stretch to (new_width, new_height). - scaled_y = cv2.resize(y, (new_width, new_height), interpolation=cv2.INTER_LINEAR) - scaled_u = cv2.resize(u, (new_width // 2, new_height // 2), interpolation=cv2.INTER_LINEAR) - scaled_v = cv2.resize(v, (new_width // 2, new_height // 2), interpolation=cv2.INTER_LINEAR) - - # Flatten and return - return np.concatenate([scaled_y.flatten(), scaled_u.flatten(), scaled_v.flatten()]).astype(np.uint8).tobytes() - - # Otherwise, the aspect ratios differ => letterbox or pillarbox - # 3) Compute scaled dimensions that fit entirely within (new_width, new_height) - if input_aspect > output_aspect: - # The image is relatively wider => match width, shrink height - scaled_width = new_width - scaled_height = int(round(new_width / input_aspect)) - else: - # The image is relatively taller => match height, shrink width - scaled_height = new_height - scaled_width = int(round(new_height * input_aspect)) - - # 4) Resize Y, U, and V to the scaled dimensions - scaled_y = cv2.resize(y, (scaled_width, scaled_height), interpolation=cv2.INTER_LINEAR) - scaled_u = cv2.resize(u, (scaled_width // 2, scaled_height // 2), interpolation=cv2.INTER_LINEAR) - scaled_v = cv2.resize(v, (scaled_width // 2, scaled_height // 2), interpolation=cv2.INTER_LINEAR) - - # 5) Create the black background only if needed - # For I420, black is typically (Y=0, U=128, V=128) or (Y=16, U=128, V=128). - # We'll use Y=0, U=128, V=128 for "dark" black. - final_y = np.zeros((new_height, new_width), dtype=np.uint8) - final_u = np.full((new_height // 2, new_width // 2), 128, dtype=np.uint8) - final_v = np.full((new_height // 2, new_width // 2), 128, dtype=np.uint8) - - # 6) Compute centering offsets for each plane - # For Y-plane - offset_y = (new_height - scaled_height) // 2 - offset_x = (new_width - scaled_width) // 2 - - # Insert Y - final_y[offset_y : offset_y + scaled_height, offset_x : offset_x + scaled_width] = scaled_y - - # For U, V planes (subsampled by 2 in each dimension) - offset_y_uv = offset_y // 2 - offset_x_uv = offset_x // 2 - - final_u[ - offset_y_uv : offset_y_uv + (scaled_height // 2), - offset_x_uv : offset_x_uv + (scaled_width // 2), - ] = scaled_u - final_v[ - offset_y_uv : offset_y_uv + (scaled_height // 2), - offset_x_uv : offset_x_uv + (scaled_width // 2), - ] = scaled_v - - # 7) Flatten back to I420 layout and return bytes - return np.concatenate([final_y.flatten(), final_u.flatten(), final_v.flatten()]).astype(np.uint8).tobytes() - - -class VideoInputStream: - def __init__(self, video_input_manager, user_id, stream_type, share_source_id): - self.video_input_manager = video_input_manager - self.user_id = user_id - self.stream_type = stream_type - self.share_source_id = share_source_id - self.renderer_destroyed = False - self.last_debug_frame_time = None - self.renderer_delegate = zoom.ZoomSDKRendererDelegateCallbacks( - onRawDataFrameReceivedCallback=self.on_raw_video_frame_received_callback, - onRendererBeDestroyedCallback=self.on_renderer_destroyed_callback, - onRawDataStatusChangedCallback=self.on_raw_data_status_changed_callback, - ) - - self.renderer = zoom.createRenderer(self.renderer_delegate) - set_resolution_result = self.renderer.setRawDataResolution(zoom.ZoomSDKResolution_180P) - raw_data_type = { - VideoInputManager.StreamType.SCREENSHARE: zoom.ZoomSDKRawDataType.RAW_DATA_TYPE_SHARE, - VideoInputManager.StreamType.VIDEO: zoom.ZoomSDKRawDataType.RAW_DATA_TYPE_VIDEO, - }[stream_type] - - if stream_type == VideoInputManager.StreamType.SCREENSHARE: - subscribe_result = self.renderer.subscribe(self.share_source_id, raw_data_type) - else: - subscribe_result = self.renderer.subscribe(self.user_id, raw_data_type) - - self.raw_data_status = zoom.RawData_Off - - self.last_frame_time = time.time() - self.black_frame_timer_id = GLib.timeout_add(250, self.send_black_frame) - - logger.info(f"In VideoInputStream.init self.renderer = {self.renderer}") - logger.info(f"In VideoInputStream.init set_resolution_result for user {self.user_id} and share source id {self.share_source_id} is {set_resolution_result}") - logger.info(f"In VideoInputStream.init subscribe_result for user {self.user_id} and share source id {self.share_source_id} is {subscribe_result}") - - def on_raw_data_status_changed_callback(self, status): - self.raw_data_status = status - logger.info(f"In VideoInputStream.on_raw_data_status_changed_callback raw_data_status for user {self.user_id} is {self.raw_data_status}") - - def send_black_frame(self): - if self.renderer_destroyed: - return False - - current_time = time.time() - if current_time - self.last_frame_time >= 0.25 and self.raw_data_status == zoom.RawData_Off: - # Create a black frame of the same dimensions - black_frame = create_black_i420_frame(self.video_input_manager.video_frame_size) - self.video_input_manager.new_frame_callback(black_frame, time.time_ns()) - logger.info(f"In VideoInputStream.send_black_frame for user {self.user_id} sent black frame") - - return not self.renderer_destroyed # Continue timer if not cleaned up - - def cleanup(self): - if self.renderer_destroyed: - return - - if self.black_frame_timer_id is not None: - GLib.source_remove(self.black_frame_timer_id) - self.black_frame_timer_id = None - - logger.info(f"starting renderer unsubscription for user {self.user_id} and share source id {self.share_source_id}") - self.renderer.unSubscribe() - logger.info(f"finished renderer unsubscription for user {self.user_id} and share source id {self.share_source_id}") - - def on_renderer_destroyed_callback(self): - self.renderer_destroyed = True - logger.info(f"renderer destroyed for user {self.user_id}") - - def on_raw_video_frame_received_callback(self, data): - current_time_ns = time.time_ns() - - if self.renderer_destroyed: - return - - if not self.video_input_manager.wants_frames_for_user(self.user_id): - return - - self.last_frame_time = time.time() - - i420_frame = data.GetBuffer() - - if i420_frame is None or len(i420_frame) == 0: - logger.warning(f"In VideoInputStream.on_raw_video_frame_received_callback invalid frame received for user {self.user_id}") - return - - if self.last_debug_frame_time is None or time.time() - self.last_debug_frame_time > 1: - logger.debug(f"In VideoInputStream.on_raw_video_frame_received_callback for user {self.user_id} received frame") - self.last_debug_frame_time = time.time() - - scaled_i420_frame = scale_i420(data, self.video_input_manager.video_frame_size) - self.video_input_manager.new_frame_callback(scaled_i420_frame, current_time_ns) - - -class VideoInputManager: - class StreamType: - VIDEO = 1 - SCREENSHARE = 2 - - class Mode: - ACTIVE_SPEAKER = 1 - ACTIVE_SHARER = 2 - - def __init__(self, *, new_frame_callback, wants_any_frames_callback, video_frame_size): - self.new_frame_callback = new_frame_callback - self.wants_any_frames_callback = wants_any_frames_callback - self.video_frame_size = video_frame_size - self.mode = None - self.input_streams = [] - - def has_any_video_input_streams(self): - return len(self.input_streams) > 0 - - def add_input_streams_if_needed(self, streams_info): - streams_to_remove = [input_stream for input_stream in self.input_streams if not any(stream_info["user_id"] == input_stream.user_id and stream_info["stream_type"] == input_stream.stream_type and stream_info["share_source_id"] == input_stream.share_source_id for stream_info in streams_info)] - - for stream in streams_to_remove: - stream.cleanup() - self.input_streams.remove(stream) - - for stream_info in streams_info: - if any(input_stream.user_id == stream_info["user_id"] and input_stream.stream_type == stream_info["stream_type"] and input_stream.share_source_id == stream_info["share_source_id"] for input_stream in self.input_streams): - continue - - self.input_streams.append( - VideoInputStream( - self, - stream_info["user_id"], - stream_info["stream_type"], - stream_info["share_source_id"], - ) - ) - - def cleanup(self): - for input_stream in self.input_streams: - input_stream.cleanup() - - def set_mode(self, *, mode, active_speaker_id, active_sharer_id, active_sharer_source_id): - if mode != VideoInputManager.Mode.ACTIVE_SPEAKER and mode != VideoInputManager.Mode.ACTIVE_SHARER: - raise Exception("Unsupported mode " + str(mode)) - - logger.info(f"In VideoInputManager.set_mode mode = {mode} active_speaker_id = {active_speaker_id} active_sharer_id = {active_sharer_id} active_sharer_source_id = {active_sharer_source_id}") - - self.mode = mode - - if self.mode == VideoInputManager.Mode.ACTIVE_SPEAKER: - self.active_speaker_id = active_speaker_id - self.add_input_streams_if_needed( - [ - { - "stream_type": VideoInputManager.StreamType.VIDEO, - "user_id": active_speaker_id, - "share_source_id": None, - } - ] - ) - - if self.mode == VideoInputManager.Mode.ACTIVE_SHARER: - self.active_sharer_id = active_sharer_id - self.active_sharer_source_id = active_sharer_source_id - self.add_input_streams_if_needed( - [ - { - "stream_type": VideoInputManager.StreamType.SCREENSHARE, - "user_id": active_sharer_id, - "share_source_id": active_sharer_source_id, - } - ] - ) - - def wants_frames_for_user(self, user_id): - if not self.wants_any_frames_callback(): - return False - - if self.mode == VideoInputManager.Mode.ACTIVE_SPEAKER and user_id != self.active_speaker_id: - return False - - if self.mode == VideoInputManager.Mode.ACTIVE_SHARER and user_id != self.active_sharer_id: - return False - - return True diff --git a/attendee/bots/zoom_bot_adapter/zoom_bot_adapter.py b/attendee/bots/zoom_bot_adapter/zoom_bot_adapter.py deleted file mode 100644 index 882f6e5..0000000 --- a/attendee/bots/zoom_bot_adapter/zoom_bot_adapter.py +++ /dev/null @@ -1,652 +0,0 @@ -import re -import time -from datetime import datetime, timedelta -from urllib.parse import parse_qs, urlparse - -import cv2 -import gi -import jwt -import numpy as np -import zoom_meeting_sdk as zoom - -from bots.bot_adapter import BotAdapter - -from .video_input_manager import VideoInputManager - -gi.require_version("GLib", "2.0") -import logging - -from gi.repository import GLib - -from bots.bot_controller.automatic_leave_configuration import AutomaticLeaveConfiguration - -logger = logging.getLogger(__name__) - - -def generate_jwt(client_id, client_secret): - iat = datetime.utcnow() - exp = iat + timedelta(hours=24) - - payload = { - "iat": iat, - "exp": exp, - "appKey": client_id, - "tokenExp": int(exp.timestamp()), - } - - token = jwt.encode(payload, client_secret, algorithm="HS256") - return token - - -def create_black_yuv420_frame(width=640, height=360): - # Create BGR frame (red is [0,0,0] in BGR) - bgr_frame = np.zeros((height, width, 3), dtype=np.uint8) - bgr_frame[:, :] = [0, 0, 0] # Pure black in BGR - - # Convert BGR to YUV420 (I420) - yuv_frame = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2YUV_I420) - - # Return as bytes - return yuv_frame.tobytes() - - -def parse_join_url(join_url): - # Parse the URL into components - parsed = urlparse(join_url) - - # Extract meeting ID using regex to match only numeric characters - meeting_id_match = re.search(r"(\d+)", parsed.path) - meeting_id = meeting_id_match.group(1) if meeting_id_match else None - - # Extract password from query parameters - query_params = parse_qs(parsed.query) - password = query_params.get("pwd", [None])[0] - - return (meeting_id, password) - - -class ZoomBotAdapter(BotAdapter): - def __init__( - self, - *, - use_one_way_audio, - use_mixed_audio, - use_video, - display_name, - send_message_callback, - add_audio_chunk_callback, - zoom_client_id, - zoom_client_secret, - meeting_url, - add_video_frame_callback, - wants_any_video_frames_callback, - add_mixed_audio_chunk_callback, - automatic_leave_configuration: AutomaticLeaveConfiguration, - ): - self.use_one_way_audio = use_one_way_audio - self.use_mixed_audio = use_mixed_audio - self.use_video = use_video - self.display_name = display_name - self.send_message_callback = send_message_callback - self.add_audio_chunk_callback = add_audio_chunk_callback - self.add_mixed_audio_chunk_callback = add_mixed_audio_chunk_callback - self.add_video_frame_callback = add_video_frame_callback - self.wants_any_video_frames_callback = wants_any_video_frames_callback - - self._jwt_token = generate_jwt(zoom_client_id, zoom_client_secret) - self.meeting_id, self.meeting_password = parse_join_url(meeting_url) - - self.meeting_service = None - self.setting_service = None - self.auth_service = None - - self.auth_event = None - self.recording_event = None - self.meeting_service_event = None - - self.audio_source = None - self.audio_helper = None - - self.audio_settings = None - - self.use_raw_recording = True - self.recording_permission_granted = False - - self.reminder_controller = None - - self.recording_ctrl = None - - self.audio_raw_data_sender = None - self.virtual_audio_mic_event_passthrough = None - - self.my_participant_id = None - self.participants_ctrl = None - self.meeting_reminder_event = None - self.on_mic_start_send_callback_called = False - self.on_virtual_camera_start_send_callback_called = False - - self.meeting_video_controller = None - self.video_sender = None - self.virtual_camera_video_source = None - self.video_source_helper = None - self.video_frame_size = (1920, 1080) - - self.automatic_leave_configuration = automatic_leave_configuration - - self.only_one_participant_in_meeting_at = None - self.last_audio_received_at = None - self.cleaned_up = False - self.requested_leave = False - - if self.use_video: - self.video_input_manager = VideoInputManager( - new_frame_callback=self.add_video_frame_callback, - wants_any_frames_callback=self.wants_any_video_frames_callback, - video_frame_size=self.video_frame_size, - ) - else: - self.video_input_manager = None - - self.meeting_sharing_controller = None - self.meeting_share_ctrl_event = None - - self.active_speaker_id = None - self.active_sharer_id = None - self.active_sharer_source_id = None - - self._participant_cache = {} - - self.meeting_status = None - - def on_user_join_callback(self, joined_user_ids, _): - logger.info(f"on_user_join_callback called. joined_user_ids = {joined_user_ids}") - for joined_user_id in joined_user_ids: - self.get_participant(joined_user_id) - - def on_user_left_callback(self, left_user_ids, _): - logger.info(f"on_user_left_callback called. left_user_ids = {left_user_ids}") - all_participant_ids = self.participants_ctrl.GetParticipantsList() - if len(all_participant_ids) == 1: - if self.only_one_participant_in_meeting_at is None: - self.only_one_participant_in_meeting_at = time.time() - else: - self.only_one_participant_in_meeting_at = None - - def on_user_active_audio_change_callback(self, user_ids): - if len(user_ids) == 0: - return - - if user_ids[0] == self.my_participant_id: - return - - if self.active_speaker_id == user_ids[0]: - return - - self.active_speaker_id = user_ids[0] - self.set_video_input_manager_based_on_state() - - def set_video_input_manager_based_on_state(self): - if not self.wants_any_video_frames_callback(): - return - - if not self.recording_permission_granted: - return - - if not self.video_input_manager: - return - - logger.info(f"set_video_input_manager_based_on_state self.active_speaker_id = {self.active_speaker_id}, self.active_sharer_id = {self.active_sharer_id}, self.active_sharer_source_id = {self.active_sharer_source_id}") - if self.active_sharer_id: - self.video_input_manager.set_mode( - mode=VideoInputManager.Mode.ACTIVE_SHARER, - active_sharer_id=self.active_sharer_id, - active_sharer_source_id=self.active_sharer_source_id, - active_speaker_id=self.active_speaker_id, - ) - elif self.active_speaker_id: - self.video_input_manager.set_mode( - mode=VideoInputManager.Mode.ACTIVE_SPEAKER, - active_sharer_id=self.active_sharer_id, - active_sharer_source_id=self.active_sharer_source_id, - active_speaker_id=self.active_speaker_id, - ) - else: - # If there is no active sharer or speaker, we'll just use the video of the first participant that is not the bot - # or if there are no participants, we'll use the bot - default_participant_id = self.my_participant_id - - participant_list = self.participants_ctrl.GetParticipantsList() - for participant_id in participant_list: - if participant_id != self.my_participant_id: - default_participant_id = participant_id - break - - logger.info(f"set_video_input_manager_based_on_state hit default case. default_participant_id = {default_participant_id}") - self.video_input_manager.set_mode( - mode=VideoInputManager.Mode.ACTIVE_SPEAKER, - active_speaker_id=default_participant_id, - active_sharer_id=None, - active_sharer_source_id=None, - ) - - def set_up_video_input_manager(self): - # If someone was sharing before we joined, we will not receive an event, so we need to poll for the active sharer - viewable_sharing_user_list = self.meeting_sharing_controller.GetViewableSharingUserList() - self.active_sharer_id = None - self.active_sharer_source_id = None - - if viewable_sharing_user_list: - sharing_source_info_list = self.meeting_sharing_controller.GetSharingSourceInfoList(viewable_sharing_user_list[0]) - if sharing_source_info_list: - self.active_sharer_id = sharing_source_info_list[0].userid - self.active_sharer_source_id = sharing_source_info_list[0].shareSourceID - - self.set_video_input_manager_based_on_state() - - def cleanup(self): - if self.audio_source: - performance_data = self.audio_source.getPerformanceData() - logger.info(f"totalProcessingTimeMicroseconds = {performance_data.totalProcessingTimeMicroseconds}") - logger.info(f"numCalls = {performance_data.numCalls}") - logger.info(f"maxProcessingTimeMicroseconds = {performance_data.maxProcessingTimeMicroseconds}") - logger.info(f"minProcessingTimeMicroseconds = {performance_data.minProcessingTimeMicroseconds}") - logger.info(f"meanProcessingTimeMicroseconds = {float(performance_data.totalProcessingTimeMicroseconds) / performance_data.numCalls}") - - # Print processing time distribution - bin_size = (performance_data.processingTimeBinMax - performance_data.processingTimeBinMin) / len(performance_data.processingTimeBinCounts) - logger.info("\nProcessing time distribution (microseconds):") - for bin_idx, count in enumerate(performance_data.processingTimeBinCounts): - if count > 0: - bin_start = bin_idx * bin_size - bin_end = (bin_idx + 1) * bin_size - logger.info(f"{bin_start:6.0f} - {bin_end:6.0f} us: {count:5d} calls") - - if self.meeting_service: - zoom.DestroyMeetingService(self.meeting_service) - logger.info("Destroyed Meeting service") - if self.setting_service: - zoom.DestroySettingService(self.setting_service) - logger.info("Destroyed Setting service") - if self.auth_service: - zoom.DestroyAuthService(self.auth_service) - logger.info("Destroyed Auth service") - - if self.audio_helper: - audio_helper_unsubscribe_result = self.audio_helper.unSubscribe() - logger.info(f"audio_helper.unSubscribe() returned {audio_helper_unsubscribe_result}") - - if self.video_input_manager: - self.video_input_manager.cleanup() - - logger.info("CleanUPSDK() called") - zoom.CleanUPSDK() - logger.info("CleanUPSDK() finished") - self.cleaned_up = True - - def init(self): - init_param = zoom.InitParam() - - init_param.strWebDomain = "https://zoom.us" - init_param.strSupportUrl = "https://zoom.us" - init_param.enableGenerateDump = True - init_param.emLanguageID = zoom.SDK_LANGUAGE_ID.LANGUAGE_English - init_param.enableLogByDefault = True - - init_sdk_result = zoom.InitSDK(init_param) - if init_sdk_result != zoom.SDKERR_SUCCESS: - raise Exception("InitSDK failed") - - self.create_services() - - def get_participant(self, participant_id): - try: - speaker_object = self.participants_ctrl.GetUserByUserID(participant_id) - participant_info = { - "participant_uuid": participant_id, - "participant_user_uuid": speaker_object.GetPersistentId(), - "participant_full_name": speaker_object.GetUserName(), - } - self._participant_cache[participant_id] = participant_info - return participant_info - except: - logger.info(f"Error getting participant {participant_id}, falling back to cache") - return self._participant_cache.get(participant_id) - - def on_sharing_status_callback(self, sharing_info): - user_id = sharing_info.userid - sharing_status = sharing_info.status - logger.info(f"on_sharing_status_callback called. sharing_status = {sharing_status}, user_id = {user_id}") - - if sharing_status == zoom.Sharing_Other_Share_Begin or sharing_status == zoom.Sharing_View_Other_Sharing: - new_active_sharer_id = user_id - new_active_sharer_source_id = sharing_info.shareSourceID - else: - new_active_sharer_id = None - new_active_sharer_source_id = None - - if new_active_sharer_id != self.active_sharer_id or new_active_sharer_source_id != self.active_sharer_source_id: - self.active_sharer_id = new_active_sharer_id - self.active_sharer_source_id = new_active_sharer_source_id - self.set_video_input_manager_based_on_state() - - def on_join(self): - # Meeting reminder controller - self.meeting_reminder_event = zoom.MeetingReminderEventCallbacks(onReminderNotifyCallback=self.on_reminder_notify) - self.reminder_controller = self.meeting_service.GetMeetingReminderController() - self.reminder_controller.SetEvent(self.meeting_reminder_event) - - # Participants controller - self.participants_ctrl = self.meeting_service.GetMeetingParticipantsController() - self.participants_ctrl_event = zoom.MeetingParticipantsCtrlEventCallbacks(onUserJoinCallback=self.on_user_join_callback, onUserLeftCallback=self.on_user_left_callback) - self.participants_ctrl.SetEvent(self.participants_ctrl_event) - self.my_participant_id = self.participants_ctrl.GetMySelfUser().GetUserID() - participant_ids_list = self.participants_ctrl.GetParticipantsList() - for participant_id in participant_ids_list: - self.get_participant(participant_id) - - # Meeting sharing controller - self.meeting_sharing_controller = self.meeting_service.GetMeetingShareController() - self.meeting_share_ctrl_event = zoom.MeetingShareCtrlEventCallbacks(onSharingStatusCallback=self.on_sharing_status_callback) - self.meeting_sharing_controller.SetEvent(self.meeting_share_ctrl_event) - - # Audio controller - self.audio_ctrl = self.meeting_service.GetMeetingAudioController() - self.audio_ctrl_event = zoom.MeetingAudioCtrlEventCallbacks(onUserActiveAudioChangeCallback=self.on_user_active_audio_change_callback) - self.audio_ctrl.SetEvent(self.audio_ctrl_event) - - if self.use_raw_recording: - self.recording_ctrl = self.meeting_service.GetMeetingRecordingController() - - def on_recording_privilege_changed(can_rec): - logger.info(f"on_recording_privilege_changed called. can_record = {can_rec}") - if can_rec: - self.start_raw_recording() - else: - self.stop_raw_recording() - - self.recording_event = zoom.MeetingRecordingCtrlEventCallbacks(onRecordPrivilegeChangedCallback=on_recording_privilege_changed) - self.recording_ctrl.SetEvent(self.recording_event) - - self.start_raw_recording() - - # Set up media streams - GLib.timeout_add_seconds(1, self.set_up_bot_audio_input) - GLib.timeout_add_seconds(1, self.set_up_bot_video_input) - - def set_up_bot_video_input(self): - self.virtual_camera_video_source = zoom.ZoomSDKVideoSourceCallbacks( - onInitializeCallback=self.on_virtual_camera_initialize_callback, - onStartSendCallback=self.on_virtual_camera_start_send_callback, - ) - self.video_source_helper = zoom.GetRawdataVideoSourceHelper() - if self.video_source_helper: - set_external_video_source_result = self.video_source_helper.setExternalVideoSource(self.virtual_camera_video_source) - logger.info(f"set_external_video_source_result = {set_external_video_source_result}") - if set_external_video_source_result == zoom.SDKERR_SUCCESS: - self.meeting_video_controller = self.meeting_service.GetMeetingVideoController() - unmute_video_result = self.meeting_video_controller.UnmuteVideo() - logger.info(f"unmute_video_result = {unmute_video_result}") - else: - logger.info("video_source_helper is None") - - def on_virtual_camera_start_send_callback(self): - logger.info("on_virtual_camera_start_send_callback called") - # As soon as we get this callback, we need to send a blank frame and it will fail with SDKERR_WRONG_USAGE - # Then the callback will be triggered again and subsequent calls will succeed. - # Not sure why this happens. - if self.video_sender and not self.on_virtual_camera_start_send_callback_called: - blank = create_black_yuv420_frame(640, 360) - initial_send_video_frame_response = self.video_sender.sendVideoFrame(blank, 640, 360, 0, zoom.FrameDataFormat_I420_FULL) - logger.info(f"initial_send_video_frame_response = {initial_send_video_frame_response}") - self.on_virtual_camera_start_send_callback_called = True - - def on_virtual_camera_initialize_callback(self, video_sender, support_cap_list, suggest_cap): - self.video_sender = video_sender - - def send_raw_image(self, yuv420_image_bytes): - if not self.on_virtual_camera_start_send_callback_called: - raise Exception("on_virtual_camera_start_send_callback_called not called so cannot send raw image") - send_video_frame_response = self.video_sender.sendVideoFrame(yuv420_image_bytes, 640, 360, 0, zoom.FrameDataFormat_I420_FULL) - logger.info(f"send_raw_image send_video_frame_response = {send_video_frame_response}") - - def set_up_bot_audio_input(self): - if self.audio_helper is None: - self.audio_helper = zoom.GetAudioRawdataHelper() - - if self.audio_helper is None: - logger.info("set_up_bot_audio_input failed because audio_helper is None") - return - - self.virtual_audio_mic_event_passthrough = zoom.ZoomSDKVirtualAudioMicEventCallbacks( - onMicInitializeCallback=self.on_mic_initialize_callback, - onMicStartSendCallback=self.on_mic_start_send_callback, - ) - - audio_helper_set_external_audio_source_result = self.audio_helper.setExternalAudioSource(self.virtual_audio_mic_event_passthrough) - logger.info(f"audio_helper_set_external_audio_source_result = {audio_helper_set_external_audio_source_result}") - if audio_helper_set_external_audio_source_result != zoom.SDKERR_SUCCESS: - logger.info("Failed to set external audio source") - return - - def on_mic_initialize_callback(self, sender): - self.audio_raw_data_sender = sender - - def send_raw_audio(self, bytes, sample_rate): - if not self.on_mic_start_send_callback_called: - raise Exception("on_mic_start_send_callback_called not called so cannot send raw audio") - send_result = self.audio_raw_data_sender.send(bytes, sample_rate, zoom.ZoomSDKAudioChannel_Mono) - if send_result != zoom.SDKERR_SUCCESS: - logger.info(f"error with send_raw_audio send_result = {send_result}") - - def on_mic_start_send_callback(self): - self.on_mic_start_send_callback_called = True - logger.info("on_mic_start_send_callback called") - - def on_one_way_audio_raw_data_received_callback(self, data, node_id): - if node_id == self.my_participant_id: - return - - current_time = datetime.utcnow() - self.last_audio_received_at = time.time() - self.add_audio_chunk_callback(node_id, current_time, data.GetBuffer()) - - def add_mixed_audio_chunk_convert_to_bytes(self, data): - self.add_mixed_audio_chunk_callback(data.GetBuffer()) - - def start_raw_recording(self): - self.recording_ctrl = self.meeting_service.GetMeetingRecordingController() - - can_start_recording_result = self.recording_ctrl.CanStartRawRecording() - if can_start_recording_result != zoom.SDKERR_SUCCESS: - self.recording_ctrl.RequestLocalRecordingPrivilege() - logger.info("Requesting recording privilege.") - return - - start_raw_recording_result = self.recording_ctrl.StartRawRecording() - if start_raw_recording_result != zoom.SDKERR_SUCCESS: - logger.info("Start raw recording failed.") - return - - if self.audio_helper is None: - self.audio_helper = zoom.GetAudioRawdataHelper() - if self.audio_helper is None: - logger.info("audio_helper is None") - return - - if self.audio_source is None: - self.audio_source = zoom.ZoomSDKAudioRawDataDelegateCallbacks( - collectPerformanceData=True, - onOneWayAudioRawDataReceivedCallback=self.on_one_way_audio_raw_data_received_callback if self.use_one_way_audio else None, - onMixedAudioRawDataReceivedCallback=self.add_mixed_audio_chunk_convert_to_bytes if self.use_mixed_audio else None, - ) - - audio_helper_subscribe_result = self.audio_helper.subscribe(self.audio_source, False) - logger.info(f"audio_helper_subscribe_result = {audio_helper_subscribe_result}") - - self.send_message_callback({"message": self.Messages.BOT_RECORDING_PERMISSION_GRANTED}) - self.recording_permission_granted = True - - GLib.timeout_add(100, self.set_up_video_input_manager) - - def stop_raw_recording(self): - rec_ctrl = self.meeting_service.StopRawRecording() - if rec_ctrl.StopRawRecording() != zoom.SDKERR_SUCCESS: - raise Exception("Error with stop raw recording") - - def leave(self): - if self.meeting_service is None: - return - - status = self.meeting_service.GetMeetingStatus() - if status == zoom.MEETING_STATUS_IDLE or status == zoom.MEETING_STATUS_ENDED: - logger.info(f"Aborting leave because meeting status is {status}") - return - - logger.info("Requesting to leave meeting...") - leave_result = self.meeting_service.Leave(zoom.LEAVE_MEETING) - logger.info(f"Requested to leave meeting. result = {leave_result}") - self.requested_leave = True - - def join_meeting(self): - meeting_number = int(self.meeting_id) - - join_param = zoom.JoinParam() - join_param.userType = zoom.SDKUserType.SDK_UT_WITHOUT_LOGIN - - param = join_param.param - param.meetingNumber = meeting_number - param.userName = self.display_name - param.psw = self.meeting_password if self.meeting_password is not None else "" - param.vanityID = "" - param.customer_key = "" - param.webinarToken = "" - param.onBehalfToken = "" - param.isVideoOff = False - param.isAudioOff = False - - join_result = self.meeting_service.Join(join_param) - logger.info(f"join_result = {join_result}") - - self.audio_settings = self.setting_service.GetAudioSettings() - self.audio_settings.EnableAutoJoinAudio(True) - - def on_reminder_notify(self, content, handler): - if handler: - handler.Accept() - - def auth_return(self, result): - if result == zoom.AUTHRET_SUCCESS: - logger.info("Auth completed successfully.") - return self.join_meeting() - - self.send_message_callback( - { - "message": self.Messages.ZOOM_AUTHORIZATION_FAILED, - "zoom_result_code": result, - } - ) - - def leave_meeting_if_not_started_yet(self): - if self.meeting_status != zoom.MEETING_STATUS_WAITINGFORHOST: - return - - logger.info(f"Give up trying to join meeting because we've waited for the host to start it for over {self.automatic_leave_configuration.wait_for_host_to_start_meeting_timeout_seconds} seconds") - self.send_message_callback({"message": self.Messages.LEAVE_MEETING_WAITING_FOR_HOST}) - - def wait_for_host_to_start_meeting_then_give_up(self): - wait_time = self.automatic_leave_configuration.wait_for_host_to_start_meeting_timeout_seconds - logger.info(f"Waiting for host to start meeting. If host doesn't start meeting in {wait_time} seconds, we'll give up") - GLib.timeout_add_seconds(wait_time, self.leave_meeting_if_not_started_yet) - - def meeting_status_changed(self, status, iResult): - logger.info(f"meeting_status_changed called. status = {status}, iResult={iResult}") - self.meeting_status = status - - if status == zoom.MEETING_STATUS_WAITINGFORHOST: - self.wait_for_host_to_start_meeting_then_give_up() - - if status == zoom.MEETING_STATUS_IN_WAITING_ROOM: - self.send_message_callback({"message": self.Messages.BOT_PUT_IN_WAITING_ROOM}) - - if status == zoom.MEETING_STATUS_INMEETING: - self.send_message_callback({"message": self.Messages.BOT_JOINED_MEETING}) - - if status == zoom.MEETING_STATUS_ENDED: - # We get the MEETING_STATUS_ENDED regardless of whether we initiated the leave or not - self.send_message_callback({"message": self.Messages.MEETING_ENDED}) - - if status == zoom.MEETING_STATUS_FAILED: - # Since the unable to join external meeting issue is so common, we'll handle it separately - if iResult == zoom.MeetingFailCode.MEETING_FAIL_UNABLE_TO_JOIN_EXTERNAL_MEETING: - self.send_message_callback( - { - "message": self.Messages.ZOOM_MEETING_STATUS_FAILED_UNABLE_TO_JOIN_EXTERNAL_MEETING, - "zoom_result_code": iResult, - } - ) - else: - self.send_message_callback( - { - "message": self.Messages.ZOOM_MEETING_STATUS_FAILED, - "zoom_result_code": iResult, - } - ) - - if status == zoom.MEETING_STATUS_INMEETING: - return self.on_join() - - def create_services(self): - self.meeting_service = zoom.CreateMeetingService() - - self.setting_service = zoom.CreateSettingService() - - self.meeting_service_event = zoom.MeetingServiceEventCallbacks(onMeetingStatusChangedCallback=self.meeting_status_changed) - - meeting_service_set_revent_result = self.meeting_service.SetEvent(self.meeting_service_event) - if meeting_service_set_revent_result != zoom.SDKERR_SUCCESS: - raise Exception("Meeting Service set event failed") - - self.auth_event = zoom.AuthServiceEventCallbacks(onAuthenticationReturnCallback=self.auth_return) - - self.auth_service = zoom.CreateAuthService() - - set_event_result = self.auth_service.SetEvent(self.auth_event) - logger.info(f"set_event_result = {set_event_result}") - - # Use the auth service - auth_context = zoom.AuthContext() - auth_context.jwt_token = self._jwt_token - - result = self.auth_service.SDKAuth(auth_context) - - if result == zoom.SDKError.SDKERR_SUCCESS: - logger.info("Authentication successful") - else: - logger.info(f"Authentication failed with error: {result}") - self.send_message_callback( - { - "message": self.Messages.ZOOM_SDK_INTERNAL_ERROR, - "zoom_result_code": result, - } - ) - - def get_first_buffer_timestamp_ms_offset(self): - return 0 - - def check_auto_leave_conditions(self): - if self.requested_leave: - return - if self.cleaned_up: - return - - if self.only_one_participant_in_meeting_at is not None: - if time.time() - self.only_one_participant_in_meeting_at > self.automatic_leave_configuration.only_participant_in_meeting_threshold_seconds: - logger.info(f"Auto-leaving meeting because there was only one participant in the meeting for {self.automatic_leave_configuration.only_participant_in_meeting_threshold_seconds} seconds") - self.send_message_callback({"message": self.Messages.ADAPTER_REQUESTED_BOT_LEAVE_MEETING, "leave_reason": BotAdapter.LEAVE_REASON.AUTO_LEAVE_ONLY_PARTICIPANT_IN_MEETING}) - return - - if self.last_audio_received_at is not None: - if time.time() - self.last_audio_received_at > self.automatic_leave_configuration.silence_threshold_seconds: - logger.info(f"Auto-leaving meeting because there was no audio message for {self.automatic_leave_configuration.silence_threshold_seconds} seconds") - self.send_message_callback({"message": self.Messages.ADAPTER_REQUESTED_BOT_LEAVE_MEETING, "leave_reason": BotAdapter.LEAVE_REASON.AUTO_LEAVE_SILENCE}) - return diff --git a/attendee/dev.docker-compose.yaml b/attendee/dev.docker-compose.yaml deleted file mode 100644 index ef88b65..0000000 --- a/attendee/dev.docker-compose.yaml +++ /dev/null @@ -1,58 +0,0 @@ -services: - attendee-worker-local: - build: ./ - volumes: - - .:/attendee - networks: - - attendee_network - environment: - - POSTGRES_HOST=postgres - - REDIS_URL=redis://redis:6379/5 - - DJANGO_SETTINGS_MODULE=attendee.settings.development - command: celery -A attendee worker -l INFO - - attendee-app-local: - build: ./ - volumes: - - .:/attendee - networks: - - attendee_network - ports: - - "8000:8000" - environment: - - POSTGRES_HOST=postgres - - REDIS_URL=redis://redis:6379/5 - - DJANGO_SETTINGS_MODULE=attendee.settings.development - command: python manage.py runserver 0.0.0.0:8000 - - postgres: - image: postgres:15.3-alpine - environment: - POSTGRES_DB: attendee_development - POSTGRES_USER: attendee_development_user - POSTGRES_PASSWORD: attendee_development_user - PGDATA: /data/postgres - volumes: - - postgres:/data/postgres - networks: - - attendee_network - restart: unless-stopped - - - redis: - image: redis:7-alpine - networks: - - attendee_network - restart: unless-stopped - volumes: - - redis:/data/redis - - - -networks: - attendee_network: - driver: bridge - -volumes: - postgres: - redis: diff --git a/attendee/docs/openapi.yml b/attendee/docs/openapi.yml deleted file mode 100644 index d2c4fab..0000000 --- a/attendee/docs/openapi.yml +++ /dev/null @@ -1,641 +0,0 @@ -openapi: 3.0.3 -info: - title: Attendee API - version: 1.0.0 - description: Meetings bots made easy -paths: - /api/v1/bots: - post: - operationId: Create Bot - description: After being created, the bot will attempt to join the specified - meeting. - summary: Create a new bot - parameters: - - in: header - name: Authorization - schema: - type: string - default: Token YOUR_API_KEY_HERE - description: API key for authentication - required: true - - in: header - name: Content-Type - schema: - type: string - default: application/json - description: Should always be application/json - required: true - tags: - - Bots - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/CreateBotRequest' - examples: - ValidMeetingURL: - value: - meeting_url: https://zoom.us/j/123?pwd=456 - bot_name: My Bot - summary: Valid meeting URL - description: Example of a valid Zoom meeting URL - required: true - security: - - {} - responses: - '201': - content: - application/json: - schema: - $ref: '#/components/schemas/Bot' - examples: - NewBot: - value: - id: bot_weIAju4OXNZkDTpZ - meeting_url: https://zoom.us/j/123?pwd=456 - state: joining - events: - - type: join_requested - created_at: '2024-01-18T12:34:56Z' - transcription_state: not_started - recording_state: not_started - summary: New bot - description: Example response when creating a new bot - description: Bot created successfully - '400': - description: Invalid input - /api/v1/bots/{object_id}: - get: - operationId: Get Bot - summary: Get the details for a bot - parameters: - - in: header - name: Authorization - schema: - type: string - default: Token YOUR_API_KEY_HERE - description: API key for authentication - required: true - - in: header - name: Content-Type - schema: - type: string - default: application/json - description: Should always be application/json - required: true - - in: path - name: object_id - schema: - type: string - description: Bot ID - required: true - examples: - BotIDExample: - value: bot_xxxxxxxxxxx - summary: Bot ID Example - tags: - - Bots - security: - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Bot' - examples: - NewBot: - value: - id: bot_weIAju4OXNZkDTpZ - meeting_url: https://zoom.us/j/123?pwd=456 - state: joining - events: - - type: join_requested - created_at: '2024-01-18T12:34:56Z' - transcription_state: not_started - recording_state: not_started - summary: New bot - description: Example response when creating a new bot - description: Bot details - '404': - description: Bot not found - /api/v1/bots/{object_id}/leave: - post: - operationId: Leave Meeting - description: Causes the bot to leave the meeting. - summary: Leave a meeting - parameters: - - in: header - name: Authorization - schema: - type: string - default: Token YOUR_API_KEY_HERE - description: API key for authentication - required: true - - in: header - name: Content-Type - schema: - type: string - default: application/json - description: Should always be application/json - required: true - - in: path - name: object_id - schema: - type: string - description: Bot ID - required: true - examples: - BotIDExample: - value: bot_xxxxxxxxxxx - summary: Bot ID Example - tags: - - Bots - security: - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Bot' - examples: - LeavingBot: - value: - id: bot_weIAju4OXNZkDTpZ - meeting_url: https://zoom.us/j/123?pwd=456 - state: leaving - events: - - type: join_requested - created_at: '2024-01-18T12:34:56Z' - - type: joined_meeting - created_at: '2024-01-18T12:35:00Z' - - type: leave_requested - created_at: '2024-01-18T13:34:56Z' - transcription_state: in_progress - recording_state: in_progress - summary: Leaving Bot - description: Example response when requesting a bot to leave - description: Successfully requested to leave meeting - '400': - description: Bot is not in a valid state to leave the meeting - '404': - description: Bot not found - /api/v1/bots/{object_id}/speech: - post: - operationId: Output speech - description: Causes the bot to speak a message in the meeting. - summary: Output speech - parameters: - - in: header - name: Authorization - schema: - type: string - default: Token YOUR_API_KEY_HERE - description: API key for authentication - required: true - - in: header - name: Content-Type - schema: - type: string - default: application/json - description: Should always be application/json - required: true - - in: path - name: object_id - schema: - type: string - description: Bot ID - required: true - examples: - BotIDExample: - value: bot_xxxxxxxxxxx - summary: Bot ID Example - tags: - - Bots - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/SpeechRequest' - examples: - ValidSpeechRequest: - value: - text: Hello, this is a bot speaking text. - text_to_speech_settings: - google: - voice_language_code: en-US - voice_name: en-US-Casual-K - summary: Valid speech request - description: Example of a valid speech request - required: true - security: - - {} - responses: - '200': - description: Speech request created successfully - '400': - description: Invalid input - '404': - description: Bot not found - /api/v1/bots/{object_id}/output_audio: - post: - operationId: Output Audio - description: Causes the bot to output audio in the meeting. - summary: Output audio - parameters: - - in: header - name: Authorization - schema: - type: string - default: Token YOUR_API_KEY_HERE - description: API key for authentication - required: true - - in: header - name: Content-Type - schema: - type: string - default: application/json - description: Should always be application/json - required: true - - in: path - name: object_id - schema: - type: string - description: Bot ID - required: true - examples: - BotIDExample: - value: bot_xxxxxxxxxxx - summary: Bot ID Example - tags: - - Bots - requestBody: - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - audio/mp3 - data: - type: string - format: binary - description: Base64 encoded audio data - required: - - type - - data - security: - - {} - responses: - '200': - description: Audio request created successfully - '400': - description: Invalid input - '404': - description: Bot not found - /api/v1/bots/{object_id}/output_image: - post: - operationId: Output Image - description: Causes the bot to output an image in the meeting. - summary: Output image - parameters: - - in: header - name: Authorization - schema: - type: string - default: Token YOUR_API_KEY_HERE - description: API key for authentication - required: true - - in: header - name: Content-Type - schema: - type: string - default: application/json - description: Should always be application/json - required: true - - in: path - name: object_id - schema: - type: string - description: Bot ID - required: true - examples: - BotIDExample: - value: bot_xxxxxxxxxxx - summary: Bot ID Example - tags: - - Bots - requestBody: - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - image/png - data: - type: string - format: binary - description: Base64 encoded image data - required: - - type - - data - security: - - {} - responses: - '200': - description: Image request created successfully - '400': - description: Invalid input - '404': - description: Bot not found - /api/v1/bots/{object_id}/recording: - get: - operationId: Get Bot Recording - description: Returns a short-lived S3 URL for the recording of the bot. - summary: Get the recording for a bot - parameters: - - in: header - name: Authorization - schema: - type: string - default: Token YOUR_API_KEY_HERE - description: API key for authentication - required: true - - in: header - name: Content-Type - schema: - type: string - default: application/json - description: Should always be application/json - required: true - - in: path - name: object_id - schema: - type: string - description: Bot ID - required: true - examples: - BotIDExample: - value: bot_xxxxxxxxxxx - summary: Bot ID Example - tags: - - Bots - security: - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/Recording' - examples: - RecordingUpload: - value: - url: https://attendee-short-term-storage-production.s3.amazonaws.com/e4da3b7fbbce2345d7772b0674a318d5.mp4?... - start_timestamp_ms: 1733114771000 - summary: Recording Upload - description: Short-lived S3 URL for the recording - /api/v1/bots/{object_id}/transcript: - get: - operationId: Get Bot Transcript - description: If the meeting is still in progress, this returns the transcript - so far. - summary: Get the transcript for a bot - parameters: - - in: header - name: Authorization - schema: - type: string - default: Token YOUR_API_KEY_HERE - description: API key for authentication - required: true - - in: header - name: Content-Type - schema: - type: string - default: application/json - description: Should always be application/json - required: true - - in: path - name: object_id - schema: - type: string - description: Bot ID - required: true - examples: - BotIDExample: - value: bot_xxxxxxxxxxx - summary: Bot ID Example - tags: - - Bots - security: - - {} - responses: - '200': - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/TranscriptUtterance' - description: List of transcribed utterances - '404': - description: Bot not found -components: - schemas: - Bot: - type: object - properties: - id: - type: string - meeting_url: - type: string - readOnly: true - state: - allOf: - - $ref: '#/components/schemas/StateEnum' - readOnly: true - events: - type: array - items: - type: object - properties: - type: - type: string - sub_type: - type: string - nullable: true - created_at: - type: string - format: date-time - readOnly: true - transcription_state: - allOf: - - $ref: '#/components/schemas/TranscriptionStateEnum' - readOnly: true - recording_state: - allOf: - - $ref: '#/components/schemas/RecordingStateEnum' - readOnly: true - required: - - events - - id - - meeting_url - - recording_state - - state - - transcription_state - CreateBotRequest: - type: object - properties: - meeting_url: - type: string - minLength: 1 - description: The URL of the meeting to join, e.g. https://zoom.us/j/123?pwd=456 - bot_name: - type: string - minLength: 1 - description: The name of the bot to create, e.g. 'My Bot' - transcription_settings: - type: object - properties: - deepgram: - type: object - properties: - language: - type: string - description: 'The language code for transcription (e.g. ''en''). - See here for available languages: https://developers.deepgram.com/docs/models-languages-overview' - detect_language: - type: boolean - description: Whether to automatically detect the spoken language - required: - - deepgram - default: - deepgram: - language: en - description: 'The transcription settings for the bot, e.g. {''deepgram'': - {''language'': ''en''}}' - rtmp_settings: - type: object - properties: - destination_url: - type: string - description: The URL of the RTMP server to send the stream to - stream_key: - type: string - description: The stream key to use for the RTMP server - required: - - destination_url - - stream_key - description: 'RTMP server to stream to, e.g. {''destination_url'': ''rtmp://global-live.mux.com:5222/app'', - ''stream_key'': ''xxxx''}.' - recording_settings: - type: object - properties: - format: - type: string - description: The format of the recording to save. The supported formats - are 'webm' and 'mp4'. - required: - - format - default: - format: webm - description: 'The settings for the bot''s recording. Either {''format'': - ''webm''} or {''format'': ''mp4''}.' - required: - - bot_name - - meeting_url - Recording: - type: object - properties: - url: - type: string - readOnly: true - start_timestamp_ms: - type: integer - required: - - start_timestamp_ms - - url - RecordingStateEnum: - type: string - enum: - - not_started - - in_progress - - complete - - failed - SpeechRequest: - type: object - properties: - text: - type: string - minLength: 1 - text_to_speech_settings: - type: object - properties: - google: - type: object - properties: - voice_language_code: - type: string - description: The voice language code (e.g. 'en-US'). See https://cloud.google.com/text-to-speech/docs/voices - for a list of available language codes and voices. - voice_name: - type: string - description: The name of the voice to use (e.g. 'en-US-Casual-K') - required: - - google - required: - - text - - text_to_speech_settings - StateEnum: - type: string - enum: - - ready - - joining - - joined_not_recording - - joined_recording - - leaving - - ended - - fatal_error - - waiting_room - TranscriptUtterance: - type: object - properties: - speaker_name: - type: string - speaker_uuid: - type: string - speaker_user_uuid: - type: string - nullable: true - timestamp_ms: - type: integer - duration_ms: - type: integer - transcription: {} - required: - - duration_ms - - speaker_name - - speaker_user_uuid - - speaker_uuid - - timestamp_ms - - transcription - TranscriptionStateEnum: - type: string - enum: - - not_started - - in_progress - - complete - - failed -servers: -- url: https://app.attendee.dev - description: Production server -tags: -- name: Bots - description: Bot management endpoints diff --git a/attendee/heroku.yml b/attendee/heroku.yml deleted file mode 100644 index b96bc2c..0000000 --- a/attendee/heroku.yml +++ /dev/null @@ -1,11 +0,0 @@ -setup: - addons: - - plan: heroku-postgresql - as: DATABASE -build: - docker: - web: Dockerfile - worker: Dockerfile -run: - web: gunicorn attendee.wsgi - worker: celery -A attendee worker -l info \ No newline at end of file diff --git a/attendee/init_env.py b/attendee/init_env.py deleted file mode 100644 index 82e1f5b..0000000 --- a/attendee/init_env.py +++ /dev/null @@ -1,25 +0,0 @@ -from cryptography.fernet import Fernet -from django.core.management.utils import get_random_secret_key - - -def generate_encryption_key(): - return Fernet.generate_key().decode("utf-8") - - -def generate_django_secret_key(): - return get_random_secret_key() - - -def main(): - credentials_key = generate_encryption_key() - django_key = generate_django_secret_key() - - print(f"CREDENTIALS_ENCRYPTION_KEY={credentials_key}") - print(f"DJANGO_SECRET_KEY={django_key}") - print("AWS_RECORDING_STORAGE_BUCKET_NAME=") - print("AWS_ACCESS_KEY_ID=") - print("AWS_SECRET_ACCESS_KEY=") - - -if __name__ == "__main__": - main() diff --git a/attendee/manage.py b/attendee/manage.py deleted file mode 100755 index 5708812..0000000 --- a/attendee/manage.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" - -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "attendee.settings") - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError("Couldn't import Django. Are you sure it's installed and available on your PYTHONPATH environment variable? Did you forget to activate a virtual environment?") from exc - execute_from_command_line(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/attendee/pyproject.toml b/attendee/pyproject.toml deleted file mode 100644 index 3bb4367..0000000 --- a/attendee/pyproject.toml +++ /dev/null @@ -1,55 +0,0 @@ -[tool.ruff] -# Enable pycodestyle (`E`), Pyflakes (`F`), and import sorting (`I`) -select = ["E", "F", "I"] -ignore = ["E501", "E722", "E402", "F403"] - -# Allow autofix for all enabled rules (when `--fix`) is provided. -fixable = ["A", "B", "C", "D", "E", "F", "I"] -unfixable = [] - -# Exclude a variety of commonly ignored directories. -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".mypy_cache", - ".nox", - ".pants.d", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "venv", - "migrations", -] - -# Same as Black. -line-length = 999 # Setting a very large line length to prevent wrapping - -# Allow unused variables when underscore-prefixed. -dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" - -# Use Python 3.10 -target-version = "py310" - -[tool.ruff.mccabe] -# Unlike Flake8, default to a complexity level of 10. -max-complexity = 10 - -[tool.ruff.isort] -known-first-party = ["attendee"] - -[tool.ruff.format] -line-ending = "auto" -quote-style = "double" -indent-style = "space" \ No newline at end of file diff --git a/attendee/requirements.txt b/attendee/requirements.txt deleted file mode 100644 index e61088e..0000000 --- a/attendee/requirements.txt +++ /dev/null @@ -1,89 +0,0 @@ -amqp==5.2.0 -asgiref==3.8.1 -attrs==24.2.0 -billiard==4.2.1 -boto3==1.35.64 -botocore==1.35.64 -celery==5.4.0 -certifi==2024.8.30 -cffi==1.17.1 -charset-normalizer==3.4.0 -click==8.1.7 -click-didyoumean==0.3.1 -click-plugins==1.1.1 -click-repl==0.3.0 -cryptography==43.0.3 -dj-database-url==2.3.0 -Django==5.1.2 -django-allauth==65.1.0 -django-concurrency==2.6.0 -django-extensions==3.2.3 -django-storages==1.14.4 -djangorestframework==3.15.2 -drf-spectacular==0.27.2 -gunicorn==23.0.0 -h11==0.14.0 -idna==3.10 -inflection==0.5.1 -jmespath==1.0.1 -jsonschema==4.23.0 -jsonschema-specifications==2024.10.1 -kombu==5.4.2 -numpy==2.1.3 -oauthlib==3.2.2 -opencv-python==4.10.0.84 -outcome==1.3.0.post0 -packaging==24.2 -prompt_toolkit==3.0.48 -psycopg2==2.9.10 -pycparser==2.22 -pydub==0.25.1 -PyJWT==2.9.0 -PySocks==1.7.1 -python-dateutil==2.9.0.post0 -python-dotenv==1.0.1 -PyVirtualDisplay==3.0 -PyYAML==6.0.2 -redis==5.2.0 -referencing==0.35.1 -requests==2.32.3 -requests-oauthlib==2.0.0 -rpds-py==0.21.0 -s3transfer==0.10.3 -selenium==4.27.1 -setuptools==75.7.0 -six==1.16.0 -sniffio==1.3.1 -sortedcontainers==2.4.0 -sqlparse==0.5.1 -trio==0.28.0 -trio-websocket==0.11.1 -typing_extensions==4.12.2 -tzdata==2024.2 -undetected-chromedriver==3.5.5 -uritemplate==4.1.1 -urllib3==2.2.3 -vine==5.1.0 -watchdog==6.0.0 -wcwidth==0.2.13 -webrtcvad==2.0.10 -websocket-client==1.8.0 -websockets==14.1 -whitenoise==6.8.2 -wsproto==1.2.0 -zoom-meeting-sdk==0.0.17 -cachetools==5.5.1 -google-api-core==2.24.1 -google-auth==2.38.0 -google-cloud-texttospeech==2.24.0 -googleapis-common-protos==1.66.0 -grpcio==1.70.0 -grpcio-status==1.70.0 -proto-plus==1.26.0 -protobuf==5.29.3 -pyasn1==0.6.1 -pyasn1_modules==0.4.1 -rsa==4.9 -ruff==0.9.6 -durationpy==0.9 -kubernetes==32.0.0 \ No newline at end of file diff --git a/attendee/scalar.config.json b/attendee/scalar.config.json deleted file mode 100644 index a122c32..0000000 --- a/attendee/scalar.config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "subdomain": "attendee", - "guides": [], - "references": [ - { - "name": "API Reference", - "path": "docs/openapi.yaml" - } - ], - "publishOnMerge": true -} diff --git a/attendee/static/images/favicon_white.png b/attendee/static/images/favicon_white.png deleted file mode 100644 index ebed4c5ed95267cbc6b6e4ea8aee84a38ba99c44..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 589 zcmV-T0ZC$SMc%*^X(Q89U>*P+Qk43lNTEoid@1fucC7QUJ9j=K{?3x;pHH{DGqWo_cL4(+ z1*XiE0~bKDX}AQ)Xjb}^;Wh_ZE@6yDeZnP?Z~z}Yxn4LKhmM$IPa-eiz!8&p5_thV zy7vTm0k0e}x1K~UKr_2nJmjS(ms`Lv;sj57g4=Jcp_?$b|p@Y#oOlj8T{z)W#=**4y3sR`s??=KW z7O!$1DLsv?t_;4B?+((W2T8vrZArQ_vwfEf{(2XqpTLO+F6{xX(l21Ij-z&oDe6@0 zseRD=V)RDR%gFd^Nrz_kr}1C-T3H6XUGUfj&K+O!@zMiK9LC8Cuc^c9s0l807>*nK z2E?z%ci>f>*?|LY60jm8hwqnVlO25fu6Ai_Ajb~KvqghXk->MNa4jHnV6Pe$zA5Z+ z=dX$;zh6Fo1k23El3oYqk))ZVdC38GDf;e&;x(XB27CqH(a{S5#lHN!lqWBLzo*aF b*tYryO7W?Tuc5=K00000NkvXXu0mjfo-_)U diff --git a/attendee/static/images/logo.svg b/attendee/static/images/logo.svg deleted file mode 100644 index 77b1f06..0000000 --- a/attendee/static/images/logo.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/attendee/static/images/logo_with_text.svg b/attendee/static/images/logo_with_text.svg deleted file mode 100644 index 9b7d7c4..0000000 --- a/attendee/static/images/logo_with_text.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/attendee/templates/base.html b/attendee/templates/base.html deleted file mode 100644 index 762e073..0000000 --- a/attendee/templates/base.html +++ /dev/null @@ -1,63 +0,0 @@ -{% load static %} - - - - - Attendee - - - - - - - - - - - {% if messages and 'accounts' in request.path %} -
- {% for message in messages %} - {% if forloop.last %} - - {% endif %} - {% endfor %} -
- {% endif %} - {% block body_content %} - {% endblock %} - - - - \ No newline at end of file diff --git a/cli/app.py b/cli/app.py deleted file mode 100644 index 615b500..0000000 --- a/cli/app.py +++ /dev/null @@ -1,112 +0,0 @@ -from dotenv import load_dotenv -import whisper -import ollama -import logging -from logger import logger -import openai -import whisper_timestamped as whisper_ts -import json -import datetime - -# Load environment variables -load_dotenv() - -# Import configurations and functions from modules -from config import openai_api_key, model_id, model_path -from load_model import load_model -#from extract_entities import extract_entities - -openai.api_key = openai_api_key -#Load whisher model -model = load_model(model_id, model_path, True) - - -#transcripe the audio to its original language -def process_all_steps(audio): - #transcription =transcribe(audio) - translation = translate_with_whisper(audio) - #translation = translate_with_ollama(transcription) - #summary = summarize_using_llama(translation) - summary = summarize_using_openai(translation) - #return [transcription, translation, summary] - return [translation, summary] - -def transcribe(audio): - logger.info("Started transciption") - result = model.transcribe(audio,fp16=False) - transcription = result["text"] - return transcription - -def transcribe_with_whisper_ts(audio_file): - audio = whisper_ts.load_audio(audio_file) - logger.info("Started transciption through whishper") - #as suggested in the document - options = dict(beam_size=5, best_of=5, temperature=(0.0, 0.2, 0.4, 0.6, 0.8, 1.0)) - translate_options = dict(task="translate", **options) - print(datetime.datetime.now()) - result = whisper_ts.transcribe_timestamped(model,audio,condition_on_previous_text=False,vad=True,trust_whisper_timestamps=False,**translate_options) - print(datetime.datetime.now()) - #result = whisper_ts.transcribe(model, audio) - return result - - -#translate the audio file to English language using whisper model -def translate_with_whisper(audio): - logger.info("Started transciption through whishper") - options = dict(beam_size=5, best_of=5) - translate_options = dict(task="translate", **options) - result = model.transcribe(audio,**translate_options) - return result["text"] - -#translate the text from transciption to English language -def translate_with_ollama(text): - logger.info("Started transciption through llama") - response = ollama.generate(model= "llama3.2", prompt = "Translate the following text to English:"+text+"\n SUMMARY:\n") - translation = response["response"] - return translation - -#Using Ollama and llama3.1 modle, summarize the English translation -def summarize_using_llama(text): - response = ollama.generate(model= "llama3.2", prompt = "Provide highlights of conversion inbullet points without pretext:"+text+"\n \n") - summary = response["response"] - return summary - - -#Using openaie, summarize the English translation -def summarize_using_openai(text): - logger.info("Started summarization") - prompt = "Summarize the following text: " +text - try: - response = openai.chat.completions.create( - model="gpt-4o", - messages=[ - {"role": "system", "content": "You are a helpful assistant that extracts information from Indian multilingual text."}, - {"role": "user", "content": prompt} - ], - max_tokens=500 - ) - summary = response.choices[0].message.content - except Exception as e: - logger.error(e) - summary = "Unable to exract summary" - return summary - -text="It's like a dialogue in a movie. They don't believe if you say you are going to win. They believe only if you say you have won. It's very difficult to get a name in India. If you win in sports, everyone will be able to say the name you have won. How is this situation for you? We have been training for 4 years. In 4 years, I have been to many national meet like this. But at that time, I have only won bronze, silver and gold. In this meet, I have won my first gold. For this, We worked very hard for a year and achieved this success. Superb! How did your journey start? Tell us about your family. I don't have a father in my family. I have only my mother. My mother is a farmer. I have two sisters. When I was in 8th or 9th grade, I ran a school sports relay. At that time, my school PD sir took me to the district division. I won medals in that. But I didn't win medals even at the state level. At that time, I was not doing any training. I went to Koko training after coming to college. I was in Koko training for 3 years. After that, I came to Athletics school. My coach's name is Manikandan Arumugam. I trained with her for 4 years and now I am fully involved in Athletics. Superb! Superb! They say one important thing. No matter what sport you play, if you get angry, you can't win. You were talking about your coach, Manikandan Arumugam, correct? You tell about him. He is also an Athlete Sir. He is working in Southern Railway. He has been medalist for 10 years in National level. He has kept his rank for 10 years." - -#Marathi audio -#trasnslation = transcribe_with_whisper_ts("https://utfs.io/f/9ed82ee5-4dd9-4eeb-8f77-9a1dfbf35bc2-gfje9d.mp3") -#Tamil audio -#trasnslation = transcribe_with_whisper_ts("https://utfs.io/f/3c714bc6-f728-48b6-813c-a77a8d281a7e-gfje9d.mp3") -#trasnslation = transcribe_with_whisper_ts("https://utfs.io/f/d3c3c169-02b7-4b70-a3e2-8f62514f5433-gfje9d.mp3") -#out = summarize_using_llama(trasnslation["text"]) -out = summarize_using_llama(text) -'''segs = [] -seg = {} -segments = trasnslation["segments"] -for segment in segments: - seg = {"start":segment["start"],"end":segment["end"],"text":segment["text"]} - - segs.append(seg) -result = {"text":trasnslation["text"], "segments": segs, "summary":out} -print(result)''' -print(out) diff --git a/cli/config.py b/cli/config.py deleted file mode 100644 index 96b92a9..0000000 --- a/cli/config.py +++ /dev/null @@ -1,9 +0,0 @@ -import os -from dotenv import load_dotenv - -load_dotenv() - -openai_api_key = os.getenv("OPENAI_KEY") -#model_id = os.getenv('MODEL_ID', 'large-v3') -model_id = os.getenv('MODEL_ID') -model_path = os.getenv('MODEL_PATH') diff --git a/cli/download_model.py b/cli/download_model.py deleted file mode 100644 index 2398a50..0000000 --- a/cli/download_model.py +++ /dev/null @@ -1,25 +0,0 @@ -import sys - -# # Check if two command-line arguments are provided -if len(sys.argv) !=3: - print("Usage: python download_model.py ") - print("Example: python download_model.py large-v3 /workspace/whisper-model/") - sys.exit(1) - -# Check if the model path ends with '/' -model_path = sys.argv[2] -if not model_path.endswith('/'): - model_path += '/' - -### Download the model in a local directory - Specify the version you want to use in the first parameter -import whisper -model_id = sys.argv[1] -model_path = f'{model_path}{model_id}' -# Available models = ['tiny.en', 'tiny', 'base.en', 'base', 'small.en', 'small', 'medium.en', 'medium', 'large-v1', 'large-v2', 'large-v3', 'large'] - -# The whisper module’s load_model() method loads a whisper model in your Python application. You must pass the model name as a parameter to the load_model() method. -try: - model = whisper.load_model(model_id, download_root=model_path) - print("Model has successfully been downloaded") -except Exception as e: - print(f"Error downloading the model: {e}") diff --git a/cli/download_whisper.py b/cli/download_whisper.py deleted file mode 100644 index 82e1c65..0000000 --- a/cli/download_whisper.py +++ /dev/null @@ -1,9 +0,0 @@ -import whisper -import os - -model_path = "whisper_model" -model_id = 'large-v3' -os.makedirs(model_path, exist_ok=True) - -# Download model -model = whisper.load_model(model_id, download_root=model_path) diff --git a/cli/extract_entities.py b/cli/extract_entities.py deleted file mode 100644 index 43dc6d6..0000000 --- a/cli/extract_entities.py +++ /dev/null @@ -1,84 +0,0 @@ -#import openai -from config import openai_api_key - -#openai.api_key = openai_api_key - -def extract_entities(text): - prompt = f""" - The following entities are present in Indian Languages. - Please extract the following entities from the text: - Name, pin code, phone number, gender, occupation, and address. - - Provide the summary of the text in exact below format: - Name is ......., pin code is ........, phone number is ........, gender is ........, occupation is ........, Address is ............ . - - Text: "{text}" - - Summary: - - - Detailed view: - - Original language: {text} - - Text: "{text}" - - Summary: - - Detailed view: - - Original language: {text} - - """ - - try: - response = openai.chat.completions.create( - model="gpt-4o", - messages=[ - {"role": "system", "content": "You are a helpful assistant that extracts information from Indian multilingual text."}, - {"role": "user", "content": prompt} - ], - max_tokens=500 - ) - response_text = response.choices[0].message.content - except Exception as e: - return f"Error during OpenAI API call: {e}", "Detailed view not available." - - # Process the response to extract summary and detailed transcription - if "Detailed view:" in response_text: - parts = response_text.split("Detailed view:") - summary_part = parts[0].strip() - detailed_transcription_part = parts[1].strip() - else: - summary_part = response_text.strip() - detailed_transcription_part = "Detailed view not provided." - - # Format the summary and detailed transcription - formatted_summary = format_summary(summary_part) - formatted_detailed_transcription = format_detailed_transcription(detailed_transcription_part) - - return formatted_summary, formatted_detailed_transcription - -def format_summary(summary): - # Process the summary to remove unnecessary parts - lines = summary.split('\n') - summary_lines = [] - is_summary_section = False - - for line in lines: - line = line.strip() - if line.startswith("Summary:"): - is_summary_section = True - continue - if is_summary_section: - summary_lines.append(line) - - formatted_summary = ' '.join(summary_lines) - return formatted_summary - -def format_detailed_transcription(detailed_transcription): - # Process the detailed transcription to ensure proper formatting - lines = detailed_transcription.split('\n') - detailed_lines = [line.strip() for line in lines if line.strip()] - formatted_detailed_transcription = '\n'.join(detailed_lines) - return formatted_detailed_transcription diff --git a/cli/load_model.py b/cli/load_model.py deleted file mode 100644 index 435b746..0000000 --- a/cli/load_model.py +++ /dev/null @@ -1,18 +0,0 @@ -import torch -import whisper -import whisper_timestamped - -#load the whisper model from net if it isn't stored locally -def load_model(model_id, model_path, is_ts): - #check GPU is avaialbe - device = "cuda" if torch.cuda.is_available() else "cpu" - #device = "cpu" - if (is_ts): - model = whisper_timestamped.load_model(model_id, device=device, download_root=model_path) - else: - model = whisper.load_model(model_id, device=device, download_root=model_path) - print( - f"Model will be run on {device}\n" - f"Model is {'multilingual' if model.is_multilingual else 'English-only'} " - ) - return model diff --git a/cli/logger.py b/cli/logger.py deleted file mode 100644 index b323b73..0000000 --- a/cli/logger.py +++ /dev/null @@ -1,16 +0,0 @@ -from os import path -import logging -import logging.config - -#log_file_path = path.join(path.dirname(path.abspath(__file__)), 'log.config') -#logging.config.fileConfig(log_file_path) - -# create logger -logger = logging.getLogger('simpleExample') - -# 'application' code -logger.debug('debug message') -logger.info('info message') -logger.warning('warn message') -logger.error('error message') -logger.critical('critical message') diff --git a/cli/logging.connf b/cli/logging.connf deleted file mode 100644 index 63c8dcf..0000000 --- a/cli/logging.connf +++ /dev/null @@ -1,27 +0,0 @@ -[loggers] -keys=root,simpleExample - -[handlers] -keys=consoleHandler - -[formatters] -keys=simpleFormatter - -[logger_root] -level=DEBUG -handlers=consoleHandler - -[logger_simpleExample] -level=DEBUG -handlers=consoleHandler -qualname=simpleExample -propagate=0 - -[handler_consoleHandler] -class=StreamHandler -level=DEBUG -formatter=simpleFormatter -args=(sys.stdout,) - -[formatter_simpleFormatter] -format=%(asctime)s - %(name)s - %(levelname)s - %(message)s diff --git a/lingo-ui/README.md b/lingo-ui/README.md deleted file mode 100644 index 90eced0..0000000 --- a/lingo-ui/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Welcome to your Lovable project - -## Project info - -**URL**: https://lovable.dev/projects/b1dbf004-c708-454e-a83f-55eb30cb8f39 - -## How can I edit this code? - -There are several ways of editing your application. - -**Use Lovable** - -Simply visit the [Lovable Project](https://lovable.dev/projects/b1dbf004-c708-454e-a83f-55eb30cb8f39) and start prompting. - -Changes made via Lovable will be committed automatically to this repo. - -**Use your preferred IDE** - -If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable. - -The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating) - -Follow these steps: - -```sh -# Step 1: Clone the repository using the project's Git URL. -git clone - -# Step 2: Navigate to the project directory. -cd - -# Step 3: Install the necessary dependencies. -npm i - -# Step 4: Start the development server with auto-reloading and an instant preview. -npm run dev -``` - -**Edit a file directly in GitHub** - -- Navigate to the desired file(s). -- Click the "Edit" button (pencil icon) at the top right of the file view. -- Make your changes and commit the changes. - -**Use GitHub Codespaces** - -- Navigate to the main page of your repository. -- Click on the "Code" button (green button) near the top right. -- Select the "Codespaces" tab. -- Click on "New codespace" to launch a new Codespace environment. -- Edit files directly within the Codespace and commit and push your changes once you're done. - -## What technologies are used for this project? - -This project is built with: - -- Vite -- TypeScript -- React -- shadcn-ui -- Tailwind CSS - -## How can I deploy this project? - -Simply open [Lovable](https://lovable.dev/projects/b1dbf004-c708-454e-a83f-55eb30cb8f39) and click on Share -> Publish. - -## Can I connect a custom domain to my Lovable project? - -Yes, you can! - -To connect a domain, navigate to Project > Settings > Domains and click Connect Domain. - -Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide) diff --git a/lingo-ui/bun.lockb b/lingo-ui/bun.lockb deleted file mode 100644 index 160304d398161f97165233dfe6636caa631bfdfb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 198351 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 diff --git a/lingo-ui/components.json b/lingo-ui/components.json deleted file mode 100644 index f29e3f1..0000000 --- a/lingo-ui/components.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "tailwind.config.ts", - "css": "src/index.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - } -} \ No newline at end of file diff --git a/lingo-ui/eslint.config.js b/lingo-ui/eslint.config.js deleted file mode 100644 index e67846f..0000000 --- a/lingo-ui/eslint.config.js +++ /dev/null @@ -1,29 +0,0 @@ -import js from "@eslint/js"; -import globals from "globals"; -import reactHooks from "eslint-plugin-react-hooks"; -import reactRefresh from "eslint-plugin-react-refresh"; -import tseslint from "typescript-eslint"; - -export default tseslint.config( - { ignores: ["dist"] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ["**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - "react-hooks": reactHooks, - "react-refresh": reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], - "@typescript-eslint/no-unused-vars": "off", - }, - } -); diff --git a/lingo-ui/index.html b/lingo-ui/index.html deleted file mode 100644 index c00557c..0000000 --- a/lingo-ui/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - lingo-bridge-global-talk - - - - - - - - - - - - - - -

- - - diff --git a/lingo-ui/package-lock.json b/lingo-ui/package-lock.json deleted file mode 100644 index fcb663d..0000000 --- a/lingo-ui/package-lock.json +++ /dev/null @@ -1,7108 +0,0 @@ -{ - "name": "vite_react_shadcn_ts", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "vite_react_shadcn_ts", - "version": "0.0.0", - "dependencies": { - "@hookform/resolvers": "^3.9.0", - "@radix-ui/react-accordion": "^1.2.0", - "@radix-ui/react-alert-dialog": "^1.1.1", - "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-avatar": "^1.1.0", - "@radix-ui/react-checkbox": "^1.1.1", - "@radix-ui/react-collapsible": "^1.1.0", - "@radix-ui/react-context-menu": "^2.2.1", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.1", - "@radix-ui/react-hover-card": "^1.1.1", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-menubar": "^1.1.1", - "@radix-ui/react-navigation-menu": "^1.2.0", - "@radix-ui/react-popover": "^1.1.1", - "@radix-ui/react-progress": "^1.1.0", - "@radix-ui/react-radio-group": "^1.2.0", - "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-select": "^2.1.1", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slider": "^1.2.0", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-switch": "^1.1.0", - "@radix-ui/react-tabs": "^1.1.0", - "@radix-ui/react-toast": "^1.2.1", - "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-toggle-group": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.4", - "@tanstack/react-query": "^5.56.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.0.0", - "date-fns": "^3.6.0", - "embla-carousel-react": "^8.3.0", - "input-otp": "^1.2.4", - "lucide-react": "^0.462.0", - "next-themes": "^0.3.0", - "react": "^18.3.1", - "react-day-picker": "^8.10.1", - "react-dom": "^18.3.1", - "react-hook-form": "^7.53.0", - "react-resizable-panels": "^2.1.3", - "react-router-dom": "^6.26.2", - "recharts": "^2.12.7", - "sonner": "^1.5.0", - "tailwind-merge": "^2.5.2", - "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.3", - "zod": "^3.23.8" - }, - "devDependencies": { - "@eslint/js": "^9.9.0", - "@tailwindcss/typography": "^0.5.15", - "@types/node": "^22.5.5", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react-swc": "^3.5.0", - "autoprefixer": "^10.4.20", - "eslint": "^9.9.0", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.9", - "globals": "^15.9.0", - "lovable-tagger": "^1.1.7", - "postcss": "^8.4.47", - "tailwindcss": "^3.4.11", - "typescript": "^5.5.3", - "typescript-eslint": "^8.0.1", - "vite": "^5.4.1" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.9.tgz", - "integrity": "sha512-aI3jjAAO1fh7vY/pBGsn1i9LDbRP43+asrRlkPuTXW5yHXtd1NgTEMudbBoDDxrf1daEEfPJqR+JBMakzrR4Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.25.9" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.25.9.tgz", - "integrity": "sha512-4zpTHZ9Cm6L9L+uIqghQX8ZXg8HKFcjYO3qHoO8zTmRm6HQUJ8SSJ+KRvbMBZn0EGVlT4DRYeQ/6hjlyXBh+Kg==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.9.tgz", - "integrity": "sha512-OwS2CM5KocvQ/k7dFJa8i5bNGJP0hXWfVCfDkqRFP1IreH1JDC7wG6eCYCi0+McbfT8OR/kNqsI0UU0xP9H6PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz", - "integrity": "sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", - "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.4", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", - "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", - "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.13.0.tgz", - "integrity": "sha512-IFLyoY4d72Z5y/6o/BazFBezupzI/taV8sGumxTAVw3lXG9A6md1Dc34T9s1FoD/an9pJH8RHbAxsaEbBed9lA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", - "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz", - "integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==", - "dev": true, - "dependencies": { - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", - "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.8" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.11", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.11.tgz", - "integrity": "sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.8" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", - "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", - "license": "MIT" - }, - "node_modules/@hookform/resolvers": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.0.tgz", - "integrity": "sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==", - "license": "MIT", - "peerDependencies": { - "react-hook-form": "^7.0.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.0.tgz", - "integrity": "sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.5", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.5.tgz", - "integrity": "sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.0", - "@humanwhocodes/retry": "^0.3.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "license": "MIT", - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@radix-ui/number": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", - "integrity": "sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==", - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", - "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", - "license": "MIT" - }, - "node_modules/@radix-ui/react-accordion": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.1.tgz", - "integrity": "sha512-bg/l7l5QzUjgsh8kjwDFommzAshnUsuVMV5NM56QVCm+7ZckYdd9P/ExR8xG/Oup0OajVxNLaHJ1tb8mXk+nzQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collapsible": "1.1.1", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.2.tgz", - "integrity": "sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dialog": "1.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", - "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.0.tgz", - "integrity": "sha512-dP87DM/Y7jFlPgUZTlhx6FF5CEzOiaxp2rBCKlaXlpH5Ip/9Fg5zZ9lDOQ5o/MOfUlf36eak14zoWYpgcgGoOg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-avatar": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz", - "integrity": "sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.2.tgz", - "integrity": "sha512-/i0fl686zaJbDQLNKrkCbMyDm6FQMt4jg323k7HuqitoANm9sE23Ql8yOK3Wusk34HSLKDChhMux05FnP6KUkw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-use-size": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.1.tgz", - "integrity": "sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", - "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", - "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context-menu": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.2.tgz", - "integrity": "sha512-99EatSTpW+hRYHt7m8wdDlLtkmTovEe8Z/hnxUPV+SKuuNL5HWNhQI4QSdjZqNSgXHay2z4M3Dym73j9p2Gx5Q==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-menu": "2.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz", - "integrity": "sha512-Yj4dZtqa2o+kG61fzB0H2qUvmwBA2oyQroGLyNtBj1beo1khoQ3q1a2AO8rrQYjd8256CO9+N8L9tvsS+bnIyA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", - "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-escape-keydown": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.2.tgz", - "integrity": "sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-menu": "2.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", - "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz", - "integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.2.tgz", - "integrity": "sha512-Y5w0qGhysvmqsIy6nQxaPa6mXNKznfoGjOfBgzOjocLxr2XlSjqBMYQQL+FfyogsMuX+m8cZyQGYhJxvxUzO4w==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", - "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.0.tgz", - "integrity": "sha512-peLblDlFw/ngk3UWq0VnYaOLy6agTZZ+MUO/WhVfm14vJGML+xH4FAl2XQGLqdefjNb7ApRg6Yn7U42ZhmYXdw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.2.tgz", - "integrity": "sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menubar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.2.tgz", - "integrity": "sha512-cKmj5Gte7LVyuz+8gXinxZAZECQU+N7aq5pw7kUPpx3xjnDXDbsdzHtCCD2W72bwzy74AvrqdYnKYS42ueskUQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-menu": "2.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.1.tgz", - "integrity": "sha512-egDo0yJD2IK8L17gC82vptkvW1jLeni1VuqCyzY727dSJdk5cDjINomouLoNk8RVF7g2aNIfENKWL4UzeU9c8Q==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", - "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", - "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-rect": "1.1.0", - "@radix-ui/react-use-size": "1.1.0", - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", - "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", - "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", - "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.0.tgz", - "integrity": "sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-radio-group": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", - "integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-use-size": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", - "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.0.tgz", - "integrity": "sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-select": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz", - "integrity": "sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-focus-guards": "1.1.1", - "@radix-ui/react-focus-scope": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.6.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz", - "integrity": "sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.1.tgz", - "integrity": "sha512-bEzQoDW0XP+h/oGbutF5VMWJPAl/UU8IJjr7h02SOHDIIIxq+cep8nItVNoBV+OMmahCdqdF38FTpmXoqQUGvw==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.0", - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-use-size": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", - "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-switch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.1.tgz", - "integrity": "sha512-diPqDDoBcZPSicYoMWdWx+bCPuTRH4QSp9J+65IvtdS0Kuzt67bI6n32vCj8q6NZmYW/ah+2orOtMwcX5eQwIg==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-previous": "1.1.0", - "@radix-ui/react-use-size": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", - "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toast": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", - "integrity": "sha512-Z6pqSzmAP/bFJoqMAston4eSNa+ud44NSZTiZUmUen+IOZ5nBY8kzuU5WDBVyFXPtcW6yUalOHsxM/BP6Sv8ww==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-collection": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-callback-ref": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", - "integrity": "sha512-gwoxaKZ0oJ4vIgzsfESBuSgJNdc0rv12VhHgcqN0TEJmmZixXG/2XpsLK8kzNWYcnaoRIEEQc0bEi3dIvdUpjw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.0.tgz", - "integrity": "sha512-PpTJV68dZU2oqqgq75Uzto5o/XfOVgkrJ9rulVmfTKxWp3HfUjHE6CP/WLRR4AzPX9HWxw7vFow2me85Yu+Naw==", - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-toggle": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-context": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", - "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz", - "integrity": "sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.1", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.2", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-slot": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0", - "@radix-ui/react-visually-hidden": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", - "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", - "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", - "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", - "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz", - "integrity": "sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", - "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", - "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", - "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.0.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", - "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", - "license": "MIT" - }, - "node_modules/@remix-run/router": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", - "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", - "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", - "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", - "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", - "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", - "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", - "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", - "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", - "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", - "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", - "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", - "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", - "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", - "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", - "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", - "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", - "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@swc/core": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", - "integrity": "sha512-jns6VFeOT49uoTKLWIEfiQqJAlyqldNAt80kAr8f7a5YjX0zgnG3RBiLMpksx4Ka4SlK4O6TJ/lumIM3Trp82g==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.13" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.39", - "@swc/core-darwin-x64": "1.7.39", - "@swc/core-linux-arm-gnueabihf": "1.7.39", - "@swc/core-linux-arm64-gnu": "1.7.39", - "@swc/core-linux-arm64-musl": "1.7.39", - "@swc/core-linux-x64-gnu": "1.7.39", - "@swc/core-linux-x64-musl": "1.7.39", - "@swc/core-win32-arm64-msvc": "1.7.39", - "@swc/core-win32-ia32-msvc": "1.7.39", - "@swc/core-win32-x64-msvc": "1.7.39" - }, - "peerDependencies": { - "@swc/helpers": "*" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.39.tgz", - "integrity": "sha512-o2nbEL6scMBMCTvY9OnbyVXtepLuNbdblV9oNJEFia5v5eGj9WMrnRQiylH3Wp/G2NYkW7V1/ZVW+kfvIeYe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.39.tgz", - "integrity": "sha512-qMlv3XPgtPi/Fe11VhiPDHSLiYYk2dFYl747oGsHZPq+6tIdDQjIhijXPcsUHIXYDyG7lNpODPL8cP/X1sc9MA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.39.tgz", - "integrity": "sha512-NP+JIkBs1ZKnpa3Lk2W1kBJMwHfNOxCUJXuTa2ckjFsuZ8OUu2gwdeLFkTHbR43dxGwH5UzSmuGocXeMowra/Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.39.tgz", - "integrity": "sha512-cPc+/HehyHyHcvAsk3ML/9wYcpWVIWax3YBaA+ScecJpSE04l/oBHPfdqKUPslqZ+Gcw0OWnIBGJT/fBZW2ayw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.39.tgz", - "integrity": "sha512-8RxgBC6ubFem66bk9XJ0vclu3exJ6eD7x7CwDhp5AD/tulZslTYXM7oNPjEtje3xxabXuj/bEUMNvHZhQRFdqA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.39.tgz", - "integrity": "sha512-3gtCPEJuXLQEolo9xsXtuPDocmXQx12vewEyFFSMSjOfakuPOBmOQMa0sVL8Wwius8C1eZVeD1fgk0omMqeC+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.39.tgz", - "integrity": "sha512-mg39pW5x/eqqpZDdtjZJxrUvQNSvJF4O8wCl37fbuFUqOtXs4TxsjZ0aolt876HXxxhsQl7rS+N4KioEMSgTZw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.39.tgz", - "integrity": "sha512-NZwuS0mNJowH3e9bMttr7B1fB8bW5svW/yyySigv9qmV5VcQRNz1kMlCvrCLYRsa93JnARuiaBI6FazSeG8mpA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.39.tgz", - "integrity": "sha512-qFmvv5UExbJPXhhvCVDBnjK5Duqxr048dlVB6ZCgGzbRxuarOlawCzzLK4N172230pzlAWGLgn9CWl3+N6zfHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.7.39", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.39.tgz", - "integrity": "sha512-o+5IMqgOtj9+BEOp16atTfBgCogVak9svhBpwsbcJQp67bQbxGYhAPPDW/hZ2rpSSF7UdzbY9wudoX9G4trcuQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@swc/types": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz", - "integrity": "sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@swc/counter": "^0.1.3" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz", - "integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==", - "dev": true, - "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" - } - }, - "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@tanstack/query-core": { - "version": "5.59.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.59.16.tgz", - "integrity": "sha512-crHn+G3ltqb5JG0oUv6q+PMz1m1YkjpASrXTU+sYWW9pLk0t2GybUHNRqYPZWhxgjPaVGC4yp92gSFEJgYEsPw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.59.16", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.16.tgz", - "integrity": "sha512-MuyWheG47h6ERd4PKQ6V8gDyBu3ThNG22e1fRVwvq6ap3EqsFhyuxCAwhNP/03m/mLg+DAb0upgbPaX6VB+CkQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.59.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", - "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", - "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.7.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz", - "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.13", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", - "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.12", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", - "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/react": "*" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", - "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.11.0", - "@typescript-eslint/type-utils": "8.11.0", - "@typescript-eslint/utils": "8.11.0", - "@typescript-eslint/visitor-keys": "8.11.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.11.0.tgz", - "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "8.11.0", - "@typescript-eslint/types": "8.11.0", - "@typescript-eslint/typescript-estree": "8.11.0", - "@typescript-eslint/visitor-keys": "8.11.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", - "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.11.0", - "@typescript-eslint/visitor-keys": "8.11.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", - "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/typescript-estree": "8.11.0", - "@typescript-eslint/utils": "8.11.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", - "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", - "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "8.11.0", - "@typescript-eslint/visitor-keys": "8.11.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", - "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.11.0", - "@typescript-eslint/types": "8.11.0", - "@typescript-eslint/typescript-estree": "8.11.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", - "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.11.0", - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@vitejs/plugin-react-swc": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.1.tgz", - "integrity": "sha512-vgWOY0i1EROUK0Ctg1hwhtC3SdcDjZcdit4Ups4aPkDcB1jYhmo+RMYWY87cmXMhvtD5uf8lV89j2w16vkdSVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@swc/core": "^1.7.26" - }, - "peerDependencies": { - "vite": "^4 || ^5" - } - }, - "node_modules/acorn": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.13.0.tgz", - "integrity": "sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-hidden": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", - "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.1" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001669", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001669.tgz", - "integrity": "sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/class-variance-authority": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", - "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "dependencies": { - "clsx": "^2.1.1" - }, - "funding": { - "url": "https://polar.sh/cva" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cmdk": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", - "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-dialog": "1.0.5", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, - "node_modules/cmdk/node_modules/@radix-ui/primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", - "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", - "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-context": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", - "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", - "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-dismissable-layer": "1.0.5", - "@radix-ui/react-focus-guards": "1.0.1", - "@radix-ui/react-focus-scope": "1.0.4", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-portal": "1.0.4", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-slot": "1.0.2", - "@radix-ui/react-use-controllable-state": "1.0.1", - "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.5" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", - "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-escape-keydown": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", - "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", - "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-id": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", - "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-portal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", - "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-primitive": "1.0.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-presence": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", - "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", - "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-slot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", - "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", - "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", - "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", - "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", - "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.13.10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/cmdk/node_modules/react-remove-scroll": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", - "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.3", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "license": "Apache-2.0" - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.45", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.45.tgz", - "integrity": "sha512-vOzZS6uZwhhbkZbcRyiy99Wg+pYFV5hk+5YaECvx0+Z31NR3Tt5zS6dze2OepT6PCTzVzT0dIJItti+uAW5zmw==", - "dev": true, - "license": "ISC" - }, - "node_modules/embla-carousel": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.3.0.tgz", - "integrity": "sha512-Ve8dhI4w28qBqR8J+aMtv7rLK89r1ZA5HocwFz6uMB/i5EiC7bGI7y+AM80yAVUJw3qqaZYK7clmZMUR8kM3UA==", - "license": "MIT" - }, - "node_modules/embla-carousel-react": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.3.0.tgz", - "integrity": "sha512-P1FlinFDcIvggcErRjNuVqnUR8anyo8vLMIH8Rthgofw7Nj8qTguCa2QjFAbzxAUTQTPNNjNL7yt0BGGinVdFw==", - "license": "MIT", - "dependencies": { - "embla-carousel": "8.3.0", - "embla-carousel-reactive-utils": "8.3.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.1 || ^18.0.0" - } - }, - "node_modules/embla-carousel-reactive-utils": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.3.0.tgz", - "integrity": "sha512-EYdhhJ302SC4Lmkx8GRsp0sjUhEN4WyFXPOk0kGu9OXZSRMmcBlRgTvHcq8eKJE1bXWBsOi1T83B+BSSVZSmwQ==", - "license": "MIT", - "peerDependencies": { - "embla-carousel": "8.3.0" - } - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.13.0.tgz", - "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.11.0", - "@eslint/config-array": "^0.18.0", - "@eslint/core": "^0.7.0", - "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "9.13.0", - "@eslint/plugin-kit": "^0.2.0", - "@humanfs/node": "^0.16.5", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.3.1", - "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.1.0", - "eslint-visitor-keys": "^4.1.0", - "espree": "^10.2.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "5.1.0-rc-fb9a90fa48-20240614", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0-rc-fb9a90fa48-20240614.tgz", - "integrity": "sha512-xsiRwaDNF5wWNC4ZHLut+x/YcAxksUd9Rizt7LaEn3bV8VyYRpXnRJQlLOfYaVy9esk4DFP4zPPnoNVjq5Gc0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.14.tgz", - "integrity": "sha512-aXvzCTK7ZBv1e7fahFuR3Z/fyQQSIQ711yPgYRj+Oj64tyTgO4iQIDmYXDBqvSWQ/FA4OSCsXOStlF+noU0/NA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=7" - } - }, - "node_modules/eslint-scope": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.1.0.tgz", - "integrity": "sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz", - "integrity": "sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.2.0.tgz", - "integrity": "sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.12.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-equals": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", - "integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true, - "license": "ISC" - }, - "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "15.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.11.0.tgz", - "integrity": "sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/input-otp": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.2.4.tgz", - "integrity": "sha512-md6rhmD+zmMnUh5crQNSQxq3keBRYvE3odbr4Qb9g2NWzQv9azi+t1a3X4TBTbh98fsGHgEEJlzbe1q860uGCA==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/invariant": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", - "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", - "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.6", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", - "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lovable-tagger": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/lovable-tagger/-/lovable-tagger-1.1.7.tgz", - "integrity": "sha512-b1wwYbuxWGx+DuqviQGQXrgLAraK1RVbqTg6G8LYRID8FJTg4TuAeO0TJ7i6UXOF8gEzbgjhRbGZ+XAkWH2T8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.8", - "esbuild": "^0.25.0", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12", - "tailwindcss": "^3.4.17" - }, - "peerDependencies": { - "vite": "^5.0.0" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/lovable-tagger/node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/lucide-react": { - "version": "0.462.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz", - "integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" - } - }, - "node_modules/magic-string": { - "version": "0.30.12", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", - "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/next-themes": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", - "integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8 || ^17 || ^18", - "react-dom": "^16.8 || ^17 || ^18" - } - }, - "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-day-picker": { - "version": "8.10.1", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", - "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/sponsors/gpbl" - }, - "peerDependencies": { - "date-fns": "^2.28.0 || ^3.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-hook-form": { - "version": "7.53.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.1.tgz", - "integrity": "sha512-6aiQeBda4zjcuaugWvim9WsGqisoUk+etmFEsSUMm451/Ic8L/UAb7sRtMj3V+Hdzm6mMjU1VhiSzYUZeBm0Vg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/react-remove-scroll": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", - "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.6", - "react-style-singleton": "^2.2.1", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.0", - "use-sidecar": "^1.1.2" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz", - "integrity": "sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==", - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.1", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-resizable-panels": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.5.tgz", - "integrity": "sha512-JMSe18rYupmx+dzYcdfWYZ93ZdxqQmLum3xWDVSUMI0UVwl9bB9gUaFmPbxYoO4G+m5sqgdXQCYQxnOysytfnw==", - "license": "MIT", - "peerDependencies": { - "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - } - }, - "node_modules/react-router": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", - "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.20.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", - "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", - "license": "MIT", - "dependencies": { - "@remix-run/router": "1.20.0", - "react-router": "6.27.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/react-smooth": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", - "integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", - "license": "MIT", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", - "integrity": "sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==", - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "invariant": "^2.2.4", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/recharts": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.13.0.tgz", - "integrity": "sha512-sbfxjWQ+oLWSZEWmvbq/DFVdeRLqqA6d0CDjKx2PkxVVdoXo16jvENCE+u/x7HxOO+/fwx//nYRwb8p8X6s/lQ==", - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.0", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", - "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.6" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.24.0", - "@rollup/rollup-android-arm64": "4.24.0", - "@rollup/rollup-darwin-arm64": "4.24.0", - "@rollup/rollup-darwin-x64": "4.24.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", - "@rollup/rollup-linux-arm-musleabihf": "4.24.0", - "@rollup/rollup-linux-arm64-gnu": "4.24.0", - "@rollup/rollup-linux-arm64-musl": "4.24.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", - "@rollup/rollup-linux-riscv64-gnu": "4.24.0", - "@rollup/rollup-linux-s390x-gnu": "4.24.0", - "@rollup/rollup-linux-x64-gnu": "4.24.0", - "@rollup/rollup-linux-x64-musl": "4.24.0", - "@rollup/rollup-win32-arm64-msvc": "4.24.0", - "@rollup/rollup-win32-ia32-msvc": "4.24.0", - "@rollup/rollup-win32-x64-msvc": "4.24.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sonner": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz", - "integrity": "sha512-FBjhG/gnnbN6FY0jaNnqZOMmB73R+5IiyYAw8yBj7L54ER7HB3fOSE5OFiQiE2iXWxeXKvg6fIP4LtVppHEdJA==", - "license": "MIT", - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/tailwind-merge": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz", - "integrity": "sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" - } - }, - "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.6", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", - "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "license": "Apache-2.0" - }, - "node_modules/tslib": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", - "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.11.0.tgz", - "integrity": "sha512-cBRGnW3FSlxaYwU8KfAewxFK5uzeOAp0l2KebIlPDOT5olVi65KDG/yjBooPBG0kGW/HLkoz1c/iuBFehcS3IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.11.0", - "@typescript-eslint/parser": "8.11.0", - "@typescript-eslint/utils": "8.11.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.0" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-callback-ref": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.2.tgz", - "integrity": "sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-sidecar": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", - "integrity": "sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==", - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "^16.9.0 || ^17.0.0 || ^18.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/vaul": { - "version": "0.9.9", - "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", - "integrity": "sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-dialog": "^1.1.1" - }, - "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - } - }, - "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, - "node_modules/vite": { - "version": "5.4.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", - "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/yaml": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", - "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "3.23.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", - "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/lingo-ui/package.json b/lingo-ui/package.json deleted file mode 100644 index 95aad28..0000000 --- a/lingo-ui/package.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "name": "vite_react_shadcn_ts", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "build:dev": "vite build --mode development", - "lint": "eslint .", - "preview": "vite preview" - }, - "dependencies": { - "@hookform/resolvers": "^3.9.0", - "@radix-ui/react-accordion": "^1.2.0", - "@radix-ui/react-alert-dialog": "^1.1.1", - "@radix-ui/react-aspect-ratio": "^1.1.0", - "@radix-ui/react-avatar": "^1.1.0", - "@radix-ui/react-checkbox": "^1.1.1", - "@radix-ui/react-collapsible": "^1.1.0", - "@radix-ui/react-context-menu": "^2.2.1", - "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-dropdown-menu": "^2.1.1", - "@radix-ui/react-hover-card": "^1.1.1", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-menubar": "^1.1.1", - "@radix-ui/react-navigation-menu": "^1.2.0", - "@radix-ui/react-popover": "^1.1.1", - "@radix-ui/react-progress": "^1.1.0", - "@radix-ui/react-radio-group": "^1.2.0", - "@radix-ui/react-scroll-area": "^1.1.0", - "@radix-ui/react-select": "^2.1.1", - "@radix-ui/react-separator": "^1.1.0", - "@radix-ui/react-slider": "^1.2.0", - "@radix-ui/react-slot": "^1.1.0", - "@radix-ui/react-switch": "^1.1.0", - "@radix-ui/react-tabs": "^1.1.0", - "@radix-ui/react-toast": "^1.2.1", - "@radix-ui/react-toggle": "^1.1.0", - "@radix-ui/react-toggle-group": "^1.1.0", - "@radix-ui/react-tooltip": "^1.1.4", - "@tanstack/react-query": "^5.56.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.0.0", - "date-fns": "^3.6.0", - "embla-carousel-react": "^8.3.0", - "input-otp": "^1.2.4", - "lucide-react": "^0.462.0", - "next-themes": "^0.3.0", - "react": "^18.3.1", - "react-day-picker": "^8.10.1", - "react-dom": "^18.3.1", - "react-hook-form": "^7.53.0", - "react-resizable-panels": "^2.1.3", - "react-router-dom": "^6.26.2", - "recharts": "^2.12.7", - "sonner": "^1.5.0", - "tailwind-merge": "^2.5.2", - "tailwindcss-animate": "^1.0.7", - "vaul": "^0.9.3", - "zod": "^3.23.8" - }, - "devDependencies": { - "@eslint/js": "^9.9.0", - "@tailwindcss/typography": "^0.5.15", - "@types/node": "^22.5.5", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react-swc": "^3.5.0", - "autoprefixer": "^10.4.20", - "eslint": "^9.9.0", - "eslint-plugin-react-hooks": "^5.1.0-rc.0", - "eslint-plugin-react-refresh": "^0.4.9", - "globals": "^15.9.0", - "lovable-tagger": "^1.1.7", - "postcss": "^8.4.47", - "tailwindcss": "^3.4.11", - "typescript": "^5.5.3", - "typescript-eslint": "^8.0.1", - "vite": "^5.4.1" - } -} diff --git a/lingo-ui/postcss.config.js b/lingo-ui/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/lingo-ui/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/lingo-ui/public/favicon.ico b/lingo-ui/public/favicon.ico deleted file mode 100644 index dd5a12627d36db7eb9c19fa2f931ff1509f0323e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7645 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 LNkvXXu0mjf>#GbK diff --git a/lingo-ui/public/placeholder.svg b/lingo-ui/public/placeholder.svg deleted file mode 100644 index e763910..0000000 --- a/lingo-ui/public/placeholder.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/lingo-ui/public/robots.txt b/lingo-ui/public/robots.txt deleted file mode 100644 index 6018e70..0000000 --- a/lingo-ui/public/robots.txt +++ /dev/null @@ -1,14 +0,0 @@ -User-agent: Googlebot -Allow: / - -User-agent: Bingbot -Allow: / - -User-agent: Twitterbot -Allow: / - -User-agent: facebookexternalhit -Allow: / - -User-agent: * -Allow: / diff --git a/lingo-ui/src/App.css b/lingo-ui/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/lingo-ui/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/lingo-ui/src/App.tsx b/lingo-ui/src/App.tsx deleted file mode 100644 index 18daf2e..0000000 --- a/lingo-ui/src/App.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { Toaster } from "@/components/ui/toaster"; -import { Toaster as Sonner } from "@/components/ui/sonner"; -import { TooltipProvider } from "@/components/ui/tooltip"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { BrowserRouter, Routes, Route } from "react-router-dom"; -import Index from "./pages/Index"; -import NotFound from "./pages/NotFound"; - -const queryClient = new QueryClient(); - -const App = () => ( - - - - - - - } /> - {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} - } /> - - - - -); - -export default App; diff --git a/lingo-ui/src/components/CallToAction.tsx b/lingo-ui/src/components/CallToAction.tsx deleted file mode 100644 index 5b85cb9..0000000 --- a/lingo-ui/src/components/CallToAction.tsx +++ /dev/null @@ -1,47 +0,0 @@ - -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; - -const CallToAction = () => { - return ( -
-
- -

- Ready to Bridge Language Barriers? -

-

- Join thousands of businesses already using TheLingo.ai to communicate effectively - with Indian language speakers and unlock new opportunities. -

- -
- - -
- -
-
- - No credit card required -
-
- - Setup in under 5 minutes -
-
- - 24/7 expert support -
-
-
-
-
- ); -}; - -export default CallToAction; diff --git a/lingo-ui/src/components/Features.tsx b/lingo-ui/src/components/Features.tsx deleted file mode 100644 index 8b7ffd0..0000000 --- a/lingo-ui/src/components/Features.tsx +++ /dev/null @@ -1,74 +0,0 @@ - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; - -const Features = () => { - const features = [ - { - title: "Multi-Language Transcription", - description: "Convert audio to text in 15+ major Indian languages including Hindi, Bengali, Tamil, Telugu, Marathi, and more.", - icon: "🎙️", - details: ["Real-time transcription", "Batch processing", "High accuracy recognition"] - }, - { - title: "Smart Translation", - description: "Translate between Indian languages and English seamlessly, preserving context and cultural nuances.", - icon: "🌐", - details: ["Context-aware translation", "Cultural adaptation", "Multiple output formats"] - }, - { - title: "AI-Powered Summarization", - description: "Generate concise summaries of lengthy audio content, extracting key insights and action items.", - icon: "📝", - details: ["Key point extraction", "Action item identification", "Custom summary lengths"] - }, - { - title: "Automated Meeting Bot", - description: "Automatically join online meetings, record, transcribe, and provide detailed summaries for your team.", - icon: "🤖", - details: ["Auto-join meetings", "Real-time processing", "CRM integration ready"] - } - ]; - - return ( -
-
-
-

- Powerful Features for Every Need -

-

- Our comprehensive AI platform offers everything you need to break down language barriers -

-
- -
- {features.map((feature, index) => ( - - -
-
{feature.icon}
- {feature.title} -
- - {feature.description} - -
- -
    - {feature.details.map((detail, idx) => ( -
  • -
    - {detail} -
  • - ))} -
-
-
- ))} -
-
-
- ); -}; - -export default Features; diff --git a/lingo-ui/src/components/Footer.tsx b/lingo-ui/src/components/Footer.tsx deleted file mode 100644 index bfcfa37..0000000 --- a/lingo-ui/src/components/Footer.tsx +++ /dev/null @@ -1,52 +0,0 @@ - -const Footer = () => { - return ( -
-
-
-
-
-
- T -
- TheLingo.ai -
-

- Empowering Indian language speakers to connect with the world through - AI-powered transcription, translation, and summarization. -

-
- © 2024 TheLingo.ai. All rights reserved. -
-
- -
-

Product

- -
- - -
- -
- Made with ❤️ for empowering Indian languages -
-
-
- ); -}; - -export default Footer; diff --git a/lingo-ui/src/components/Hero.tsx b/lingo-ui/src/components/Hero.tsx deleted file mode 100644 index 1510f57..0000000 --- a/lingo-ui/src/components/Hero.tsx +++ /dev/null @@ -1,61 +0,0 @@ - -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { ArrowDown } from "lucide-react"; - -const Hero = () => { - return ( -
-
- - 🚀 Empowering Indian Languages with AI - - -

- Bridge Language Barriers with -
- TheLingo.ai -

- -

- Transcribe, translate, and summarize major Indian language audio. - Empower millions to share their thoughts with the world through our AI-powered platform. -

- -
- - -
- -
-
-
-
-
15+
-
Indian Languages
-
-
-
99%
-
Accuracy Rate
-
-
-
1M+
-
Audio Hours Processed
-
-
-
-
- -
- -
-
-
- ); -}; - -export default Hero; diff --git a/lingo-ui/src/components/Navigation.tsx b/lingo-ui/src/components/Navigation.tsx deleted file mode 100644 index 59762e4..0000000 --- a/lingo-ui/src/components/Navigation.tsx +++ /dev/null @@ -1,40 +0,0 @@ - -import { Button } from "@/components/ui/button"; - -const Navigation = () => { - return ( - - ); -}; - -export default Navigation; diff --git a/lingo-ui/src/components/UseCases.tsx b/lingo-ui/src/components/UseCases.tsx deleted file mode 100644 index 735c720..0000000 --- a/lingo-ui/src/components/UseCases.tsx +++ /dev/null @@ -1,97 +0,0 @@ - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; - -const UseCases = () => { - const useCases = [ - { - title: "Rural Banking & Microfinance", - description: "Enable sales teams to communicate effectively with farmers and rural customers for loan applications and financial services.", - industry: "Financial Services", - benefits: ["Increased loan approvals", "Better customer understanding", "Seamless CRM integration"], - icon: "🏦" - }, - { - title: "Healthcare Consultations", - description: "Help healthcare providers understand patient concerns in their native language and maintain accurate medical records.", - industry: "Healthcare", - benefits: ["Improved patient care", "Accurate documentation", "Better diagnosis"], - icon: "🏥" - }, - { - title: "Education & Training", - description: "Make educational content accessible to students and professionals who are more comfortable in Indian languages.", - industry: "Education", - benefits: ["Inclusive learning", "Better comprehension", "Skill development"], - icon: "🎓" - }, - { - title: "Customer Support", - description: "Provide multilingual customer support with real-time translation and comprehensive interaction summaries.", - industry: "Customer Service", - benefits: ["24/7 support", "Higher satisfaction", "Reduced resolution time"], - icon: "📞" - }, - { - title: "Sales & CRM Integration", - description: "Automatically capture and process sales calls in local languages, integrating insights directly into your CRM system.", - industry: "Sales", - benefits: ["Better lead qualification", "Automated data entry", "Enhanced follow-up"], - icon: "💼" - }, - { - title: "Content Creation", - description: "Transform audio content in Indian languages into written material for blogs, documentation, and marketing.", - industry: "Content & Media", - benefits: ["Faster content creation", "Multilingual reach", "Cost-effective scaling"], - icon: "✍️" - } - ]; - - return ( -
-
-
-

- Transforming Industries Across India -

-

- See how TheLingo.ai is empowering businesses and organizations to serve Indian language speakers better -

-
- -
- {useCases.map((useCase, index) => ( - - -
-
{useCase.icon}
- - {useCase.industry} - -
- {useCase.title} - - {useCase.description} - -
- -
-

Key Benefits:

- {useCase.benefits.map((benefit, idx) => ( -
-
- {benefit} -
- ))} -
-
-
- ))} -
-
-
- ); -}; - -export default UseCases; diff --git a/lingo-ui/src/components/ui/accordion.tsx b/lingo-ui/src/components/ui/accordion.tsx deleted file mode 100644 index e6a723d..0000000 --- a/lingo-ui/src/components/ui/accordion.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDown } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Accordion = AccordionPrimitive.Root - -const AccordionItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AccordionItem.displayName = "AccordionItem" - -const AccordionTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - svg]:rotate-180", - className - )} - {...props} - > - {children} - - - -)) -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName - -const AccordionContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - -
{children}
-
-)) - -AccordionContent.displayName = AccordionPrimitive.Content.displayName - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/lingo-ui/src/components/ui/alert-dialog.tsx b/lingo-ui/src/components/ui/alert-dialog.tsx deleted file mode 100644 index 8722561..0000000 --- a/lingo-ui/src/components/ui/alert-dialog.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" - -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" - -const AlertDialog = AlertDialogPrimitive.Root - -const AlertDialogTrigger = AlertDialogPrimitive.Trigger - -const AlertDialogPortal = AlertDialogPrimitive.Portal - -const AlertDialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName - -const AlertDialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - - -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName - -const AlertDialogHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -AlertDialogHeader.displayName = "AlertDialogHeader" - -const AlertDialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( -
-) -AlertDialogFooter.displayName = "AlertDialogFooter" - -const AlertDialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName - -const AlertDialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName - -const AlertDialogAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName - -const AlertDialogCancel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName - -export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, -} diff --git a/lingo-ui/src/components/ui/alert.tsx b/lingo-ui/src/components/ui/alert.tsx deleted file mode 100644 index 41fa7e0..0000000 --- a/lingo-ui/src/components/ui/alert.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const alertVariants = cva( - "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", - { - variants: { - variant: { - default: "bg-background text-foreground", - destructive: - "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes & VariantProps ->(({ className, variant, ...props }, ref) => ( -
-)) -Alert.displayName = "Alert" - -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -AlertTitle.displayName = "AlertTitle" - -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)) -AlertDescription.displayName = "AlertDescription" - -export { Alert, AlertTitle, AlertDescription } diff --git a/lingo-ui/src/components/ui/aspect-ratio.tsx b/lingo-ui/src/components/ui/aspect-ratio.tsx deleted file mode 100644 index c4abbf3..0000000 --- a/lingo-ui/src/components/ui/aspect-ratio.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" - -const AspectRatio = AspectRatioPrimitive.Root - -export { AspectRatio } diff --git a/lingo-ui/src/components/ui/avatar.tsx b/lingo-ui/src/components/ui/avatar.tsx deleted file mode 100644 index 991f56e..0000000 --- a/lingo-ui/src/components/ui/avatar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import * as React from "react" -import * as AvatarPrimitive from "@radix-ui/react-avatar" - -import { cn } from "@/lib/utils" - -const Avatar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -Avatar.displayName = AvatarPrimitive.Root.displayName - -const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AvatarImage.displayName = AvatarPrimitive.Image.displayName - -const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName - -export { Avatar, AvatarImage, AvatarFallback } diff --git a/lingo-ui/src/components/ui/badge.tsx b/lingo-ui/src/components/ui/badge.tsx deleted file mode 100644 index f000e3e..0000000 --- a/lingo-ui/src/components/ui/badge.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ) -} - -export { Badge, badgeVariants } diff --git a/lingo-ui/src/components/ui/breadcrumb.tsx b/lingo-ui/src/components/ui/breadcrumb.tsx deleted file mode 100644 index 71a5c32..0000000 --- a/lingo-ui/src/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { ChevronRight, MoreHorizontal } from "lucide-react" - -import { cn } from "@/lib/utils" - -const Breadcrumb = React.forwardRef< - HTMLElement, - React.ComponentPropsWithoutRef<"nav"> & { - separator?: React.ReactNode - } ->(({ ...props }, ref) =>