diff --git a/Cargo.lock b/Cargo.lock index 4be23ba2ba..f06402029a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,6 +566,7 @@ dependencies = [ "api-cloud", "api-env", "api-mail", + "api-meeting-import", "api-messenger", "api-nango", "api-notion", @@ -574,6 +575,7 @@ dependencies = [ "api-subscription", "api-sync", "api-ticket", + "api-zoom", "axum 0.8.9", "dotenvy", "envy", @@ -746,6 +748,25 @@ dependencies = [ "utoipa", ] +[[package]] +name = "api-meeting-import" +version = "0.1.0" +dependencies = [ + "api-auth", + "api-error", + "api-nango", + "axum 0.8.9", + "chrono", + "meeting-import", + "nango", + "serde", + "serde_json", + "thiserror 2.0.18", + "url", + "urlencoding", + "utoipa", +] + [[package]] name = "api-messenger" version = "0.1.0" @@ -794,6 +815,9 @@ dependencies = [ "api-error", "api-nango", "axum 0.8.9", + "meeting-import", + "nango", + "reqwest 0.13.2", "serde", "serde_json", "thiserror 2.0.18", @@ -933,6 +957,24 @@ dependencies = [ "utoipa", ] +[[package]] +name = "api-zoom" +version = "0.1.0" +dependencies = [ + "api-auth", + "api-error", + "api-nango", + "axum 0.8.9", + "chrono", + "nango", + "serde", + "serde_json", + "thiserror 2.0.18", + "url", + "utoipa", + "zoom", +] + [[package]] name = "apple-calendar" version = "0.1.0" @@ -11193,6 +11235,22 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "meeting-import" +version = "0.1.0" +dependencies = [ + "anlg-http-utils", + "chrono", + "dirs 6.0.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "url", + "urlencoding", + "zoom", +] + [[package]] name = "memchr" version = "2.8.0" @@ -18689,6 +18747,7 @@ dependencies = [ "db-parser", "dirs 6.0.0", "importer-core", + "meeting-import", "rmcp", "serde", "serde_json", @@ -24030,6 +24089,19 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zoom" +version = "0.1.0" +dependencies = [ + "anlg-http-utils", + "chrono", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "urlencoding", +] + [[package]] name = "zopfli" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 8ce3a1f1c6..fe5e6f7c3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ anlg-api-cloud = { path = "crates/api-cloud", package = "api-cloud" } anlg-api-env = { path = "crates/api-env", package = "api-env" } anlg-api-error = { path = "crates/api-error", package = "api-error" } anlg-api-mail = { path = "crates/api-mail", package = "api-mail" } +anlg-api-meeting-import = { path = "crates/api-meeting-import", package = "api-meeting-import" } anlg-api-messenger = { path = "crates/api-messenger", package = "api-messenger" } anlg-api-nango = { path = "crates/api-nango", package = "api-nango" } anlg-api-notion = { path = "crates/api-notion", package = "api-notion" } @@ -51,6 +52,7 @@ anlg-api-storage = { path = "crates/api-storage", package = "api-storage" } anlg-api-subscription = { path = "crates/api-subscription", package = "api-subscription" } anlg-api-sync = { path = "crates/api-sync", package = "api-sync" } anlg-api-ticket = { path = "crates/api-ticket", package = "api-ticket" } +anlg-api-zoom = { path = "crates/api-zoom", package = "api-zoom" } anlg-apple-calendar = { path = "crates/apple-calendar", package = "apple-calendar" } anlg-apple-note = { path = "crates/apple-note", package = "apple-note" } anlg-apple-todo = { path = "crates/apple-todo", package = "apple-todo" } @@ -133,6 +135,7 @@ anlg-loops = { path = "crates/loops", package = "loops" } anlg-mac = { path = "crates/mac", package = "mac" } anlg-mcp = { path = "crates/mcp", package = "mcp" } anlg-meeting-capture = { path = "crates/meeting-capture", package = "meeting-capture" } +anlg-meeting-import = { path = "crates/meeting-import", package = "meeting-import" } anlg-mobile-bridge = { path = "crates/mobile-bridge", package = "mobile-bridge" } anlg-model-downloader = { path = "crates/model-downloader", package = "model-downloader" } anlg-model-manager = { path = "crates/model-manager", package = "model-manager" } @@ -192,6 +195,7 @@ anlg-whisper-local = { path = "crates/whisper-local", package = "whisper-local" anlg-whisper-local-model = { path = "crates/whisper-local-model", package = "whisper-local-model" } anlg-ws-client = { path = "crates/ws-client", package = "ws-client" } anlg-ws-utils = { path = "crates/ws-utils", package = "ws-utils" } +anlg-zoom = { path = "crates/zoom", package = "zoom" } keyring = "4.1.4" legacy-db-core = { path = "legacy/db-core", package = "legacy-db-core" } legacy-db-parser = { path = "legacy/db-parser", package = "db-parser" } diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml index 14c1eca786..e59dece809 100644 --- a/apps/api/Cargo.toml +++ b/apps/api/Cargo.toml @@ -10,6 +10,7 @@ anlg-api-calendar = { workspace = true } anlg-api-cloud = { workspace = true } anlg-api-env = { workspace = true } anlg-api-mail = { workspace = true } +anlg-api-meeting-import = { workspace = true } anlg-api-messenger = { workspace = true } anlg-api-nango = { workspace = true } anlg-api-notion = { workspace = true } @@ -18,6 +19,7 @@ anlg-api-research = { workspace = true } anlg-api-subscription = { workspace = true } anlg-api-sync = { workspace = true } anlg-api-ticket = { workspace = true } +anlg-api-zoom = { workspace = true } anlg-linear = { workspace = true } anlg-llm-proxy = { workspace = true } anlg-observability = { workspace = true } diff --git a/apps/api/openapi.gen.json b/apps/api/openapi.gen.json index d582e745bc..5e185754cb 100644 --- a/apps/api/openapi.gen.json +++ b/apps/api/openapi.gen.json @@ -173,6 +173,88 @@ ] } }, + "/fathom/import-meetings": { + "post": { + "tags": [ + "fathom" + ], + "operationId": "fathom_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Fathom meetings fetched for import", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/google-meet/import-meetings": { + "post": { + "tags": [ + "google-meet" + ], + "operationId": "google_meet_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Google Meet meetings fetched for import", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/llm/chat/completions": { "post": { "tags": [ @@ -609,6 +691,47 @@ } } }, + "/microsoft-teams/import-meetings": { + "post": { + "tags": [ + "microsoft-teams" + ], + "operationId": "microsoft_teams_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Microsoft Teams meetings fetched for import", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, "/nango/connections": { "get": { "tags": [ @@ -816,7 +939,53 @@ "500": { "description": "Notion connection unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/notion/import-meetings": { + "post": { + "tags": [ + "notion" + ], + "operationId": "notion_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotionImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Notion meeting notes fetched for import", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotionImportMeetingsResponse" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Notion connection unavailable" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/notion/search-pages": { @@ -852,7 +1021,12 @@ "500": { "description": "Notion connection unavailable" } - } + }, + "security": [ + { + "bearer_auth": [] + } + ] } }, "/pyannote/v1/diarize": { @@ -4456,6 +4630,88 @@ } ] } + }, + "/webex/import-meetings": { + "post": { + "tags": [ + "webex" + ], + "operationId": "webex_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Webex meetings fetched for import", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } + }, + "/zoom/import-meetings": { + "post": { + "tags": [ + "zoom" + ], + "operationId": "zoom_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoomImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Zoom meetings fetched for import", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoomImportMeetingsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ] + } } }, "components": { @@ -7690,6 +7946,63 @@ "precision-2" ] }, + "ImportMeetingsRequest": { + "type": "object", + "required": [ + "connection_id" + ], + "properties": { + "connection_id": { + "type": "string" + }, + "known_meeting_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ImportMeetingsResponse": { + "type": "object", + "required": [ + "files", + "warnings" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportTextFile" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ImportTextFile": { + "type": "object", + "required": [ + "path", + "name", + "content" + ], + "properties": { + "content": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "Importance": { "type": "string", "enum": [ @@ -8952,6 +9265,63 @@ } } }, + "NotionImportMeetingsRequest": { + "type": "object", + "required": [ + "connection_id" + ], + "properties": { + "connection_id": { + "type": "string" + }, + "known_meeting_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "NotionImportMeetingsResponse": { + "type": "object", + "required": [ + "files", + "warnings" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotionImportTextFile" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "NotionImportTextFile": { + "type": "object", + "required": [ + "path", + "name", + "content" + ], + "properties": { + "content": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "NotionPage": { "type": "object", "required": [ @@ -11659,6 +12029,63 @@ } } }, + "ZoomImportMeetingsRequest": { + "type": "object", + "required": [ + "connection_id" + ], + "properties": { + "connection_id": { + "type": "string" + }, + "known_meeting_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ZoomImportMeetingsResponse": { + "type": "object", + "required": [ + "files", + "warnings" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ZoomImportTextFile" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ZoomImportTextFile": { + "type": "object", + "required": [ + "path", + "name", + "content" + ], + "properties": { + "content": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, "google.Attendee": { "type": "object", "properties": { @@ -12934,6 +13361,26 @@ "name": "ticket", "description": "Ticket management" }, + { + "name": "zoom", + "description": "Zoom meeting import" + }, + { + "name": "fathom", + "description": "Fathom meeting import" + }, + { + "name": "webex", + "description": "Webex meeting import" + }, + { + "name": "google-meet", + "description": "Google Meet meeting import" + }, + { + "name": "microsoft-teams", + "description": "Microsoft Teams meeting import" + }, { "name": "nango", "description": "Integration management via Nango" diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index 82dfdbc1ce..5fe152baed 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -386,6 +386,8 @@ async fn app_with_env(env: &'static crate::env::RuntimeConfig) -> Router { .nest("/messenger", anlg_api_messenger::router()) .nest("/notion", anlg_api_notion::router()) .nest("/ticket", anlg_api_ticket::router()) + .nest("/zoom", anlg_api_zoom::router()) + .merge(anlg_api_meeting_import::router()) .nest("/nango", anlg_api_nango::session_router(config)) .layer(axum::Extension(nango_connection_state)) .route_layer(middleware::from_fn(auth::sentry_and_analytics)) diff --git a/apps/api/src/openapi.rs b/apps/api/src/openapi.rs index f769c76cbc..8fa2de1eee 100644 --- a/apps/api/src/openapi.rs +++ b/apps/api/src/openapi.rs @@ -20,6 +20,11 @@ use utoipa::{Modify, OpenApi}; (name = "messenger", description = "Messaging integrations"), (name = "notion", description = "Notion integration"), (name = "ticket", description = "Ticket management"), + (name = "zoom", description = "Zoom meeting import"), + (name = "fathom", description = "Fathom meeting import"), + (name = "webex", description = "Webex meeting import"), + (name = "google-meet", description = "Google Meet meeting import"), + (name = "microsoft-teams", description = "Microsoft Teams meeting import"), (name = "nango", description = "Integration management via Nango"), (name = "sync", description = "CloudSync credential management"), (name = "shared-notes", description = "Public shared-note delivery"), @@ -41,6 +46,8 @@ pub fn openapi() -> utoipa::openapi::OpenApi { let messenger_doc = with_path_prefix(anlg_api_messenger::openapi(), "/messenger"); let notion_doc = with_path_prefix(anlg_api_notion::openapi(), "/notion"); let ticket_doc = with_path_prefix(anlg_api_ticket::openapi(), "/ticket"); + let zoom_doc = with_path_prefix(anlg_api_zoom::openapi(), "/zoom"); + let meeting_import_doc = anlg_api_meeting_import::openapi(); let nango_doc = with_path_prefix(anlg_api_nango::openapi(), "/nango"); let subscription_doc = with_path_prefix(anlg_api_subscription::openapi(), "/subscription"); let sync_doc = with_path_prefix(anlg_api_sync::openapi(), "/sync"); @@ -56,6 +63,8 @@ pub fn openapi() -> utoipa::openapi::OpenApi { doc.merge(messenger_doc); doc.merge(notion_doc); doc.merge(ticket_doc); + doc.merge(zoom_doc); + doc.merge(meeting_import_doc); doc.merge(nango_doc); doc.merge(subscription_doc); doc.merge(sync_doc); @@ -136,6 +145,12 @@ fn apply_bearer_auth_to_protected_paths(doc: &mut utoipa::openapi::OpenApi) { if path.starts_with("/calendar") || path.starts_with("/mail") || path.starts_with("/ticket") + || path.starts_with("/zoom") + || path.starts_with("/fathom") + || path.starts_with("/webex") + || path.starts_with("/google-meet") + || path.starts_with("/microsoft-teams") + || path.starts_with("/notion") || path.starts_with("/subscription") || path.starts_with("/nango") || path.starts_with("/pyannote") @@ -285,6 +300,29 @@ mod tests { } } + #[test] + fn zoom_import_path_is_prefixed_and_protected() { + let doc = super::openapi(); + assert_bearer( + doc.paths.paths.get("/zoom/import-meetings").unwrap(), + "post", + ); + } + + #[test] + fn nango_meeting_import_paths_are_protected() { + let doc = super::openapi(); + for path in [ + "/fathom/import-meetings", + "/webex/import-meetings", + "/google-meet/import-meetings", + "/microsoft-teams/import-meetings", + "/notion/import-meetings", + ] { + assert_bearer(doc.paths.paths.get(path).unwrap(), "post"); + } + } + #[test] fn cloud_api_documents_management_and_connector_auth_separately() { let doc = super::openapi(); diff --git a/apps/desktop/src/imports/connected-import.test.ts b/apps/desktop/src/imports/connected-import.test.ts index 4d2602ed11..e6036e7c37 100644 --- a/apps/desktop/src/imports/connected-import.test.ts +++ b/apps/desktop/src/imports/connected-import.test.ts @@ -12,6 +12,14 @@ const mocks = vi.hoisted(() => ({ deleteSecret: vi.fn(), getImportedMeetingIds: vi.fn(), importConnectedMeetings: vi.fn(), + openIntegrationUrl: vi.fn(), + listConnections: vi.fn(), + zoomImportMeetings: vi.fn(), + fathomImportMeetings: vi.fn(), + googleMeetImportMeetings: vi.fn(), + microsoftTeamsImportMeetings: vi.fn(), + notionImportMeetings: vi.fn(), + webexImportMeetings: vi.fn(), })); vi.mock("@anlg/plugin-importer", () => ({ @@ -40,10 +48,34 @@ vi.mock("./queries", () => ({ importConnectedMeetings: mocks.importConnectedMeetings, })); +vi.mock("~/shared/integration", () => ({ + openIntegrationUrl: mocks.openIntegrationUrl, +})); + +vi.mock("@anlg/api-client", () => ({ + listConnections: mocks.listConnections, + zoomImportMeetings: mocks.zoomImportMeetings, + fathomImportMeetings: mocks.fathomImportMeetings, + googleMeetImportMeetings: mocks.googleMeetImportMeetings, + microsoftTeamsImportMeetings: mocks.microsoftTeamsImportMeetings, + notionImportMeetings: mocks.notionImportMeetings, + webexImportMeetings: mocks.webexImportMeetings, +})); + +vi.mock("@anlg/api-client/client", () => ({ + createClient: () => ({}), +})); + +vi.mock("~/env", () => ({ + env: { VITE_API_URL: "https://api.test" }, +})); + import { cancelConnectedImport, connectConnectedImport, + connectNangoImport, connectedImportSyncQueryOptions, + nangoImportSyncQueryOptions, } from "./connected-import"; const provider = { id: "circleback", name: "Circleback" }; @@ -193,4 +225,170 @@ describe("connected meeting imports", () => { ); expect(result.result.imported).toBe(1); }); + + it("connects Plaud through the local CLI without opening a leftover URL", async () => { + const plaud = { id: "plaud", name: "Plaud" }; + const plaudCredentials = { + providerId: "plaud", + clientId: "ada@example.com", + clientSecret: null, + tokenJson: JSON.stringify({ + kind: "cli", + binary: "/usr/local/bin/plaud", + }), + tokenReceivedAt: 1_786_217_400, + }; + mocks.beginConnectedImport.mockResolvedValue({ + status: "ok", + data: { + providerId: "plaud", + authorizationUrl: "", + }, + }); + mocks.completeConnectedImport.mockResolvedValue({ + status: "ok", + data: plaudCredentials, + }); + + await expect(connectConnectedImport(plaud)).resolves.toEqual( + plaudCredentials, + ); + expect(mocks.openUrl).not.toHaveBeenCalled(); + expect(mocks.setSecret).toHaveBeenCalledWith( + "meeting-imports", + "plaud-cli", + JSON.stringify(plaudCredentials), + ); + }); +}); + +describe("nango meeting imports", () => { + const provider = { + id: "zoom", + name: "Zoom", + nangoIntegrationId: "zoom", + }; + const headers = { Authorization: "Bearer test" }; + + beforeEach(() => { + vi.clearAllMocks(); + mocks.openIntegrationUrl.mockResolvedValue(undefined); + }); + + it("opens Zoom OAuth and waits for the Nango connection", async () => { + mocks.listConnections.mockResolvedValue({ + data: { + connections: [ + { + connection_id: "zoom-1", + integration_id: "zoom", + status: "ok", + }, + ], + }, + error: null, + }); + + await expect(connectNangoImport(provider, headers)).resolves.toEqual({ + connection_id: "zoom-1", + integration_id: "zoom", + status: "ok", + }); + expect(mocks.openIntegrationUrl).toHaveBeenCalledWith( + "zoom", + undefined, + "connect", + "imports", + headers, + ); + expect(mocks.listConnections).toHaveBeenCalledOnce(); + }); + + it("imports Zoom meetings that are not already present", async () => { + mocks.getImportedMeetingIds.mockResolvedValue(["meeting-existing"]); + mocks.zoomImportMeetings.mockResolvedValue({ + data: { + files: [ + { + path: "oauth://zoom/meeting-new.json", + name: "meeting-new.json", + content: "{}", + }, + ], + warnings: [], + }, + error: null, + }); + mocks.importConnectedMeetings.mockResolvedValue({ + discovered: 1, + imported: 1, + matched: 0, + conflicts: 0, + errors: 0, + }); + + const queryClient = new QueryClient(); + const result = await queryClient.fetchQuery( + nangoImportSyncQueryOptions(provider, "zoom-1", headers, true), + ); + + expect(mocks.zoomImportMeetings).toHaveBeenCalledWith({ + client: {}, + body: { + connection_id: "zoom-1", + known_meeting_ids: ["meeting-existing"], + }, + }); + expect(mocks.importConnectedMeetings).toHaveBeenCalledWith("zoom", [ + { + path: "oauth://zoom/meeting-new.json", + name: "meeting-new.json", + content: "{}", + }, + ]); + expect(result.result.imported).toBe(1); + }); + + it("imports Fathom meetings through the Fathom endpoint", async () => { + const fathomProvider = { + id: "fathom", + name: "Fathom", + nangoIntegrationId: "fathom", + }; + mocks.getImportedMeetingIds.mockResolvedValue([]); + mocks.fathomImportMeetings.mockResolvedValue({ + data: { + files: [ + { + path: "oauth://fathom/meeting-new.json", + name: "meeting-new.json", + content: "{}", + }, + ], + warnings: [], + }, + error: null, + }); + mocks.importConnectedMeetings.mockResolvedValue({ + discovered: 1, + imported: 1, + matched: 0, + conflicts: 0, + errors: 0, + }); + + const queryClient = new QueryClient(); + await queryClient.fetchQuery( + nangoImportSyncQueryOptions(fathomProvider, "fathom-1", headers, true), + ); + + expect(mocks.fathomImportMeetings).toHaveBeenCalledWith({ + client: {}, + body: { + connection_id: "fathom-1", + known_meeting_ids: [], + }, + }); + expect(mocks.zoomImportMeetings).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/imports/connected-import.ts b/apps/desktop/src/imports/connected-import.ts index 58a31dfaac..6e886264f2 100644 --- a/apps/desktop/src/imports/connected-import.ts +++ b/apps/desktop/src/imports/connected-import.ts @@ -1,5 +1,16 @@ import { queryOptions } from "@tanstack/react-query"; +import { + fathomImportMeetings, + googleMeetImportMeetings, + listConnections, + microsoftTeamsImportMeetings, + notionImportMeetings, + webexImportMeetings, + zoomImportMeetings, + type ConnectionItem, +} from "@anlg/api-client"; +import { createClient } from "@anlg/api-client/client"; import { commands as importerCommands, type ConnectedImportCredentials, @@ -14,6 +25,9 @@ import { type MeetingImportResult, } from "./queries"; +import { env } from "~/env"; +import { openIntegrationUrl } from "~/shared/integration"; + const CONNECTED_IMPORT_SECRET_SCOPE = "meeting-imports"; const CONNECTED_IMPORT_SYNC_INTERVAL_MS = 5 * 60 * 1_000; @@ -38,6 +52,33 @@ export function connectedImportCredentialsQueryOptions(providerId: string) { }); } +const NANGO_CONNECTION_POLL_MS = 2_000; +const NANGO_CONNECTION_TIMEOUT_MS = 5 * 60 * 1_000; + +export function isDirectMeetingImport( + provider: Pick, +) { + return Boolean(provider.directImport); +} + +export function isNangoMeetingImport( + provider: Pick, +) { + return provider.directImport === "nango-oauth"; +} + +export function isLocalConnectedImport( + provider: Pick, +) { + return ( + provider.directImport === "mcp-oauth" || provider.directImport === "cli" + ); +} + +export function nangoConnectionIsReady(connection: ConnectionItem | undefined) { + return Boolean(connection) && connection?.status !== "reconnect_required"; +} + export function connectedImportSyncQueryOptions( provider: Pick, enabled: boolean, @@ -55,6 +96,25 @@ export function connectedImportSyncQueryOptions( }); } +export function nangoImportSyncQueryOptions( + provider: Pick, + connectionId: string | undefined, + headers: Record | null, + enabled: boolean, +) { + return queryOptions({ + queryKey: [...connectedImportSyncQueryKey(provider.id), connectionId], + queryFn: () => syncNangoMeetings(provider, connectionId!, headers!), + enabled: enabled && Boolean(connectionId) && Boolean(headers), + retry: false, + staleTime: CONNECTED_IMPORT_SYNC_INTERVAL_MS, + refetchInterval: CONNECTED_IMPORT_SYNC_INTERVAL_MS, + refetchIntervalInBackground: true, + refetchOnMount: true, + refetchOnWindowFocus: true, + }); +} + export async function connectConnectedImport( provider: Pick, signal?: AbortSignal, @@ -66,12 +126,14 @@ export async function connectConnectedImport( if (authorization.status === "error") throw new Error(authorization.error); await cancelConnectionIfRequested(provider.id, signal); - const opened = await openerCommands.openUrl( - authorization.data.authorizationUrl, - null, - ); - if (opened.status === "error") throw new Error(opened.error); - await cancelConnectionIfRequested(provider.id, signal); + if (authorization.data.authorizationUrl) { + const opened = await openerCommands.openUrl( + authorization.data.authorizationUrl, + null, + ); + if (opened.status === "error") throw new Error(opened.error); + await cancelConnectionIfRequested(provider.id, signal); + } const credentials = await waitForConnectionCompletion(provider.id, signal); if (credentials.status === "error") throw new Error(credentials.error); @@ -81,6 +143,41 @@ export async function connectConnectedImport( return credentials.data; } +export async function connectNangoImport( + provider: Pick, + headers: Record, + signal?: AbortSignal, +) { + const integrationId = provider.nangoIntegrationId; + if (!integrationId) { + throw new Error(`${provider.name} connection is not available`); + } + throwIfConnectionCancelled(signal); + await openIntegrationUrl( + integrationId, + undefined, + "connect", + "imports", + headers, + ); + await cancelNangoConnectionIfRequested(signal); + return waitForNangoConnection(provider.name, integrationId, headers, signal); +} + +export async function disconnectNangoImport( + nangoIntegrationId: string, + connectionId: string, +) { + await openIntegrationUrl( + nangoIntegrationId, + connectionId, + "disconnect", + "imports", + null, + false, + ); +} + export async function cancelConnectedImport(providerId: string) { const result = await importerCommands.cancelConnectedImport(providerId); if (result.status === "error") throw new Error(result.error); @@ -136,6 +233,81 @@ export async function disconnectConnectedImport(providerId: string) { if (result.status === "error") throw new Error(result.error); } +async function waitForNangoConnection( + providerName: string, + integrationId: string, + headers: Record, + signal?: AbortSignal, +) { + const client = createClient({ baseUrl: env.VITE_API_URL, headers }); + const deadline = Date.now() + NANGO_CONNECTION_TIMEOUT_MS; + + while (true) { + throwIfConnectionCancelled(signal); + const { data, error } = await listConnections({ client }); + if (error) throw new Error("Failed to load integrations"); + const connection = data?.connections.find( + (item) => + item.integration_id === integrationId && nangoConnectionIsReady(item), + ); + if (connection) return connection; + if (Date.now() >= deadline) { + throw new Error(`${providerName} sign-in timed out. Try again.`); + } + await sleep(NANGO_CONNECTION_POLL_MS, signal); + } +} + +async function cancelNangoConnectionIfRequested(signal?: AbortSignal) { + throwIfConnectionCancelled(signal); +} + +function sleep(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (!signal) { + setTimeout(resolve, ms); + return; + } + if (signal.aborted) { + reject(connectionCancellationError(signal)); + return; + } + + const onAbort = () => { + clearTimeout(timer); + reject(connectionCancellationError(signal)); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +async function syncNangoMeetings( + provider: Pick, + connectionId: string, + headers: Record, +): Promise { + const knownMeetingIds = await getImportedMeetingIds(provider.id); + const client = createClient({ baseUrl: env.VITE_API_URL, headers }); + const body = { + connection_id: connectionId, + known_meeting_ids: knownMeetingIds, + }; + const { data, error } = await nangoImportMeetings(provider.id, { + client, + body, + }); + if (error || !data) { + throw new Error(`Reconnect ${provider.name} to keep importing`); + } + + const result = await importConnectedMeetings(provider.id, data.files); + return { result, warnings: data.warnings }; +} + async function syncConnectedMeetings( provider: Pick, ): Promise { @@ -203,5 +375,32 @@ async function writeConnectedImportCredentials( } function connectedImportSecretKey(providerId: string) { - return providerId === "granola" ? "granola-mcp" : `${providerId}-mcp`; + if (providerId === "granola") return "granola-mcp"; + if (providerId === "plaud") return "plaud-cli"; + return `${providerId}-mcp`; +} + +function nangoImportMeetings( + providerId: string, + options: { + client: ReturnType; + body: { connection_id: string; known_meeting_ids: string[] }; + }, +) { + switch (providerId) { + case "fathom": + return fathomImportMeetings(options); + case "google-meet": + return googleMeetImportMeetings(options); + case "microsoft-teams": + return microsoftTeamsImportMeetings(options); + case "notion": + return notionImportMeetings(options); + case "webex": + return webexImportMeetings(options); + case "zoom": + return zoomImportMeetings(options); + default: + throw new Error(`${providerId} import is not available`); + } } diff --git a/apps/desktop/src/imports/detection.test.ts b/apps/desktop/src/imports/detection.test.ts index 908f23e61b..69a18c3d6f 100644 --- a/apps/desktop/src/imports/detection.test.ts +++ b/apps/desktop/src/imports/detection.test.ts @@ -37,8 +37,12 @@ describe("import source detection", () => { expect(listInstalledApplications).toHaveBeenCalledOnce(); expect(getInstalledApplicationIcons).toHaveBeenCalledWith([ "com.granola.app", + "google-meet", + ]); + expect(result.map((provider) => provider.id)).toEqual([ + "granola", + "google-meet", ]); - expect(result.map((provider) => provider.id)).toEqual(["granola"]); expect(result[0]?.iconUrl).toBe("data:image/png;base64,granola"); }); }); diff --git a/apps/desktop/src/imports/providers.test.ts b/apps/desktop/src/imports/providers.test.ts index d3e2d80fb9..44877fa31b 100644 --- a/apps/desktop/src/imports/providers.test.ts +++ b/apps/desktop/src/imports/providers.test.ts @@ -13,7 +13,7 @@ describe("meeting import providers", () => { ).toBe(MEETING_IMPORT_PROVIDERS.length); }); - it("enables direct OAuth imports for every provider with a public MCP server", () => { + it("enables direct OAuth imports for MCP providers and Nango meeting sources", () => { expect( MEETING_IMPORT_PROVIDERS.filter((provider) => provider.directImport).map( (provider) => provider.id, @@ -23,10 +23,40 @@ describe("meeting import providers", () => { "circleback", "fireflies", "krisp", + "fathom", "read-ai", + "notion", "fellow", "tactiq", "jiminny", + "plaud", + "zoom", + "microsoft-teams", + "google-meet", + "webex", + ]); + expect( + MEETING_IMPORT_PROVIDERS.find((provider) => provider.id === "plaud"), + ).toMatchObject({ + directImport: "cli", + }); + expect( + MEETING_IMPORT_PROVIDERS.find((provider) => provider.id === "zoom"), + ).toMatchObject({ + directImport: "nango-oauth", + nangoIntegrationId: "zoom", + }); + expect( + MEETING_IMPORT_PROVIDERS.filter( + (provider) => provider.directImport === "nango-oauth", + ).map((provider) => provider.nangoIntegrationId), + ).toEqual([ + "fathom", + "notion", + "zoom", + "microsoft-teams", + "google-meet", + "webex", ]); }); @@ -39,10 +69,12 @@ describe("meeting import providers", () => { expect(providers.map((provider) => provider.id)).toEqual([ "granola", "microsoft-teams", + "google-meet", ]); expect(providers.map((provider) => provider.installedAppId)).toEqual([ "com.granola.app", "com.microsoft.teams2", + "google-meet", ]); }); @@ -50,15 +82,15 @@ describe("meeting import providers", () => { expect( detectMeetingImportProviders([ { id: "com.granola.app.helper", name: "Something Else" }, - ]), - ).toEqual([]); + ]).map((provider) => provider.id), + ).toEqual(["google-meet"]); }); it("does not infer extension-only products from a browser", () => { expect( detectMeetingImportProviders([ { id: "com.google.Chrome", name: "Google Chrome" }, - ]), - ).toEqual([]); + ]).map((provider) => provider.id), + ).toEqual(["google-meet"]); }); }); diff --git a/apps/desktop/src/imports/providers.ts b/apps/desktop/src/imports/providers.ts index ba87ce43cc..2ece2be374 100644 --- a/apps/desktop/src/imports/providers.ts +++ b/apps/desktop/src/imports/providers.ts @@ -5,9 +5,11 @@ export type MeetingImportProvider = { name: string; access: "API" | "CLI" | "Export" | "MCP" | "OAuth" | "Webhook"; helpUrl: string; - directImport?: "mcp-oauth"; + directImport?: "cli" | "mcp-oauth" | "nango-oauth"; + nangoIntegrationId?: string; nativeNames?: string[]; bundleIds?: string[]; + alwaysAvailable?: boolean; }; export type DetectedMeetingImportProvider = MeetingImportProvider & { @@ -56,6 +58,8 @@ export const MEETING_IMPORT_PROVIDERS: MeetingImportProvider[] = [ name: "Fathom", access: "OAuth", helpUrl: "https://developers.fathom.ai/sdks/oauth", + directImport: "nango-oauth", + nangoIntegrationId: "fathom", nativeNames: ["Fathom"], bundleIds: ["Fathom"], }, @@ -73,6 +77,8 @@ export const MEETING_IMPORT_PROVIDERS: MeetingImportProvider[] = [ name: "Notion AI Meeting Notes", access: "OAuth", helpUrl: "https://developers.notion.com/reference/query-meeting-notes", + directImport: "nango-oauth", + nangoIntegrationId: "notion", nativeNames: ["Notion"], bundleIds: ["notion.id", "notion"], }, @@ -161,6 +167,7 @@ export const MEETING_IMPORT_PROVIDERS: MeetingImportProvider[] = [ access: "CLI", helpUrl: "https://support.plaud.ai/hc/en-us/articles/57751026815257-Plaud-CLI", + directImport: "cli", nativeNames: ["Plaud"], bundleIds: ["ai.plaud.desktop.plaud"], }, @@ -169,6 +176,8 @@ export const MEETING_IMPORT_PROVIDERS: MeetingImportProvider[] = [ name: "Zoom", access: "OAuth", helpUrl: "https://developers.zoom.us/docs/api/meetings/", + directImport: "nango-oauth", + nangoIntegrationId: "zoom", nativeNames: ["zoom.us", "Zoom", "Zoom Workplace"], bundleIds: ["us.zoom.xos"], }, @@ -178,6 +187,8 @@ export const MEETING_IMPORT_PROVIDERS: MeetingImportProvider[] = [ access: "OAuth", helpUrl: "https://learn.microsoft.com/en-us/graph/api/onlinemeeting-list-transcripts?view=graph-rest-1.0", + directImport: "nango-oauth", + nangoIntegrationId: "microsoft-teams", nativeNames: ["Microsoft Teams", "Microsoft Teams (work or school)"], bundleIds: ["com.microsoft.teams", "com.microsoft.teams2"], }, @@ -187,6 +198,10 @@ export const MEETING_IMPORT_PROVIDERS: MeetingImportProvider[] = [ access: "OAuth", helpUrl: "https://developers.google.com/workspace/meet/api/guides/artifacts", + directImport: "nango-oauth", + nangoIntegrationId: "google-meet", + nativeNames: ["Google Meet"], + alwaysAvailable: true, }, { id: "webex", @@ -194,6 +209,8 @@ export const MEETING_IMPORT_PROVIDERS: MeetingImportProvider[] = [ access: "OAuth", helpUrl: "https://developer.webex.com/meeting/docs/api/v1/meeting-transcripts", + directImport: "nango-oauth", + nangoIntegrationId: "webex", nativeNames: ["Webex", "Cisco Webex Meetings"], bundleIds: ["com.cisco.webex", "com.webex"], }, @@ -284,6 +301,8 @@ export function detectMeetingImportProviders( return installedApp ? [{ ...provider, installedAppId: installedApp.id }] - : []; + : provider.alwaysAvailable + ? [{ ...provider, installedAppId: provider.id }] + : []; }); } diff --git a/apps/desktop/src/imports/screen.test.tsx b/apps/desktop/src/imports/screen.test.tsx index 2c5b578506..30fe70e662 100644 --- a/apps/desktop/src/imports/screen.test.tsx +++ b/apps/desktop/src/imports/screen.test.tsx @@ -13,15 +13,32 @@ const mocks = vi.hoisted(() => ({ detectImportSources: vi.fn(), cancelConnectedImport: vi.fn(), connectConnectedImport: vi.fn(), + connectNangoImport: vi.fn(), disconnectConnectedImport: vi.fn(), + disconnectNangoImport: vi.fn(), signIn: vi.fn(), signedIn: true, + connections: [] as Array<{ + connection_id: string; + integration_id: string; + status?: string | null; + }>, })); vi.mock("~/auth", () => ({ useAuth: () => ({ - session: mocks.signedIn ? {} : null, + session: mocks.signedIn ? { user: { id: "user-1" } } : null, signIn: mocks.signIn, + getHeaders: () => + mocks.signedIn ? { Authorization: "Bearer test" } : null, + }), +})); + +vi.mock("~/auth/useConnections", () => ({ + useConnections: () => ({ + data: mocks.connections, + error: null, + isPending: false, }), })); @@ -39,7 +56,18 @@ vi.mock("./queries", () => ({ vi.mock("./connected-import", () => ({ cancelConnectedImport: mocks.cancelConnectedImport, connectConnectedImport: mocks.connectConnectedImport, + connectNangoImport: mocks.connectNangoImport, disconnectConnectedImport: mocks.disconnectConnectedImport, + disconnectNangoImport: mocks.disconnectNangoImport, + isDirectMeetingImport: (provider: { directImport?: string }) => + Boolean(provider.directImport), + isNangoMeetingImport: (provider: { directImport?: string }) => + provider.directImport === "nango-oauth", + isLocalConnectedImport: (provider: { directImport?: string }) => + provider.directImport === "mcp-oauth" || provider.directImport === "cli", + nangoConnectionIsReady: ( + connection: { status?: string | null } | undefined, + ) => Boolean(connection) && connection?.status !== "reconnect_required", connectedImportCredentialsQueryKey: (providerId: string) => [ "meeting-import", providerId, @@ -73,6 +101,26 @@ vi.mock("./connected-import", () => ({ enabled, retry: false, }), + nangoImportSyncQueryOptions: ( + provider: { id: string }, + connectionId: string | undefined, + _headers: Record | null, + enabled: boolean, + ) => ({ + queryKey: ["meeting-import", provider.id, "sync", connectionId], + queryFn: async () => ({ + result: { + discovered: 0, + imported: 0, + matched: 0, + conflicts: 0, + errors: 0, + }, + warnings: [], + }), + enabled, + retry: false, + }), })); vi.mock("./termination-pause", () => ({ @@ -116,7 +164,12 @@ describe("MeetingImportScreen", () => { beforeEach(() => { vi.clearAllMocks(); mocks.signedIn = true; + mocks.connections = []; mocks.cancelConnectedImport.mockResolvedValue(true); + mocks.connectNangoImport.mockResolvedValue({ + connection_id: "zoom-1", + integration_id: "zoom", + }); mocks.signIn.mockResolvedValue(undefined); }); @@ -148,17 +201,17 @@ describe("MeetingImportScreen", () => { expect(screen.queryByText("Export help")).toBeNull(); expect( screen.getAllByRole("button", { name: "Connect & import" }), - ).toHaveLength(2); + ).toHaveLength(3); expect(screen.getAllByRole("button", { name: "Use files" })).toHaveLength( - 2, + 3, ); expect(screen.queryByRole("menuitem", { name: "Use files" })).toBeNull(); expect( screen.getAllByRole("button", { name: "Choose files" }), - ).toHaveLength(3); + ).toHaveLength(2); expect( screen.getAllByText(/keep new meetings coming in while you switch/i), - ).toHaveLength(2); + ).toHaveLength(3); expect( container.querySelectorAll('img[src^="data:image/png;base64,"]'), ).toHaveLength(5); @@ -265,6 +318,55 @@ describe("MeetingImportScreen", () => { }); }); + it("connects Zoom through Nango OAuth instead of file-only import", async () => { + mockDetected(["zoom"]); + + renderImports(); + + fireEvent.click( + await screen.findByRole("button", { name: "Connect & import" }), + ); + + await waitFor(() => { + expect(mocks.connectNangoImport).toHaveBeenCalledOnce(); + }); + expect(mocks.connectConnectedImport).not.toHaveBeenCalled(); + expect( + screen.getByText(/keep new meetings coming in while you switch/i), + ).toBeTruthy(); + expect( + screen.queryByText(/Direct connection is not available yet/i), + ).toBeNull(); + }); + + it("connects Plaud by running the local CLI instead of file-only import", async () => { + mockDetected(["plaud"]); + mocks.connectConnectedImport.mockResolvedValue({ + providerId: "plaud", + clientId: "ada@example.com", + tokenJson: "{}", + }); + + renderImports(); + + fireEvent.click( + await screen.findByRole("button", { name: "Connect & import" }), + ); + + await waitFor(() => { + expect(mocks.connectConnectedImport).toHaveBeenCalledOnce(); + }); + expect(mocks.connectNangoImport).not.toHaveBeenCalled(); + expect( + screen.getByText( + /Connected ยท New meetings are imported automatically while Anarlog is running/i, + ), + ).toBeTruthy(); + expect( + screen.queryByText(/Direct connection is not available yet/i), + ).toBeNull(); + }); + it("shows the empty state when nothing is detected", async () => { mockDetected([]); diff --git a/apps/desktop/src/imports/screen.tsx b/apps/desktop/src/imports/screen.tsx index 351231731d..61c10005f2 100644 --- a/apps/desktop/src/imports/screen.tsx +++ b/apps/desktop/src/imports/screen.tsx @@ -30,11 +30,18 @@ import { cn } from "@anlg/utils"; import { cancelConnectedImport, connectConnectedImport, + connectNangoImport, connectedImportCredentialsQueryKey, connectedImportCredentialsQueryOptions, connectedImportSyncQueryKey, connectedImportSyncQueryOptions, disconnectConnectedImport, + disconnectNangoImport, + isDirectMeetingImport, + isLocalConnectedImport, + isNangoMeetingImport, + nangoConnectionIsReady, + nangoImportSyncQueryOptions, } from "./connected-import"; import { detectImportSources } from "./detection"; import type { @@ -49,6 +56,7 @@ import { import { pauseCompetingApplicationTermination } from "./termination-pause"; import { useAuth } from "~/auth"; +import { useConnections } from "~/auth/useConnections"; import { useMountEffect } from "~/shared/hooks/useMountEffect"; const IMPORT_EXTENSIONS = [ @@ -98,6 +106,8 @@ export function MeetingImportScreen({ const queryClient = useQueryClient(); const connectAbortController = useRef(null); const signedIn = Boolean(auth.session); + const headers = auth.getHeaders(); + const connectionsQuery = useConnections(signedIn); const detectionQuery = useQuery({ queryKey: ["meeting-import-sources"], queryFn: detectImportSources, @@ -107,13 +117,15 @@ export function MeetingImportScreen({ const historyQuery = useMeetingImportHistory(); const history = historyQuery.data ?? EMPTY_MEETING_IMPORT_HISTORY; const detectedProviders = detectionQuery.data ?? []; - const directProviders = detectedProviders - .filter((provider) => provider.directImport === "mcp-oauth") + const connectedProviders = detectedProviders + .filter((provider) => isDirectMeetingImport(provider)) .sort((left, right) => left.name.localeCompare(right.name)); + const mcpProviders = connectedProviders.filter(isLocalConnectedImport); + const nangoProviders = connectedProviders.filter(isNangoMeetingImport); const fileProviders = detectedProviders .filter((provider) => !provider.directImport) .sort((left, right) => left.name.localeCompare(right.name)); - const displayedProviders = [...directProviders, ...fileProviders]; + const displayedProviders = [...connectedProviders, ...fileProviders]; const detectionSettled = !detectionQuery.isLoading && !detectionQuery.error; useEffect(() => { @@ -133,22 +145,38 @@ export function MeetingImportScreen({ onNoSourcesDetected, ]); - const connectedProviders = directProviders; + const connectedProvidersForQueries = mcpProviders; const credentialQueries = useQueries({ - queries: connectedProviders.map((provider) => + queries: connectedProvidersForQueries.map((provider) => connectedImportCredentialsQueryOptions(provider.id), ), }); const syncQueries = useQueries({ - queries: connectedProviders.map((provider, index) => + queries: connectedProvidersForQueries.map((provider, index) => connectedImportSyncQueryOptions( provider, signedIn && Boolean(credentialQueries[index]?.data), ), ), }); + const nangoSyncQueries = useQueries({ + queries: nangoProviders.map((provider) => { + const connection = connectionsQuery.data?.find( + (item) => item.integration_id === provider.nangoIntegrationId, + ); + return nangoImportSyncQueryOptions( + provider, + connection?.connection_id, + headers, + signedIn && nangoConnectionIsReady(connection), + ); + }), + }); const connectedProviderIndexes = new Map( - connectedProviders.map((provider, index) => [provider.id, index]), + connectedProvidersForQueries.map((provider, index) => [provider.id, index]), + ); + const nangoProviderIndexes = new Map( + nangoProviders.map((provider, index) => [provider.id, index]), ); const fileImportMutation = useMutation({ @@ -182,6 +210,17 @@ export function MeetingImportScreen({ const controller = new AbortController(); connectAbortController.current = controller; try { + if (isNangoMeetingImport(provider)) { + const sessionHeaders = auth.getHeaders(); + if (!sessionHeaders) { + throw new Error("No authentication session is available"); + } + return await connectNangoImport( + provider, + sessionHeaders, + controller.signal, + ); + } return await connectConnectedImport(provider, controller.signal); } catch (error) { if (controller.signal.aborted) return null; @@ -192,11 +231,17 @@ export function MeetingImportScreen({ } } }, - onSuccess: (credentials) => { - if (!credentials) return; + onSuccess: async (result) => { + if (!result) return; + if ("connection_id" in result) { + await queryClient.invalidateQueries({ + queryKey: ["integration-status"], + }); + return; + } queryClient.setQueryData( - connectedImportCredentialsQueryKey(credentials.providerId), - credentials, + connectedImportCredentialsQueryKey(result.providerId), + result, ); }, }); @@ -210,31 +255,59 @@ export function MeetingImportScreen({ }); const disconnectMutation = useMutation({ - mutationFn: (providerId: string) => disconnectConnectedImport(providerId), - onSuccess: async (_, providerId) => { + mutationFn: async (input: { + providerId: string; + nangoIntegrationId?: string; + connectionId?: string; + }) => { + if (input.nangoIntegrationId && input.connectionId) { + await disconnectNangoImport( + input.nangoIntegrationId, + input.connectionId, + ); + return; + } + await disconnectConnectedImport(input.providerId); + }, + onSuccess: async (_, input) => { + if (input.nangoIntegrationId) { + await queryClient.invalidateQueries({ + queryKey: ["integration-status"], + }); + await queryClient.cancelQueries({ + queryKey: connectedImportSyncQueryKey(input.providerId), + }); + queryClient.removeQueries({ + queryKey: connectedImportSyncQueryKey(input.providerId), + }); + return; + } queryClient.setQueryData( - connectedImportCredentialsQueryKey(providerId), + connectedImportCredentialsQueryKey(input.providerId), null, ); await queryClient.cancelQueries({ - queryKey: connectedImportSyncQueryKey(providerId), + queryKey: connectedImportSyncQueryKey(input.providerId), }); queryClient.removeQueries({ - queryKey: connectedImportSyncQueryKey(providerId), + queryKey: connectedImportSyncQueryKey(input.providerId), }); }, }); const connectedError = credentialQueries.find((query) => query.error)?.error ?? + connectionsQuery.error ?? signInMutation.error ?? connectMutation.error ?? cancelConnectMutation.error ?? disconnectMutation.error ?? - syncQueries.find((query) => query.error)?.error; + syncQueries.find((query) => query.error)?.error ?? + nangoSyncQueries.find((query) => query.error)?.error; const latestResult = fileImportMutation.data ?? syncQueries.find((query) => query.data)?.data?.result ?? + nangoSyncQueries.find((query) => query.data)?.data?.result ?? null; return ( @@ -277,6 +350,7 @@ export function MeetingImportScreen({ ) : null} {syncQueries .flatMap((query) => query.data?.warnings ?? []) + .concat(nangoSyncQueries.flatMap((query) => query.data?.warnings ?? [])) .map((warning) => (

{warning} @@ -299,17 +373,33 @@ export function MeetingImportScreen({ const importing = fileImportMutation.isPending && fileImportMutation.variables.id === provider.id; - const connectedProvider = provider.directImport === "mcp-oauth"; + const connectedProvider = isDirectMeetingImport(provider); + const nangoProvider = isNangoMeetingImport(provider); const connectedIndex = connectedProviderIndexes.get(provider.id); + const nangoIndex = nangoProviderIndexes.get(provider.id); const credentialsQuery = connectedIndex === undefined ? undefined : credentialQueries[connectedIndex]; - const syncQuery = - connectedIndex === undefined + const nangoConnection = nangoProvider + ? connectionsQuery.data?.find( + (item) => + item.integration_id === provider.nangoIntegrationId, + ) + : undefined; + const syncQuery = nangoProvider + ? nangoIndex === undefined + ? undefined + : nangoSyncQueries[nangoIndex] + : connectedIndex === undefined ? undefined : syncQueries[connectedIndex]; - const connected = signedIn && Boolean(credentialsQuery?.data); + const connected = nangoProvider + ? signedIn && nangoConnectionIsReady(nangoConnection) + : signedIn && Boolean(credentialsQuery?.data); + const checkingConnection = nangoProvider + ? signedIn && connectionsQuery.isPending + : Boolean(credentialsQuery?.isPending); const connecting = connectMutation.isPending && connectMutation.variables.id === provider.id; @@ -321,7 +411,7 @@ export function MeetingImportScreen({ cancelConnectMutation.variables === provider.id; const disconnecting = disconnectMutation.isPending && - disconnectMutation.variables === provider.id; + disconnectMutation.variables?.providerId === provider.id; const lastRun = history.find( (run) => run.providerId === provider.id, ); @@ -397,7 +487,13 @@ export function MeetingImportScreen({ variant="ghost" disabled={syncQuery?.isFetching || disconnecting} onClick={() => - disconnectMutation.mutate(provider.id) + disconnectMutation.mutate({ + providerId: provider.id, + nangoIntegrationId: nangoProvider + ? provider.nangoIntegrationId + : undefined, + connectionId: nangoConnection?.connection_id, + }) } > Disconnect @@ -414,7 +510,7 @@ export function MeetingImportScreen({ } disabled={ signedIn - ? credentialsQuery?.isPending || + ? checkingConnection || cancelConnectMutation.isPending || connectionCancellationRequested || (connectMutation.isPending && !connecting) @@ -431,7 +527,9 @@ export function MeetingImportScreen({ } if (connecting) { connectAbortController.current?.abort(); - cancelConnectMutation.mutate(provider.id); + if (!nangoProvider) { + cancelConnectMutation.mutate(provider.id); + } return; } connectMutation.mutate(provider); @@ -457,7 +555,7 @@ export function MeetingImportScreen({ ) - ) : credentialsQuery?.isPending || + ) : checkingConnection || connecting || cancellingConnection ? ( @@ -467,7 +565,7 @@ export function MeetingImportScreen({ {!signedIn ? null : connecting || cancellingConnection ? ( Cancel - ) : credentialsQuery?.isPending ? ( + ) : checkingConnection ? ( Checking connection ) : ( Connect & import diff --git a/apps/desktop/src/services/meeting-import-sync.test.tsx b/apps/desktop/src/services/meeting-import-sync.test.tsx index e3e7057827..071ab31d03 100644 --- a/apps/desktop/src/services/meeting-import-sync.test.tsx +++ b/apps/desktop/src/services/meeting-import-sync.test.tsx @@ -3,11 +3,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ signedIn: false, + connections: [] as Array<{ + connection_id: string; + integration_id: string; + status?: string | null; + }>, connectedImportSyncQueryOptions: vi.fn(), + nangoImportSyncQueryOptions: vi.fn(), })); vi.mock("~/auth", () => ({ - useAuth: () => ({ session: mocks.signedIn ? {} : null }), + useAuth: () => ({ + session: mocks.signedIn ? { user: { id: "user-1" } } : null, + getHeaders: () => + mocks.signedIn ? { Authorization: "Bearer test" } : null, + }), +})); + +vi.mock("~/auth/useConnections", () => ({ + useConnections: () => ({ + data: mocks.connections, + error: null, + isPending: false, + }), })); vi.mock("~/imports/connected-import", () => ({ @@ -18,6 +36,20 @@ vi.mock("~/imports/connected-import", () => ({ provider: { id: string }, enabled: boolean, ) => mocks.connectedImportSyncQueryOptions(provider, enabled), + isNangoMeetingImport: (provider: { directImport?: string }) => + provider.directImport === "nango-oauth", + isLocalConnectedImport: (provider: { directImport?: string }) => + provider.directImport === "mcp-oauth" || provider.directImport === "cli", + nangoConnectionIsReady: ( + connection: { status?: string | null } | undefined, + ) => Boolean(connection) && connection?.status !== "reconnect_required", + nangoImportSyncQueryOptions: ( + provider: { id: string }, + connectionId: string | undefined, + headers: Record | null, + enabled: boolean, + ) => + mocks.nangoImportSyncQueryOptions(provider, connectionId, headers, enabled), })); vi.mock("@tanstack/react-query", () => ({ @@ -33,11 +65,22 @@ describe("MeetingImportSync", () => { beforeEach(() => { vi.clearAllMocks(); mocks.signedIn = false; + mocks.connections = []; mocks.connectedImportSyncQueryOptions.mockImplementation( (provider: { id: string }, enabled: boolean) => ({ queryKey: ["sync", provider.id, String(enabled)], }), ); + mocks.nangoImportSyncQueryOptions.mockImplementation( + ( + provider: { id: string }, + connectionId: string | undefined, + _headers: Record | null, + enabled: boolean, + ) => ({ + queryKey: ["nango-sync", provider.id, connectionId, String(enabled)], + }), + ); }); afterEach(cleanup); @@ -51,6 +94,11 @@ describe("MeetingImportSync", () => { ([, enabled]) => enabled === false, ), ).toBe(true); + expect( + mocks.nangoImportSyncQueryOptions.mock.calls.every( + ([, , , enabled]) => enabled === false, + ), + ).toBe(true); }); it("enables connected imports after sign-in", () => { @@ -64,5 +112,30 @@ describe("MeetingImportSync", () => { ([, enabled]) => enabled === true, ), ).toBe(true); + expect( + mocks.nangoImportSyncQueryOptions.mock.calls.every( + ([, , , enabled]) => enabled === false, + ), + ).toBe(true); + }); + + it("syncs Zoom after a Nango connection is ready", () => { + mocks.signedIn = true; + mocks.connections = [ + { + connection_id: "zoom-1", + integration_id: "zoom", + status: "ok", + }, + ]; + + render(); + + expect(mocks.nangoImportSyncQueryOptions).toHaveBeenCalledWith( + expect.objectContaining({ id: "zoom" }), + "zoom-1", + { Authorization: "Bearer test" }, + true, + ); }); }); diff --git a/apps/desktop/src/services/meeting-import-sync.tsx b/apps/desktop/src/services/meeting-import-sync.tsx index 8832cf7215..d3604e818c 100644 --- a/apps/desktop/src/services/meeting-import-sync.tsx +++ b/apps/desktop/src/services/meeting-import-sync.tsx @@ -1,32 +1,53 @@ import { useQueries } from "@tanstack/react-query"; import { useAuth } from "~/auth"; +import { useConnections } from "~/auth/useConnections"; import { connectedImportCredentialsQueryOptions, connectedImportSyncQueryOptions, + isLocalConnectedImport, + isNangoMeetingImport, + nangoConnectionIsReady, + nangoImportSyncQueryOptions, } from "~/imports/connected-import"; import { MEETING_IMPORT_PROVIDERS } from "~/imports/providers"; -const CONNECTED_PROVIDERS = MEETING_IMPORT_PROVIDERS.filter( - (provider) => provider.directImport === "mcp-oauth", +const LOCAL_CONNECTED_PROVIDERS = MEETING_IMPORT_PROVIDERS.filter( + isLocalConnectedImport, ); +const NANGO_PROVIDERS = MEETING_IMPORT_PROVIDERS.filter(isNangoMeetingImport); export function MeetingImportSync() { const auth = useAuth(); const signedIn = Boolean(auth.session); + const headers = auth.getHeaders(); + const connectionsQuery = useConnections(signedIn); const credentialQueries = useQueries({ - queries: CONNECTED_PROVIDERS.map((provider) => + queries: LOCAL_CONNECTED_PROVIDERS.map((provider) => connectedImportCredentialsQueryOptions(provider.id), ), }); useQueries({ - queries: CONNECTED_PROVIDERS.map((provider, index) => + queries: LOCAL_CONNECTED_PROVIDERS.map((provider, index) => connectedImportSyncQueryOptions( provider, signedIn && Boolean(credentialQueries[index]?.data), ), ), }); + useQueries({ + queries: NANGO_PROVIDERS.map((provider) => { + const connection = connectionsQuery.data?.find( + (item) => item.integration_id === provider.nangoIntegrationId, + ); + return nangoImportSyncQueryOptions( + provider, + connection?.connection_id, + headers, + signedIn && nangoConnectionIsReady(connection), + ); + }), + }); return null; } diff --git a/apps/web/src/routes/_view/app/-account-integrations.tsx b/apps/web/src/routes/_view/app/-account-integrations.tsx index ed87dfa339..aa666cd843 100644 --- a/apps/web/src/routes/_view/app/-account-integrations.tsx +++ b/apps/web/src/routes/_view/app/-account-integrations.tsx @@ -23,6 +23,11 @@ const INTEGRATION_NAMES: Record = { github: "GitHub", slack: "Slack", notion: "Notion", + zoom: "Zoom", + fathom: "Fathom", + webex: "Webex", + "google-meet": "Google Meet", + "microsoft-teams": "Microsoft Teams", }; const INTEGRATION_ICONS: Record = { @@ -34,6 +39,13 @@ const INTEGRATION_ICONS: Record = { github: , slack: , notion: , + zoom: , + fathom: , + webex: , + "google-meet": , + "microsoft-teams": ( + + ), }; const connectionsQueryKey = ["account-integrations"]; diff --git a/apps/web/src/routes/_view/app/integration.tsx b/apps/web/src/routes/_view/app/integration.tsx index caba21c14e..8299e4f84b 100644 --- a/apps/web/src/routes/_view/app/integration.tsx +++ b/apps/web/src/routes/_view/app/integration.tsx @@ -62,6 +62,37 @@ export const INTEGRATION_DISPLAY: Record< description: "Connect Notion to add meeting updates to your pages", connectingHint: "Pick the Notion pages to share, then return to Anarlog", }, + zoom: { + name: "Zoom", + description: + "Review how Anarlog uses Zoom cloud recordings, then continue to Zoom", + connectingHint: "Finish authorization with Zoom, then return to Anarlog", + }, + fathom: { + name: "Fathom", + description: + "Review how Anarlog uses Fathom meeting recordings, then continue to Fathom", + connectingHint: "Finish authorization with Fathom, then return to Anarlog", + }, + webex: { + name: "Webex", + description: + "Review how Anarlog uses Webex meeting transcripts, then continue to Webex", + connectingHint: "Finish authorization with Webex, then return to Anarlog", + }, + "google-meet": { + name: "Google Meet", + description: + "Review how Anarlog uses Google Meet transcripts, then continue to Google", + connectingHint: "Finish authorization with Google, then return to Anarlog", + }, + "microsoft-teams": { + name: "Microsoft Teams", + description: + "Review how Anarlog uses Teams meeting transcripts, then continue to Microsoft", + connectingHint: + "Finish authorization with Microsoft, then return to Anarlog", + }, }; export function getIntegrationDisplay(integrationId: string) { diff --git a/crates/api-client/build.rs b/crates/api-client/build.rs index 9fbf7f83b8..e603249f5a 100644 --- a/crates/api-client/build.rs +++ b/crates/api-client/build.rs @@ -6,6 +6,12 @@ const ALLOWED_PATH_PREFIXES: &[&str] = &[ "/nango", "/subscription", "/ticket", + "/zoom", + "/fathom", + "/webex", + "/google-meet", + "/microsoft-teams", + "/notion", "/v1/cloud-api", "/v1/meetings", "/v1/sync-snapshots", diff --git a/crates/api-client/openapi.gen.json b/crates/api-client/openapi.gen.json index 472d3ac529..056e6b233d 100644 --- a/crates/api-client/openapi.gen.json +++ b/crates/api-client/openapi.gen.json @@ -1122,6 +1122,63 @@ ], "type": "object" }, + "ImportMeetingsRequest": { + "properties": { + "connection_id": { + "type": "string" + }, + "known_meeting_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "ImportMeetingsResponse": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/ImportTextFile" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "files", + "warnings" + ], + "type": "object" + }, + "ImportTextFile": { + "properties": { + "content": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "name", + "content" + ], + "type": "object" + }, "Importance": { "enum": [ "low", @@ -1552,6 +1609,149 @@ ], "type": "string" }, + "NotionAppendUpdateRequest": { + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "heading": { + "type": "string" + }, + "markdown": { + "type": "string" + }, + "page_id": { + "type": "string" + } + }, + "required": [ + "connection_id", + "page_id", + "heading", + "markdown" + ], + "type": "object" + }, + "NotionAppendUpdateResponse": { + "properties": { + "block_count": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "block_count" + ], + "type": "object" + }, + "NotionImportMeetingsRequest": { + "properties": { + "connection_id": { + "type": "string" + }, + "known_meeting_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "NotionImportMeetingsResponse": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/NotionImportTextFile" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "files", + "warnings" + ], + "type": "object" + }, + "NotionImportTextFile": { + "properties": { + "content": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "name", + "content" + ], + "type": "object" + }, + "NotionPage": { + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "id", + "title" + ], + "type": "object" + }, + "NotionPagesResponse": { + "properties": { + "pages": { + "items": { + "$ref": "#/components/schemas/NotionPage" + }, + "type": "array" + } + }, + "required": [ + "pages" + ], + "type": "object" + }, + "NotionSearchPagesRequest": { + "additionalProperties": false, + "properties": { + "connection_id": { + "type": "string" + }, + "query": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, "OfficeLocation": { "properties": { "buildingId": { @@ -2452,6 +2652,63 @@ ], "type": "string" }, + "ZoomImportMeetingsRequest": { + "properties": { + "connection_id": { + "type": "string" + }, + "known_meeting_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "connection_id" + ], + "type": "object" + }, + "ZoomImportMeetingsResponse": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/ZoomImportTextFile" + }, + "type": "array" + }, + "warnings": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "files", + "warnings" + ], + "type": "object" + }, + "ZoomImportTextFile": { + "properties": { + "content": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "path", + "name", + "content" + ], + "type": "object" + }, "google.Attendee": { "properties": { "additionalGuests": { @@ -3217,6 +3474,129 @@ ] } }, + "/fathom/import-meetings": { + "post": { + "operationId": "fathom_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsResponse" + } + } + }, + "description": "Fathom meetings fetched for import" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + "fathom" + ] + } + }, + "/google-meet/import-meetings": { + "post": { + "operationId": "google_meet_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsResponse" + } + } + }, + "description": "Google Meet meetings fetched for import" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + "google-meet" + ] + } + }, + "/microsoft-teams/import-meetings": { + "post": { + "operationId": "microsoft_teams_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsResponse" + } + } + }, + "description": "Microsoft Teams meetings fetched for import" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + "microsoft-teams" + ] + } + }, "/nango/connections": { "delete": { "operationId": "delete_connection", @@ -3388,6 +3768,132 @@ ] } }, + "/notion/append-update": { + "post": { + "operationId": "notion_append_update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotionAppendUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotionAppendUpdateResponse" + } + } + }, + "description": "Update appended to the Notion page" + }, + "400": { + "description": "Invalid update payload" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Notion connection unavailable" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + "notion" + ] + } + }, + "/notion/import-meetings": { + "post": { + "operationId": "notion_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotionImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotionImportMeetingsResponse" + } + } + }, + "description": "Notion meeting notes fetched for import" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Notion connection unavailable" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + "notion" + ] + } + }, + "/notion/search-pages": { + "post": { + "operationId": "notion_search_pages", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotionSearchPagesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotionPagesResponse" + } + } + }, + "description": "Notion pages shared with the connected integration" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Notion connection unavailable" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + "notion" + ] + } + }, "/subscription/can-start-trial": { "get": { "operationId": "can_start_trial", @@ -4210,6 +4716,88 @@ "cloud-api" ] } + }, + "/webex/import-meetings": { + "post": { + "operationId": "webex_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportMeetingsResponse" + } + } + }, + "description": "Webex meetings fetched for import" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + "webex" + ] + } + }, + "/zoom/import-meetings": { + "post": { + "operationId": "zoom_import_meetings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoomImportMeetingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ZoomImportMeetingsResponse" + } + } + }, + "description": "Zoom meetings fetched for import" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearer_auth": [] + } + ], + "tags": [ + "zoom" + ] + } } }, "tags": [ @@ -4245,6 +4833,26 @@ "description": "Ticket management", "name": "ticket" }, + { + "description": "Zoom meeting import", + "name": "zoom" + }, + { + "description": "Fathom meeting import", + "name": "fathom" + }, + { + "description": "Webex meeting import", + "name": "webex" + }, + { + "description": "Google Meet meeting import", + "name": "google-meet" + }, + { + "description": "Microsoft Teams meeting import", + "name": "microsoft-teams" + }, { "description": "Integration management via Nango", "name": "nango" diff --git a/crates/api-meeting-import/Cargo.toml b/crates/api-meeting-import/Cargo.toml new file mode 100644 index 0000000000..a4152b23bb --- /dev/null +++ b/crates/api-meeting-import/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "api-meeting-import" +version = "0.1.0" +edition = "2024" + +[dependencies] +anlg-api-auth = { workspace = true } +anlg-api-error = { workspace = true } +anlg-api-nango = { workspace = true } +anlg-meeting-import = { workspace = true } +anlg-nango = { workspace = true } + +axum = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +urlencoding = { workspace = true } +utoipa = { workspace = true } diff --git a/crates/api-meeting-import/src/error.rs b/crates/api-meeting-import/src/error.rs new file mode 100644 index 0000000000..bbded3b8c4 --- /dev/null +++ b/crates/api-meeting-import/src/error.rs @@ -0,0 +1,35 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum MeetingImportError { + #[error("Invalid request: {0}")] + BadRequest(String), + + #[error("Internal error: {0}")] + Internal(String), + + #[error(transparent)] + NangoConnection(#[from] anlg_api_nango::NangoConnectionError), +} + +impl IntoResponse for MeetingImportError { + fn into_response(self) -> Response { + let (status, code, message) = match self { + Self::BadRequest(message) => (StatusCode::BAD_REQUEST, "bad_request", message), + Self::Internal(message) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_server_error", + message, + ), + Self::NangoConnection(err) => return err.into_response(), + }; + + anlg_api_error::error_response(status, code, &message) + } +} diff --git a/crates/api-meeting-import/src/import/fathom.rs b/crates/api-meeting-import/src/import/fathom.rs new file mode 100644 index 0000000000..fe2cd73d89 --- /dev/null +++ b/crates/api-meeting-import/src/import/fathom.rs @@ -0,0 +1,83 @@ +use std::collections::HashSet; + +use anlg_meeting_import::{ + fathom::{FathomClient, FathomMeeting}, + meeting_file, meeting_has_content, +}; +use chrono::{Duration, Utc}; + +use super::{ImportResult, MAX_PAGES}; + +pub async fn import_meetings( + client: &FathomClient, + known_meeting_ids: &[String], +) -> Result { + let known = known_meeting_ids.iter().cloned().collect::>(); + let created_after = (Utc::now() - Duration::days(180)).to_rfc3339(); + let mut meetings = Vec::new(); + let mut cursor = None; + for _ in 0..MAX_PAGES { + let page = client + .list_meetings(&created_after, cursor.as_deref()) + .await + .map_err(|error| format!("could not list Fathom meetings: {error}"))?; + meetings.extend(page.items); + let next = page.next_cursor.filter(|token| !token.is_empty()); + if next.as_ref() == cursor.as_ref() { + break; + } + match next { + Some(token) => cursor = Some(token), + None => break, + } + } + + let mut files = Vec::new(); + let mut without_content = 0; + for meeting in meetings { + let Some(recording_id) = meeting.recording_id().map(ToOwned::to_owned) else { + continue; + }; + if known.contains(&recording_id) { + continue; + } + let imported = import_one(client, &meeting).await?; + if !meeting_has_content(&imported) { + without_content += 1; + continue; + } + files.push(meeting_file("fathom", &imported)?); + } + + let mut warnings = Vec::new(); + if files.is_empty() && without_content == 0 { + warnings + .push("Fathom did not return any accessible meetings for this account.".to_string()); + } + if without_content > 0 { + warnings.push(format!( + "{without_content} Fathom meetings did not include notes or transcripts." + )); + } + Ok(ImportResult { files, warnings }) +} + +async fn import_one( + client: &FathomClient, + meeting: &FathomMeeting, +) -> Result { + let recording_id = meeting + .recording_id() + .ok_or_else(|| "Fathom meeting is missing a recording id".to_string())?; + let summary = client + .get_summary(recording_id) + .await + .map_err(|error| format!("could not read a Fathom summary: {error}"))?; + let transcript = client + .get_transcript(recording_id) + .await + .map_err(|error| format!("could not read a Fathom transcript: {error}"))?; + meeting + .imported(summary, transcript) + .ok_or_else(|| "Fathom meeting is missing a recording id".to_string()) +} diff --git a/crates/api-meeting-import/src/import/google_meet.rs b/crates/api-meeting-import/src/import/google_meet.rs new file mode 100644 index 0000000000..906573c51f --- /dev/null +++ b/crates/api-meeting-import/src/import/google_meet.rs @@ -0,0 +1,185 @@ +use std::collections::{HashMap, HashSet}; + +use anlg_meeting_import::{ + TranscriptSegment, + google_meet::{ + ConferenceRecord, GoogleMeetClient, Participant, conference_origin_ms, entry_segment, + }, + meeting_file, meeting_has_content, nonempty, +}; + +use super::{ImportResult, MAX_PAGES}; + +pub async fn import_meetings( + client: &GoogleMeetClient, + known_meeting_ids: &[String], +) -> Result { + let known = known_meeting_ids.iter().cloned().collect::>(); + let mut records = Vec::new(); + let mut page_token = None; + for _ in 0..MAX_PAGES { + let page = client + .list_conference_records(page_token.as_deref()) + .await + .map_err(|error| format!("could not list Google Meet records: {error}"))?; + records.extend(page.conference_records); + let next = page.next_page_token.filter(|token| !token.is_empty()); + if next.as_ref() == page_token.as_ref() { + break; + } + match next { + Some(token) => page_token = Some(token), + None => break, + } + } + + let mut files = Vec::new(); + let mut without_content = 0; + for record in records { + let Some(name) = record.resource_name().map(ToOwned::to_owned) else { + continue; + }; + if known.contains(&name) { + continue; + } + let imported = import_one(client, &record).await?; + if !meeting_has_content(&imported) { + without_content += 1; + continue; + } + files.push(meeting_file("google-meet", &imported)?); + } + + let mut warnings = Vec::new(); + if files.is_empty() && without_content == 0 { + warnings.push( + "Google Meet did not return any conference transcripts. Transcripts are available for about 30 days after a meeting ends.".to_string(), + ); + } + if without_content > 0 { + warnings.push(format!( + "{without_content} Google Meet conferences did not include transcripts for this account or Workspace setting." + )); + } + Ok(ImportResult { files, warnings }) +} + +async fn import_one( + client: &GoogleMeetClient, + record: &ConferenceRecord, +) -> Result { + let conference_name = record + .resource_name() + .ok_or_else(|| "Google Meet conference is missing a name".to_string())?; + let title = space_title(client, record.space.as_deref()).await; + let speakers = participant_names(client, conference_name).await?; + let origin_ms = conference_origin_ms(record); + let mut transcript = Vec::new(); + let mut page_token = None; + for _ in 0..MAX_PAGES { + let page = client + .list_transcripts(conference_name, page_token.as_deref()) + .await + .map_err(|error| format!("could not list Google Meet transcripts: {error}"))?; + for item in page.transcripts { + let Some(name) = nonempty(item.name.as_deref()) else { + continue; + }; + transcript.extend(transcript_entries(client, &name, origin_ms, &speakers).await?); + } + let next = page.next_page_token.filter(|token| !token.is_empty()); + if next.as_ref() == page_token.as_ref() { + break; + } + match next { + Some(token) => page_token = Some(token), + None => break, + } + } + record + .imported(title, transcript) + .ok_or_else(|| "Google Meet conference is missing a name".to_string()) +} + +async fn transcript_entries( + client: &GoogleMeetClient, + transcript_name: &str, + origin_ms: u64, + speakers: &HashMap, +) -> Result, String> { + let mut entries = Vec::new(); + let mut page_token = None; + for _ in 0..MAX_PAGES { + let page = client + .list_entries(transcript_name, page_token.as_deref()) + .await + .map_err(|error| format!("could not list Google Meet transcript entries: {error}"))?; + entries.extend(page.transcript_entries.into_iter().filter_map(|entry| { + let speaker = entry + .participant + .as_deref() + .and_then(|name| speakers.get(name).cloned()) + .unwrap_or_default(); + entry_segment(&entry, origin_ms, speaker) + })); + let next = page.next_page_token.filter(|token| !token.is_empty()); + if next.as_ref() == page_token.as_ref() { + break; + } + match next { + Some(token) => page_token = Some(token), + None => break, + } + } + Ok(entries) +} + +async fn participant_names( + client: &GoogleMeetClient, + conference_name: &str, +) -> Result, String> { + let mut names = HashMap::new(); + let mut page_token = None; + for _ in 0..MAX_PAGES { + let page = client + .list_participants(conference_name, page_token.as_deref()) + .await + .map_err(|error| format!("could not list Google Meet participants: {error}"))?; + for participant in page.participants { + remember_participant(&mut names, participant); + } + let next = page.next_page_token.filter(|token| !token.is_empty()); + if next.as_ref() == page_token.as_ref() { + break; + } + match next { + Some(token) => page_token = Some(token), + None => break, + } + } + Ok(names) +} + +fn remember_participant(names: &mut HashMap, participant: Participant) { + let Some(name) = nonempty(participant.name.as_deref()) else { + return; + }; + if let Some(display_name) = participant.display_name() { + names.insert(name, display_name); + } +} + +async fn space_title( + client: &GoogleMeetClient, + space_name: Option<&str>, +) -> String { + let Some(space_name) = space_name.map(str::trim).filter(|value| !value.is_empty()) else { + return "Google Meet".to_string(); + }; + match client.get_space(space_name).await { + Ok(Some(space)) => nonempty(space.display_name.as_deref()) + .or_else(|| nonempty(space.meeting_code.as_deref())) + .unwrap_or_else(|| "Google Meet".to_string()), + _ => "Google Meet".to_string(), + } +} diff --git a/crates/api-meeting-import/src/import/mod.rs b/crates/api-meeting-import/src/import/mod.rs new file mode 100644 index 0000000000..19a46c0888 --- /dev/null +++ b/crates/api-meeting-import/src/import/mod.rs @@ -0,0 +1,62 @@ +use anlg_meeting_import::parse_vtt; +use anlg_nango::OwnedNangoProxy; +use url::Url; + +pub mod fathom; +pub mod google_meet; +pub mod teams; +pub mod webex; + +pub use anlg_meeting_import::ImportFile; + +pub struct ImportResult { + pub files: Vec, + pub warnings: Vec, +} + +const MAX_PAGES: usize = 20; +const LOOKBACK_WINDOWS: i64 = 6; +const WINDOW_DAYS: i64 = 29; + +fn recording_windows() -> Vec<(chrono::NaiveDate, chrono::NaiveDate)> { + let mut end = chrono::Utc::now().date_naive(); + let mut windows = Vec::with_capacity(LOOKBACK_WINDOWS as usize); + for _ in 0..LOOKBACK_WINDOWS { + let start = end - chrono::Duration::days(WINDOW_DAYS); + windows.push((start, end)); + end = start - chrono::Duration::days(1); + } + windows +} + +async fn download_vtt( + proxy: &OwnedNangoProxy, + download_url: &str, +) -> Vec { + let Ok(url) = Url::parse(download_url) else { + return Vec::new(); + }; + let origin = format!( + "{}://{}", + url.scheme(), + url.host_str().unwrap_or("localhost") + ); + let path = match url.query() { + Some(query) => format!("{}?{query}", url.path()), + None => url.path().to_string(), + }; + let response = match proxy.clone().base_url_override(origin).get(&path) { + Ok(request) => request.send().await, + Err(_) => return Vec::new(), + }; + let Ok(response) = response else { + return Vec::new(); + }; + if !response.status().is_success() { + return Vec::new(); + } + let Ok(body) = response.text().await else { + return Vec::new(); + }; + parse_vtt(&body) +} diff --git a/crates/api-meeting-import/src/import/teams.rs b/crates/api-meeting-import/src/import/teams.rs new file mode 100644 index 0000000000..b1f1ebb77f --- /dev/null +++ b/crates/api-meeting-import/src/import/teams.rs @@ -0,0 +1,156 @@ +use std::collections::HashSet; + +use anlg_meeting_import::{ + meeting_file, meeting_has_content, + teams::{CalendarEvent, TeamsClient}, +}; +use anlg_nango::OwnedNangoProxy; + +use super::{ImportResult, MAX_PAGES, recording_windows}; + +pub async fn import_meetings( + client: &TeamsClient, + proxy: &OwnedNangoProxy, + known_meeting_ids: &[String], +) -> Result { + let known = known_meeting_ids.iter().cloned().collect::>(); + let mut events = Vec::new(); + let mut calendar_error = None; + for (start, end) in recording_windows() { + let start = format!("{start}T00:00:00Z"); + let end = format!("{end}T23:59:59Z"); + let mut next_link = None; + for _ in 0..MAX_PAGES { + match client + .list_calendar_view(&start, &end, next_link.as_deref()) + .await + { + Ok(page) => { + events.extend(page.value); + let next = page.next_link.filter(|link| !link.is_empty()); + if next.as_ref() == next_link.as_ref() { + break; + } + match next { + Some(link) => next_link = Some(link), + None => break, + } + } + Err(error) => { + calendar_error = Some(error.to_string()); + break; + } + } + } + if calendar_error.is_some() { + break; + } + } + + let mut files = Vec::new(); + let mut without_content = 0; + let mut transcript_denied = false; + for event in events { + if event.join_url().is_none() { + continue; + } + match import_one(client, proxy, &event, &known).await { + Ok(Some(imported)) => { + if !meeting_has_content(&imported) { + without_content += 1; + continue; + } + files.push(meeting_file("microsoft-teams", &imported)?); + } + Ok(None) => {} + Err(error) if error.contains("403") => transcript_denied = true, + Err(_) => without_content += 1, + } + } + + let mut warnings = Vec::new(); + if let Some(error) = calendar_error { + warnings.push(format!( + "Microsoft Teams calendar could not be read ({error}). Work or school accounts are required." + )); + } + if transcript_denied { + warnings.push( + "Microsoft Teams transcripts need admin consent for OnlineMeetingTranscript.Read.All." + .to_string(), + ); + } + if files.is_empty() && without_content == 0 && warnings.is_empty() { + warnings.push( + "Microsoft Teams did not return any meeting transcripts for this account.".to_string(), + ); + } + if without_content > 0 { + warnings.push(format!( + "{without_content} Teams meetings did not include transcripts for this account or policy." + )); + } + Ok(ImportResult { files, warnings }) +} + +async fn import_one( + client: &TeamsClient, + proxy: &OwnedNangoProxy, + event: &CalendarEvent, + known: &HashSet, +) -> Result, String> { + let Some(join_url) = event.join_url() else { + return Ok(None); + }; + let Some(meeting) = client + .find_online_meeting(join_url) + .await + .map_err(|error| format!("could not look up a Teams online meeting: {error}"))? + else { + return Ok(None); + }; + let Some(meeting_id) = meeting.id.clone().filter(|value| !value.trim().is_empty()) else { + return Ok(None); + }; + if known.contains(&meeting_id) { + return Ok(None); + } + let transcripts = client + .list_transcripts(&meeting_id) + .await + .map_err(|error| format!("could not list Teams transcripts: {error}"))?; + let mut segments = Vec::new(); + for transcript in transcripts { + let Some(transcript_id) = transcript.id.filter(|value| !value.trim().is_empty()) else { + continue; + }; + segments.extend(download_transcript(proxy, &meeting_id, &transcript_id).await); + } + Ok(event.imported(&meeting, segments)) +} + +async fn download_transcript( + proxy: &OwnedNangoProxy, + meeting_id: &str, + transcript_id: &str, +) -> Vec { + let path = format!( + "/v1.0/me/onlineMeetings/{}/transcripts/{}/content?$format=text/vtt", + urlencoding::encode(meeting_id), + urlencoding::encode(transcript_id) + ); + let response = match proxy.get(&path) { + Ok(request) => request.send().await, + Err(_) => return Vec::new(), + }; + let Ok(response) = response else { + return Vec::new(); + }; + if !response.status().is_success() { + return Vec::new(); + } + let Ok(body) = response.text().await else { + return Vec::new(); + }; + anlg_meeting_import::parse_vtt(&body) +} diff --git a/crates/api-meeting-import/src/import/webex.rs b/crates/api-meeting-import/src/import/webex.rs new file mode 100644 index 0000000000..d6348dba28 --- /dev/null +++ b/crates/api-meeting-import/src/import/webex.rs @@ -0,0 +1,86 @@ +use std::collections::HashSet; + +use anlg_meeting_import::{meeting_file, meeting_has_content, webex::WebexClient}; +use anlg_nango::OwnedNangoProxy; + +use super::{ImportResult, MAX_PAGES, download_vtt}; + +pub async fn import_meetings( + client: &WebexClient, + proxy: &OwnedNangoProxy, + known_meeting_ids: &[String], +) -> Result { + let known = known_meeting_ids.iter().cloned().collect::>(); + let mut transcripts = Vec::new(); + for page_index in 0..MAX_PAGES { + let page = client + .list_transcripts((page_index as u32) * 100) + .await + .map_err(|error| format!("could not list Webex transcripts: {error}"))?; + let count = page.items.len(); + transcripts.extend(page.items); + if count < 100 { + break; + } + } + + let mut files = Vec::new(); + let mut without_content = 0; + for item in transcripts { + let Some(id) = item.transcript_id().map(ToOwned::to_owned) else { + continue; + }; + if known.contains(&id) { + continue; + } + let segments = match item.vtt_download_link() { + Some(url) => download_vtt(proxy, url).await, + None => download_transcript_by_id(proxy, &id).await, + }; + let Some(imported) = item.imported(segments) else { + continue; + }; + if !meeting_has_content(&imported) { + without_content += 1; + continue; + } + files.push(meeting_file("webex", &imported)?); + } + + let mut warnings = Vec::new(); + if files.is_empty() && without_content == 0 { + warnings.push( + "Webex did not return any accessible transcripts. Transcripts exist only when recording, Webex Assistant, or captions were enabled.".to_string(), + ); + } + if without_content > 0 { + warnings.push(format!( + "{without_content} Webex transcripts could not be downloaded for this account." + )); + } + Ok(ImportResult { files, warnings }) +} + +async fn download_transcript_by_id( + proxy: &OwnedNangoProxy, + transcript_id: &str, +) -> Vec { + let path = format!( + "/v1/meetingTranscripts/{}/download?format=vtt", + urlencoding::encode(transcript_id) + ); + let response = match proxy.get(&path) { + Ok(request) => request.send().await, + Err(_) => return Vec::new(), + }; + let Ok(response) = response else { + return Vec::new(); + }; + if !response.status().is_success() { + return Vec::new(); + } + let Ok(body) = response.text().await else { + return Vec::new(); + }; + anlg_meeting_import::parse_vtt(&body) +} diff --git a/crates/api-meeting-import/src/lib.rs b/crates/api-meeting-import/src/lib.rs new file mode 100644 index 0000000000..c7fd5255bf --- /dev/null +++ b/crates/api-meeting-import/src/lib.rs @@ -0,0 +1,28 @@ +mod error; +mod import; +mod openapi; +mod routes; + +use axum::{Router, routing::post}; + +pub use openapi::openapi; + +pub fn router() -> Router { + Router::new() + .route( + "/fathom/import-meetings", + post(routes::fathom_import_meetings), + ) + .route( + "/webex/import-meetings", + post(routes::webex_import_meetings), + ) + .route( + "/google-meet/import-meetings", + post(routes::google_meet_import_meetings), + ) + .route( + "/microsoft-teams/import-meetings", + post(routes::teams_import_meetings), + ) +} diff --git a/crates/api-meeting-import/src/openapi.rs b/crates/api-meeting-import/src/openapi.rs new file mode 100644 index 0000000000..bd9e70349e --- /dev/null +++ b/crates/api-meeting-import/src/openapi.rs @@ -0,0 +1,27 @@ +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::fathom_import_meetings, + crate::routes::webex_import_meetings, + crate::routes::google_meet_import_meetings, + crate::routes::teams_import_meetings, + ), + components(schemas( + crate::routes::ImportMeetingsRequest, + crate::routes::ImportMeetingsResponse, + crate::routes::ImportTextFile, + )), + tags( + (name = "fathom", description = "Fathom meeting import"), + (name = "webex", description = "Webex meeting import"), + (name = "google-meet", description = "Google Meet meeting import"), + (name = "microsoft-teams", description = "Microsoft Teams meeting import"), + ) +)] +struct ApiDoc; + +pub fn openapi() -> utoipa::openapi::OpenApi { + ApiDoc::openapi() +} diff --git a/crates/api-meeting-import/src/routes.rs b/crates/api-meeting-import/src/routes.rs new file mode 100644 index 0000000000..57286ab05f --- /dev/null +++ b/crates/api-meeting-import/src/routes.rs @@ -0,0 +1,174 @@ +use anlg_api_auth::AuthContext; +use anlg_api_nango::{ + Fathom, GoogleMeet, MicrosoftTeams, NangoConnectionState, NangoIntegrationId, Webex, +}; +use anlg_meeting_import::{ + fathom::FathomClient, google_meet::GoogleMeetClient, teams::TeamsClient, webex::WebexClient, +}; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::error::{MeetingImportError, Result}; +use crate::import::{self, ImportFile}; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ImportMeetingsRequest { + pub connection_id: String, + #[serde(default)] + pub known_meeting_ids: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportTextFile { + pub path: String, + pub name: String, + pub content: String, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportMeetingsResponse { + pub files: Vec, + pub warnings: Vec, +} + +#[utoipa::path( + post, + path = "/fathom/import-meetings", + operation_id = "fathom_import_meetings", + request_body = ImportMeetingsRequest, + responses( + (status = 200, description = "Fathom meetings fetched for import", body = ImportMeetingsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + tag = "fathom", +)] +pub async fn fathom_import_meetings( + Extension(auth): Extension, + Extension(nango_state): Extension, + Json(req): Json, +) -> Result> { + let http = connection_http(&auth, &nango_state, Fathom::ID, &req).await?; + let client = FathomClient::new(http); + let imported = import::fathom::import_meetings(&client, &req.known_meeting_ids) + .await + .map_err(MeetingImportError::Internal)?; + Ok(Json(into_response(imported.files, imported.warnings))) +} + +#[utoipa::path( + post, + path = "/webex/import-meetings", + operation_id = "webex_import_meetings", + request_body = ImportMeetingsRequest, + responses( + (status = 200, description = "Webex meetings fetched for import", body = ImportMeetingsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + tag = "webex", +)] +pub async fn webex_import_meetings( + Extension(auth): Extension, + Extension(nango_state): Extension, + Json(req): Json, +) -> Result> { + let http = connection_http(&auth, &nango_state, Webex::ID, &req).await?; + let proxy = http.clone().into_proxy(); + let client = WebexClient::new(http); + let imported = import::webex::import_meetings(&client, &proxy, &req.known_meeting_ids) + .await + .map_err(MeetingImportError::Internal)?; + Ok(Json(into_response(imported.files, imported.warnings))) +} + +#[utoipa::path( + post, + path = "/google-meet/import-meetings", + operation_id = "google_meet_import_meetings", + request_body = ImportMeetingsRequest, + responses( + (status = 200, description = "Google Meet meetings fetched for import", body = ImportMeetingsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + tag = "google-meet", +)] +pub async fn google_meet_import_meetings( + Extension(auth): Extension, + Extension(nango_state): Extension, + Json(req): Json, +) -> Result> { + let http = connection_http(&auth, &nango_state, GoogleMeet::ID, &req).await?; + let client = GoogleMeetClient::new(http); + let imported = import::google_meet::import_meetings(&client, &req.known_meeting_ids) + .await + .map_err(MeetingImportError::Internal)?; + Ok(Json(into_response(imported.files, imported.warnings))) +} + +#[utoipa::path( + post, + path = "/microsoft-teams/import-meetings", + operation_id = "microsoft_teams_import_meetings", + request_body = ImportMeetingsRequest, + responses( + (status = 200, description = "Microsoft Teams meetings fetched for import", body = ImportMeetingsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + tag = "microsoft-teams", +)] +pub async fn teams_import_meetings( + Extension(auth): Extension, + Extension(nango_state): Extension, + Json(req): Json, +) -> Result> { + let http = connection_http(&auth, &nango_state, MicrosoftTeams::ID, &req).await?; + let proxy = http.clone().into_proxy(); + let client = TeamsClient::new(http); + let imported = import::teams::import_meetings(&client, &proxy, &req.known_meeting_ids) + .await + .map_err(MeetingImportError::Internal)?; + Ok(Json(into_response(imported.files, imported.warnings))) +} + +async fn connection_http( + auth: &AuthContext, + nango_state: &NangoConnectionState, + integration_id: &str, + req: &ImportMeetingsRequest, +) -> Result { + if req.connection_id.trim().is_empty() { + return Err(MeetingImportError::BadRequest( + "connection_id is required".to_string(), + )); + } + nango_state + .build_http_client( + &auth.token, + &auth.claims.sub, + integration_id, + &req.connection_id, + ) + .await + .map_err(MeetingImportError::from) +} + +fn into_response(files: Vec, warnings: Vec) -> ImportMeetingsResponse { + ImportMeetingsResponse { + files: files.into_iter().map(into_file).collect(), + warnings, + } +} + +fn into_file(file: ImportFile) -> ImportTextFile { + ImportTextFile { + path: file.path, + name: file.name, + content: file.content, + } +} diff --git a/crates/api-nango/src/integrations.rs b/crates/api-nango/src/integrations.rs index 1993bdb3ff..9da9954058 100644 --- a/crates/api-nango/src/integrations.rs +++ b/crates/api-nango/src/integrations.rs @@ -10,10 +10,29 @@ pub const GOOGLE_CALENDAR_OAUTH_SCOPES: &str = "https://www.googleapis.com/auth/ pub const OUTLOOK_OAUTH_SCOPES: &str = "offline_access User.Read Calendars.Read"; +pub const ZOOM_OAUTH_SCOPES: &str = + "user:read:user cloud_recording:read:list_user_recordings meeting:read:summary"; + +pub const FATHOM_OAUTH_SCOPES: &str = "public_api"; + +pub const WEBEX_OAUTH_SCOPES: &str = + "spark:people_read meeting:schedules_read meeting:transcripts_read"; + +pub const GOOGLE_MEET_OAUTH_SCOPES: &str = + "https://www.googleapis.com/auth/meetings.space.readonly"; + +pub const MICROSOFT_TEAMS_OAUTH_SCOPES: &str = + "offline_access User.Read Calendars.Read OnlineMeetings.Read OnlineMeetingTranscript.Read.All"; + pub fn oauth_scopes_override(integration_id: &str) -> Option<&'static str> { match integration_id { GoogleCalendar::ID => Some(GOOGLE_CALENDAR_OAUTH_SCOPES), Outlook::ID => Some(OUTLOOK_OAUTH_SCOPES), + Zoom::ID => Some(ZOOM_OAUTH_SCOPES), + Fathom::ID => Some(FATHOM_OAUTH_SCOPES), + Webex::ID => Some(WEBEX_OAUTH_SCOPES), + GoogleMeet::ID => Some(GOOGLE_MEET_OAUTH_SCOPES), + MicrosoftTeams::ID => Some(MICROSOFT_TEAMS_OAUTH_SCOPES), _ => None, } } @@ -87,6 +106,36 @@ impl NangoIntegrationId for Notion { const ID: &'static str = "notion"; } +pub struct Zoom; + +impl NangoIntegrationId for Zoom { + const ID: &'static str = "zoom"; +} + +pub struct Fathom; + +impl NangoIntegrationId for Fathom { + const ID: &'static str = "fathom"; +} + +pub struct Webex; + +impl NangoIntegrationId for Webex { + const ID: &'static str = "webex"; +} + +pub struct GoogleMeet; + +impl NangoIntegrationId for GoogleMeet { + const ID: &'static str = "google-meet"; +} + +pub struct MicrosoftTeams; + +impl NangoIntegrationId for MicrosoftTeams { + const ID: &'static str = "microsoft-teams"; +} + #[cfg(test)] mod tests { use super::*; @@ -126,6 +175,52 @@ mod tests { assert!(!scopes.contains("Calendars.ReadWrite")); } + #[test] + fn zoom_connect_requests_readonly_recording_scopes() { + let defaults = integrations_config_defaults(Zoom::ID).unwrap(); + let scopes = defaults + .get(Zoom::ID) + .unwrap() + .connection_config + .as_ref() + .and_then(|c| c.oauth_scopes_override.as_deref()) + .unwrap(); + + assert_eq!(scopes, ZOOM_OAUTH_SCOPES); + assert!(scopes.contains("cloud_recording:read:list_user_recordings")); + assert!(scopes.contains("meeting:read:summary")); + assert!(!scopes.contains("recording:write")); + } + + #[test] + fn meeting_import_connect_requests_readonly_scopes() { + assert_eq!( + integrations_config_defaults(Fathom::ID) + .unwrap() + .get(Fathom::ID) + .unwrap() + .connection_config + .as_ref() + .and_then(|c| c.oauth_scopes_override.as_deref()) + .unwrap(), + FATHOM_OAUTH_SCOPES + ); + assert!( + oauth_scopes_override(Webex::ID) + .unwrap() + .contains("meeting:transcripts_read") + ); + assert!( + oauth_scopes_override(GoogleMeet::ID) + .unwrap() + .contains("meetings.space.readonly") + ); + let teams = oauth_scopes_override(MicrosoftTeams::ID).unwrap(); + assert!(teams.contains("OnlineMeetingTranscript.Read.All")); + assert!(teams.contains("Calendars.Read")); + assert!(!teams.contains("Calendars.ReadWrite")); + } + #[test] fn other_integrations_keep_dashboard_scopes() { assert!(integrations_config_defaults(GitHub::ID).is_none()); diff --git a/crates/api-nango/src/lib.rs b/crates/api-nango/src/lib.rs index 5605bef222..77526378de 100644 --- a/crates/api-nango/src/lib.rs +++ b/crates/api-nango/src/lib.rs @@ -10,8 +10,8 @@ mod supabase; pub use config::NangoConfig; pub use extractor::{NangoConnection, NangoConnectionError, NangoConnectionState}; pub use integrations::{ - Discord, GitHub, GoogleCalendar, GoogleDrive, GoogleMail, Linear, NangoIntegrationId, Notion, - Outlook, Slack, + Discord, Fathom, GitHub, GoogleCalendar, GoogleDrive, GoogleMail, GoogleMeet, Linear, + MicrosoftTeams, NangoIntegrationId, Notion, Outlook, Slack, Webex, Zoom, }; pub use openapi::openapi; pub use routes::{ diff --git a/crates/api-nango/src/routes/identity.rs b/crates/api-nango/src/routes/identity.rs index 1b4fd18782..77c1462c2c 100644 --- a/crates/api-nango/src/routes/identity.rs +++ b/crates/api-nango/src/routes/identity.rs @@ -15,6 +15,67 @@ struct OutlookMe { display_name: Option, } +#[derive(serde::Deserialize)] +struct ZoomUser { + email: Option, + display_name: Option, + first_name: Option, + last_name: Option, +} + +#[derive(serde::Deserialize)] +struct WebexMe { + #[serde(rename = "displayName")] + display_name: Option, + #[serde(default)] + emails: Vec, +} + +#[derive(serde::Deserialize)] +struct FathomMeetings { + #[serde(default)] + items: Vec, +} + +#[derive(serde::Deserialize)] +struct FathomMeeting { + recorded_by: Option, +} + +#[derive(serde::Deserialize)] +struct FathomUser { + email: Option, + name: Option, +} + +#[derive(serde::Deserialize)] +struct NotionUser { + name: Option, + person: Option, + bot: Option, +} + +#[derive(serde::Deserialize)] +struct NotionPerson { + email: Option, +} + +#[derive(serde::Deserialize)] +struct NotionBot { + owner: Option, +} + +#[derive(serde::Deserialize)] +struct NotionBotOwner { + user: Option, +} + +#[derive(serde::Deserialize)] +struct NotionOwnerUser { + person: Option, + name: Option, +} + pub(crate) async fn fetch_identity( nango: &anlg_nango::NangoClient, integration_id: &str, @@ -24,9 +85,15 @@ pub(crate) async fn fetch_identity( match integration_id { // https://docs.cloud.google.com/identity-platform/docs/reference/rest/v1/UserInfo - "google-calendar" | "google-drive" => { - let resp = proxy - .get("/oauth2/v1/userinfo?alt=json") + "google-calendar" | "google-drive" | "google-meet" => { + let request = if integration_id == "google-meet" { + proxy + .base_url_override("https://www.googleapis.com") + .get("/oauth2/v1/userinfo?alt=json") + } else { + proxy.get("/oauth2/v1/userinfo?alt=json") + }; + let resp = request .map_err(|e| e.to_string())? .send() .await @@ -39,7 +106,7 @@ pub(crate) async fn fetch_identity( } // https://learn.microsoft.com/en-us/graph/api/user-get - "outlook" => { + "outlook" | "microsoft-teams" => { let resp = proxy .get("/v1.0/me?$select=mail,userPrincipalName,displayName") .map_err(|e| e.to_string())? @@ -53,6 +120,93 @@ pub(crate) async fn fetch_identity( Ok((me.mail.or(me.user_principal_name), me.display_name)) } + "zoom" => { + let resp = proxy + .get("/users/me") + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + + let me: ZoomUser = resp.json().await.map_err(|e| e.to_string())?; + let name = me + .display_name + .or_else(|| match (me.first_name, me.last_name) { + (Some(first), Some(last)) => Some(format!("{first} {last}").trim().to_string()), + (Some(first), None) => Some(first), + (None, Some(last)) => Some(last), + (None, None) => None, + }); + Ok((me.email, name)) + } + + "webex" => { + let resp = proxy + .get("/v1/people/me") + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let me: WebexMe = resp.json().await.map_err(|e| e.to_string())?; + Ok(( + me.emails.into_iter().find(|email| !email.is_empty()), + me.display_name, + )) + } + + "fathom" => { + let resp = proxy + .get("/external/v1/meetings?limit=1") + .map_err(|e| e.to_string())? + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let page: FathomMeetings = resp.json().await.map_err(|e| e.to_string())?; + let recorded_by = page + .items + .into_iter() + .next() + .and_then(|item| item.recorded_by); + Ok(( + recorded_by.as_ref().and_then(|user| user.email.clone()), + recorded_by.and_then(|user| user.name), + )) + } + + "notion" => { + let resp = proxy + .get("/v1/users/me") + .map_err(|e| e.to_string())? + .header("Notion-Version", "2022-06-28") + .send() + .await + .map_err(|e| e.to_string())? + .error_for_status() + .map_err(|e| e.to_string())?; + let me: NotionUser = resp.json().await.map_err(|e| e.to_string())?; + let email = me.person.and_then(|person| person.email).or_else(|| { + me.bot + .as_ref() + .and_then(|bot| bot.owner.as_ref()) + .and_then(|owner| owner.user.as_ref()) + .and_then(|user| user.person.as_ref()) + .and_then(|person| person.email.clone()) + }); + let name = me.name.or_else(|| { + me.bot + .and_then(|bot| bot.owner) + .and_then(|owner| owner.user) + .and_then(|user| user.name) + }); + Ok((email, name)) + } + other => Err(format!("unsupported integration: {other}")), } } diff --git a/crates/api-notion/Cargo.toml b/crates/api-notion/Cargo.toml index 70978ee472..9beb1f8ddb 100644 --- a/crates/api-notion/Cargo.toml +++ b/crates/api-notion/Cargo.toml @@ -7,8 +7,11 @@ edition = "2024" anlg-api-auth = { workspace = true } anlg-api-error = { workspace = true } anlg-api-nango = { workspace = true } +anlg-meeting-import = { workspace = true } +anlg-nango = { workspace = true } axum = { workspace = true } +reqwest = { workspace = true, features = ["json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/api-notion/src/import.rs b/crates/api-notion/src/import.rs new file mode 100644 index 0000000000..0c7026fd68 --- /dev/null +++ b/crates/api-notion/src/import.rs @@ -0,0 +1,264 @@ +use std::collections::HashSet; + +use anlg_meeting_import::{ + ImportedMeeting, TranscriptSegment, hhmmss_to_ms, meeting_file, meeting_has_content, nonempty, +}; +use anlg_nango::OwnedNangoProxy; +use serde_json::{Value, json}; + +use crate::error::{NotionError, Result}; + +const MEETING_NOTES_VERSION: &str = "2026-03-11"; +const MAX_CHILD_PAGES: usize = 20; + +pub struct ImportFile { + pub path: String, + pub name: String, + pub content: String, +} + +pub struct ImportResult { + pub files: Vec, + pub warnings: Vec, +} + +pub async fn import_meetings( + proxy: &OwnedNangoProxy, + known_meeting_ids: &[String], +) -> Result { + let known = known_meeting_ids.iter().cloned().collect::>(); + let payload = notion_post( + proxy, + "/v1/blocks/meeting_notes/query", + json!({ + "sort": [{ "property": "last_edited_time", "direction": "descending" }], + "limit": 50 + }), + ) + .await?; + + let results = payload["results"].as_array().cloned().unwrap_or_default(); + let mut files = Vec::new(); + let mut without_content = 0; + let query_warning = results + .is_empty() + .then(|| payload["message"].as_str().map(str::to_string)) + .flatten(); + + for result in results { + let Some(id) = result["id"].as_str().map(str::to_string) else { + continue; + }; + if known.contains(&id) { + continue; + } + let imported = meeting_from_block(proxy, &result).await?; + if !meeting_has_content(&imported) { + without_content += 1; + continue; + } + let file = meeting_file("notion", &imported).map_err(NotionError::Internal)?; + files.push(ImportFile { + path: file.path, + name: file.name, + content: file.content, + }); + } + + let mut warnings = Vec::new(); + if let Some(message) = query_warning { + warnings.push(format!( + "Notion meeting notes could not be listed: {message}" + )); + } + if files.is_empty() && without_content == 0 && warnings.is_empty() { + warnings + .push("Notion did not return any AI meeting notes for the connected user.".to_string()); + } + if without_content > 0 { + warnings.push(format!( + "{without_content} Notion meeting notes did not include summary, notes, or a transcript." + )); + } + Ok(ImportResult { files, warnings }) +} + +async fn meeting_from_block(proxy: &OwnedNangoProxy, block: &Value) -> Result { + let id = block["id"] + .as_str() + .ok_or_else(|| NotionError::Internal("meeting note is missing an id".into()))? + .to_string(); + let notes = &block["meeting_notes"]; + let title = rich_text(¬es["title"]).unwrap_or_else(|| "Notion meeting".to_string()); + let start_time = nonempty(notes["recording"]["start_time"].as_str()) + .or_else(|| nonempty(notes["calendar_event"]["start_time"].as_str())) + .or_else(|| nonempty(block["created_time"].as_str())); + let children = ¬es["children"]; + let summary = block_markdown(proxy, children["summary_block_id"].as_str()).await?; + let notes_text = block_markdown(proxy, children["notes_block_id"].as_str()).await?; + let transcript = block_transcript(proxy, children["transcript_block_id"].as_str()).await?; + let url = format!("https://www.notion.so/{}", id.replace('-', "")); + Ok(ImportedMeeting { + id, + title, + start_time, + url: Some(url), + summary, + notes: notes_text, + transcript, + action_items: Vec::new(), + }) +} + +async fn block_markdown(proxy: &OwnedNangoProxy, block_id: Option<&str>) -> Result> { + let Some(block_id) = block_id.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let children = list_children(proxy, block_id).await?; + Ok(nonempty(Some(&blocks_to_markdown(&children)))) +} + +async fn block_transcript( + proxy: &OwnedNangoProxy, + block_id: Option<&str>, +) -> Result> { + let Some(block_id) = block_id.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(Vec::new()); + }; + let children = list_children(proxy, block_id).await?; + Ok(blocks_to_transcript(&children)) +} + +async fn list_children(proxy: &OwnedNangoProxy, block_id: &str) -> Result> { + let mut children = Vec::new(); + let mut cursor: Option = None; + for _ in 0..MAX_CHILD_PAGES { + let mut path = format!("/v1/blocks/{block_id}/children?page_size=100"); + if let Some(cursor) = cursor.as_deref() { + path.push_str("&start_cursor="); + path.push_str(cursor); + } + let payload = notion_get(proxy, &path).await?; + if let Some(results) = payload["results"].as_array() { + children.extend(results.iter().cloned()); + } + if payload["has_more"].as_bool() != Some(true) { + break; + } + let next = payload["next_cursor"] + .as_str() + .map(str::to_string) + .filter(|value| !value.is_empty()); + if next == cursor { + break; + } + match next { + Some(value) => cursor = Some(value), + None => break, + } + } + Ok(children) +} + +async fn notion_post(proxy: &OwnedNangoProxy, path: &str, body: Value) -> Result { + notion_send( + proxy + .post( + path, + serde_json::to_vec(&body).map_err(|e| NotionError::Notion(e.to_string()))?, + "application/json", + ) + .map_err(|e| NotionError::Notion(e.to_string()))?, + ) + .await +} + +async fn notion_get(proxy: &OwnedNangoProxy, path: &str) -> Result { + notion_send( + proxy + .get(path) + .map_err(|e| NotionError::Notion(e.to_string()))?, + ) + .await +} + +async fn notion_send(builder: reqwest::RequestBuilder) -> Result { + let response = builder + .header("Notion-Version", MEETING_NOTES_VERSION) + .send() + .await + .map_err(|e| NotionError::Notion(e.to_string()))?; + let status = response.status(); + let payload: Value = response + .json() + .await + .map_err(|e| NotionError::Notion(e.to_string()))?; + if !status.is_success() { + let message = payload["message"] + .as_str() + .unwrap_or("Notion request failed"); + return Err(NotionError::Notion(format!("{status}: {message}"))); + } + Ok(payload) +} + +fn blocks_to_markdown(blocks: &[Value]) -> String { + blocks + .iter() + .filter_map(block_line) + .collect::>() + .join("\n") +} + +fn blocks_to_transcript(blocks: &[Value]) -> Vec { + blocks + .iter() + .enumerate() + .filter_map(|(index, block)| { + let text = block_line(block)?; + let (speaker, spoken) = if let Some((speaker, spoken)) = text.split_once(": ") + && speaker.len() <= 60 + { + (speaker.trim().to_string(), spoken.trim().to_string()) + } else { + (String::new(), text) + }; + let start_ms = timestamp_prefix(&spoken) + .or_else(|| timestamp_prefix(block_plain_text(block).as_deref().unwrap_or(""))) + .unwrap_or((index as u64).saturating_mul(1_000)); + Some(TranscriptSegment { + speaker, + text: spoken, + start_ms, + end_ms: start_ms, + }) + }) + .collect() +} + +fn timestamp_prefix(text: &str) -> Option { + let stamp = text.split_whitespace().next()?; + if stamp.contains(':') && stamp.chars().all(|c| c.is_ascii_digit() || c == ':') { + Some(hhmmss_to_ms(stamp)) + } else { + None + } +} + +fn block_line(block: &Value) -> Option { + block_plain_text(block).and_then(|text| nonempty(Some(&text))) +} + +fn block_plain_text(block: &Value) -> Option { + let block_type = block["type"].as_str()?; + rich_text(&block[block_type]["rich_text"]) +} + +fn rich_text(value: &Value) -> Option { + let fragments = value.as_array()?; + let text = fragments + .iter() + .filter_map(|fragment| fragment["plain_text"].as_str()) + .collect::(); + nonempty(Some(&text)) +} diff --git a/crates/api-notion/src/lib.rs b/crates/api-notion/src/lib.rs index eb3bb889da..c9b5cfa734 100644 --- a/crates/api-notion/src/lib.rs +++ b/crates/api-notion/src/lib.rs @@ -1,5 +1,6 @@ mod blocks; mod error; +mod import; mod openapi; mod routes; diff --git a/crates/api-notion/src/openapi.rs b/crates/api-notion/src/openapi.rs index b66767f548..9b3646d9ce 100644 --- a/crates/api-notion/src/openapi.rs +++ b/crates/api-notion/src/openapi.rs @@ -5,6 +5,7 @@ use utoipa::OpenApi; paths( crate::routes::notion::search_pages, crate::routes::notion::append_update, + crate::routes::notion::import_meetings, ), components(schemas( crate::routes::notion::NotionSearchPagesRequest, @@ -12,6 +13,9 @@ use utoipa::OpenApi; crate::routes::notion::NotionPagesResponse, crate::routes::notion::NotionAppendUpdateRequest, crate::routes::notion::NotionAppendUpdateResponse, + crate::routes::notion::NotionImportMeetingsRequest, + crate::routes::notion::NotionImportMeetingsResponse, + crate::routes::notion::NotionImportTextFile, )), tags( (name = "notion", description = "Notion integration") diff --git a/crates/api-notion/src/routes/mod.rs b/crates/api-notion/src/routes/mod.rs index 1c15861162..1532a0a88d 100644 --- a/crates/api-notion/src/routes/mod.rs +++ b/crates/api-notion/src/routes/mod.rs @@ -6,4 +6,5 @@ pub fn router() -> Router { Router::new() .route("/search-pages", post(notion::search_pages)) .route("/append-update", post(notion::append_update)) + .route("/import-meetings", post(notion::import_meetings)) } diff --git a/crates/api-notion/src/routes/notion.rs b/crates/api-notion/src/routes/notion.rs index feaccac0c4..7d37a2111a 100644 --- a/crates/api-notion/src/routes/notion.rs +++ b/crates/api-notion/src/routes/notion.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use crate::blocks::{heading_block, markdown_to_blocks}; use crate::error::{NotionError, Result}; +use crate::import::{self, ImportFile}; const NOTION_VERSION: &str = "2022-06-28"; @@ -168,6 +169,75 @@ pub async fn append_update( Ok(Json(NotionAppendUpdateResponse { block_count })) } +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct NotionImportMeetingsRequest { + pub connection_id: String, + #[serde(default)] + pub known_meeting_ids: Vec, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct NotionImportTextFile { + pub path: String, + pub name: String, + pub content: String, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct NotionImportMeetingsResponse { + pub files: Vec, + pub warnings: Vec, +} + +#[utoipa::path( + post, + path = "/import-meetings", + operation_id = "notion_import_meetings", + request_body = NotionImportMeetingsRequest, + responses( + (status = 200, description = "Notion meeting notes fetched for import", body = NotionImportMeetingsResponse), + (status = 401, description = "Authentication required"), + (status = 500, description = "Notion connection unavailable"), + ), + tag = "notion", +)] +pub async fn import_meetings( + Extension(auth): Extension, + Extension(nango_state): Extension, + Json(req): Json, +) -> Result> { + if req.connection_id.trim().is_empty() { + return Err(NotionError::BadRequest( + "connection_id is required".to_string(), + )); + } + + let proxy = nango_state + .build_http_client( + &auth.token, + &auth.claims.sub, + Notion::ID, + &req.connection_id, + ) + .await? + .into_proxy(); + let imported = import::import_meetings(&proxy, &req.known_meeting_ids).await?; + Ok(Json(NotionImportMeetingsResponse { + files: imported.files.into_iter().map(into_file).collect(), + warnings: imported.warnings, + })) +} + +fn into_file(file: ImportFile) -> NotionImportTextFile { + NotionImportTextFile { + path: file.path, + name: file.name, + content: file.content, + } +} + fn page_from_result(result: &Value) -> Option { if result["object"].as_str() != Some("page") { return None; diff --git a/crates/api-zoom/Cargo.toml b/crates/api-zoom/Cargo.toml new file mode 100644 index 0000000000..046aedaf31 --- /dev/null +++ b/crates/api-zoom/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "api-zoom" +version = "0.1.0" +edition = "2024" + +[dependencies] +anlg-api-auth = { workspace = true } +anlg-api-error = { workspace = true } +anlg-api-nango = { workspace = true } +anlg-nango = { workspace = true } +anlg-zoom = { workspace = true } + +axum = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +url = { workspace = true } +utoipa = { workspace = true } diff --git a/crates/api-zoom/src/error.rs b/crates/api-zoom/src/error.rs new file mode 100644 index 0000000000..8abe086b9c --- /dev/null +++ b/crates/api-zoom/src/error.rs @@ -0,0 +1,35 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, +}; +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum ZoomError { + #[error("Invalid request: {0}")] + BadRequest(String), + + #[error("Internal error: {0}")] + Internal(String), + + #[error(transparent)] + NangoConnection(#[from] anlg_api_nango::NangoConnectionError), +} + +impl IntoResponse for ZoomError { + fn into_response(self) -> Response { + let (status, code, message) = match self { + Self::BadRequest(message) => (StatusCode::BAD_REQUEST, "bad_request", message), + Self::Internal(message) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_server_error", + message, + ), + Self::NangoConnection(err) => return err.into_response(), + }; + + anlg_api_error::error_response(status, code, &message) + } +} diff --git a/crates/api-zoom/src/import.rs b/crates/api-zoom/src/import.rs new file mode 100644 index 0000000000..1f01860a0c --- /dev/null +++ b/crates/api-zoom/src/import.rs @@ -0,0 +1,171 @@ +use anlg_nango::OwnedNangoProxy; +use anlg_zoom::{ + RecordingMeeting, TranscriptSegment, ZoomClient, meeting_has_content, meeting_json, parse_vtt, +}; +use chrono::{Duration, Utc}; +use serde_json::Value; +use std::collections::HashSet; +use url::Url; + +const RECORDING_WINDOWS: i64 = 6; +const RECORDING_WINDOW_DAYS: i64 = 29; +const MAX_PAGES_PER_WINDOW: usize = 20; + +pub struct ZoomImportFile { + pub path: String, + pub name: String, + pub content: String, +} + +pub struct ZoomImportResult { + pub files: Vec, + pub warnings: Vec, +} + +pub async fn import_meetings( + client: &ZoomClient, + proxy: &OwnedNangoProxy, + known_meeting_ids: &[String], +) -> Result { + let known = known_meeting_ids.iter().cloned().collect::>(); + let mut recordings = Vec::new(); + let mut warnings = Vec::new(); + + for (from, to) in recording_windows() { + let mut next_page_token = None; + for _ in 0..MAX_PAGES_PER_WINDOW { + let page = client + .list_user_recordings(from, to, next_page_token.as_deref()) + .await + .map_err(|error| format!("could not list Zoom recordings: {error}"))?; + recordings.extend(page.meetings); + let next = page.next_page_token.filter(|token| !token.is_empty()); + if next.as_ref() == next_page_token.as_ref() { + break; + } + match next { + Some(token) => next_page_token = Some(token), + None => break, + } + } + } + + let mut files = Vec::new(); + let mut without_content = 0; + for recording in recordings { + let Some(external_id) = recording.external_id() else { + continue; + }; + if known.contains(&external_id) { + continue; + } + + let summary = match recording.meeting_id() { + Some(meeting_id) => client + .get_meeting_summary(&meeting_id) + .await + .map_err(|error| format!("could not read a Zoom meeting summary: {error}"))?, + None => None, + }; + let transcript = match recording + .transcript_file() + .and_then(|file| file.download_url.as_deref().filter(|url| !url.is_empty())) + { + Some(download_url) => download_transcript(proxy, download_url).await, + None => Vec::new(), + }; + + if !meeting_has_content(summary.as_ref(), &transcript) { + without_content += 1; + continue; + } + + let Some(meeting) = meeting_json(&recording, summary.as_ref(), transcript) else { + continue; + }; + files.push(meeting_file(&recording, meeting)?); + } + + if files.is_empty() && without_content == 0 { + warnings.push( + "Zoom did not return any accessible cloud recordings for this account.".to_string(), + ); + } + if without_content > 0 { + warnings.push(format!( + "{without_content} Zoom recordings did not include notes or transcripts for this account or plan." + )); + } + + Ok(ZoomImportResult { files, warnings }) +} + +fn meeting_file(recording: &RecordingMeeting, meeting: Value) -> Result { + let id = recording + .external_id() + .ok_or_else(|| "Zoom recording is missing an id".to_string())?; + let safe_id = safe_file_component(&id); + Ok(ZoomImportFile { + path: format!("oauth://zoom/{safe_id}.json"), + name: format!("{safe_id}.json"), + content: serde_json::to_string(&meeting) + .map_err(|error| format!("could not save a Zoom meeting: {error}"))?, + }) +} + +fn recording_windows() -> Vec<(chrono::NaiveDate, chrono::NaiveDate)> { + let mut end = Utc::now().date_naive(); + let mut windows = Vec::with_capacity(RECORDING_WINDOWS as usize); + for _ in 0..RECORDING_WINDOWS { + let start = end - Duration::days(RECORDING_WINDOW_DAYS); + windows.push((start, end)); + end = start - Duration::days(1); + } + windows +} + +async fn download_transcript( + proxy: &OwnedNangoProxy, + download_url: &str, +) -> Vec { + let Ok(url) = Url::parse(download_url) else { + return Vec::new(); + }; + let origin = format!("{}://{}", url.scheme(), url.host_str().unwrap_or("zoom.us")); + let path = match url.query() { + Some(query) => format!("{}?{query}", url.path()), + None => url.path().to_string(), + }; + let response = match proxy.clone().base_url_override(origin).get(&path) { + Ok(request) => request.send().await, + Err(_) => return Vec::new(), + }; + let Ok(response) = response else { + return Vec::new(); + }; + if !response.status().is_success() { + return Vec::new(); + } + let Ok(body) = response.text().await else { + return Vec::new(); + }; + parse_vtt(&body) +} + +fn safe_file_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect::(); + if sanitized.is_empty() { + "meeting".to_string() + } else { + sanitized + } +} diff --git a/crates/api-zoom/src/lib.rs b/crates/api-zoom/src/lib.rs new file mode 100644 index 0000000000..4ced62b2b7 --- /dev/null +++ b/crates/api-zoom/src/lib.rs @@ -0,0 +1,12 @@ +mod error; +mod import; +mod openapi; +mod routes; + +use axum::{Router, routing::post}; + +pub use openapi::openapi; + +pub fn router() -> Router { + Router::new().route("/import-meetings", post(routes::import_meetings)) +} diff --git a/crates/api-zoom/src/openapi.rs b/crates/api-zoom/src/openapi.rs new file mode 100644 index 0000000000..a9aa7524b0 --- /dev/null +++ b/crates/api-zoom/src/openapi.rs @@ -0,0 +1,17 @@ +use utoipa::OpenApi; + +#[derive(OpenApi)] +#[openapi( + paths(crate::routes::import_meetings), + components(schemas( + crate::routes::ZoomImportMeetingsRequest, + crate::routes::ZoomImportMeetingsResponse, + crate::routes::ZoomImportTextFile, + )), + tags((name = "zoom", description = "Zoom meeting import")) +)] +struct ApiDoc; + +pub fn openapi() -> utoipa::openapi::OpenApi { + ApiDoc::openapi() +} diff --git a/crates/api-zoom/src/routes.rs b/crates/api-zoom/src/routes.rs new file mode 100644 index 0000000000..946299d81f --- /dev/null +++ b/crates/api-zoom/src/routes.rs @@ -0,0 +1,77 @@ +use anlg_api_auth::AuthContext; +use anlg_api_nango::{NangoConnectionState, NangoIntegrationId, Zoom}; +use anlg_zoom::ZoomClient; +use axum::{Extension, Json}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::error::{Result, ZoomError}; +use crate::import::{self, ZoomImportFile}; + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ZoomImportMeetingsRequest { + pub connection_id: String, + #[serde(default)] + pub known_meeting_ids: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ZoomImportTextFile { + pub path: String, + pub name: String, + pub content: String, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ZoomImportMeetingsResponse { + pub files: Vec, + pub warnings: Vec, +} + +#[utoipa::path( + post, + path = "/import-meetings", + operation_id = "zoom_import_meetings", + request_body = ZoomImportMeetingsRequest, + responses( + (status = 200, description = "Zoom meetings fetched for import", body = ZoomImportMeetingsResponse), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + tag = "zoom", +)] +pub async fn import_meetings( + Extension(auth): Extension, + Extension(nango_state): Extension, + Json(req): Json, +) -> Result> { + if req.connection_id.trim().is_empty() { + return Err(ZoomError::BadRequest( + "connection_id is required".to_string(), + )); + } + + let http = nango_state + .build_http_client(&auth.token, &auth.claims.sub, Zoom::ID, &req.connection_id) + .await?; + let proxy = http.clone().into_proxy(); + let client = ZoomClient::new(http); + let imported = import::import_meetings(&client, &proxy, &req.known_meeting_ids) + .await + .map_err(ZoomError::Internal)?; + + Ok(Json(ZoomImportMeetingsResponse { + files: imported.files.into_iter().map(into_file).collect(), + warnings: imported.warnings, + })) +} + +fn into_file(file: ZoomImportFile) -> ZoomImportTextFile { + ZoomImportTextFile { + path: file.path, + name: file.name, + content: file.content, + } +} diff --git a/crates/meeting-import/Cargo.toml b/crates/meeting-import/Cargo.toml new file mode 100644 index 0000000000..fd1011a737 --- /dev/null +++ b/crates/meeting-import/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "meeting-import" +version = "0.1.0" +edition = "2024" + +[dependencies] +anlg-http = { workspace = true } +anlg-zoom = { workspace = true } + +chrono = { workspace = true, features = ["serde"] } +dirs = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["io-util", "macros", "process", "rt", "sync", "time"] } +url = { workspace = true } +urlencoding = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/meeting-import/src/error.rs b/crates/meeting-import/src/error.rs new file mode 100644 index 0000000000..7ba5d35df2 --- /dev/null +++ b/crates/meeting-import/src/error.rs @@ -0,0 +1,10 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("HTTP client error: {0}")] + Http(Box), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} diff --git a/crates/meeting-import/src/fathom.rs b/crates/meeting-import/src/fathom.rs new file mode 100644 index 0000000000..51ddf24580 --- /dev/null +++ b/crates/meeting-import/src/fathom.rs @@ -0,0 +1,219 @@ +use anlg_http::HttpClient; +use serde::Deserialize; + +use crate::error::Error; +use crate::json::{ImportedMeeting, TranscriptSegment, nonempty}; +use crate::time::hhmmss_to_ms; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ListMeetingsResponse { + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct FathomMeeting { + #[serde(default)] + pub recording_id: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub meeting_title: Option, + #[serde(default)] + pub url: Option, + #[serde(default)] + pub share_url: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub scheduled_start_time: Option, + #[serde(default)] + pub recording_start_time: Option, + #[serde(default)] + pub recorded_by: Option, + #[serde(default)] + pub action_items: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct FathomUser { + #[serde(default)] + pub email: Option, + #[serde(default)] + pub name: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct FathomActionItem { + #[serde(default)] + pub description: Option, + #[serde(default)] + pub text: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct TranscriptResponse { + #[serde(default)] + transcript: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct TranscriptItem { + #[serde(default)] + speaker: Option, + #[serde(default)] + text: Option, + #[serde(default)] + timestamp: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct TranscriptSpeaker { + #[serde(default)] + display_name: Option, + #[serde(default)] + name: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct SummaryResponse { + #[serde(default)] + markdown: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + default_summary: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +struct DefaultSummary { + #[serde(default)] + markdown: Option, +} + +pub struct FathomClient { + http: C, +} + +impl FathomClient { + pub fn new(http: C) -> Self { + Self { http } + } + + pub async fn list_meetings( + &self, + created_after: &str, + cursor: Option<&str>, + ) -> Result { + let mut path = format!( + "/external/v1/meetings?created_after={}", + urlencoding::encode(created_after) + ); + if let Some(cursor) = cursor.filter(|cursor| !cursor.is_empty()) { + path.push_str("&cursor="); + path.push_str(&urlencoding::encode(cursor)); + } + let bytes = self.http.get(&path).await.map_err(Error::Http)?; + Ok(serde_json::from_slice(&bytes)?) + } + + pub async fn get_transcript( + &self, + recording_id: &str, + ) -> Result, Error> { + let path = format!( + "/external/v1/recordings/{}/transcript", + urlencoding::encode(recording_id) + ); + match self.http.get(&path).await { + Ok(bytes) => Ok(parse_transcript(&serde_json::from_slice(&bytes)?)), + Err(_) => Ok(Vec::new()), + } + } + + pub async fn get_summary(&self, recording_id: &str) -> Result, Error> { + let path = format!( + "/external/v1/recordings/{}/summary", + urlencoding::encode(recording_id) + ); + match self.http.get(&path).await { + Ok(bytes) => Ok(summary_text(&serde_json::from_slice(&bytes)?)), + Err(_) => Ok(None), + } + } +} + +impl FathomMeeting { + pub fn recording_id(&self) -> Option<&str> { + self.recording_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + pub fn imported( + &self, + summary: Option, + transcript: Vec, + ) -> Option { + let id = self.recording_id()?.to_string(); + let title = nonempty(self.title.as_deref()) + .or_else(|| nonempty(self.meeting_title.as_deref())) + .unwrap_or_else(|| "Fathom meeting".to_string()); + Some(ImportedMeeting { + id, + title, + start_time: nonempty(self.recording_start_time.as_deref()) + .or_else(|| nonempty(self.scheduled_start_time.as_deref())) + .or_else(|| nonempty(self.created_at.as_deref())), + url: nonempty(self.share_url.as_deref()).or_else(|| nonempty(self.url.as_deref())), + summary, + notes: None, + transcript, + action_items: self + .action_items + .iter() + .filter_map(|item| { + nonempty(item.description.as_deref()).or_else(|| nonempty(item.text.as_deref())) + }) + .collect(), + }) + } +} + +fn parse_transcript(response: &TranscriptResponse) -> Vec { + response + .transcript + .iter() + .filter_map(|item| { + let text = nonempty(item.text.as_deref())?; + let start_ms = item.timestamp.as_deref().map(hhmmss_to_ms).unwrap_or(0); + Some(TranscriptSegment { + speaker: item + .speaker + .as_ref() + .and_then(|speaker| { + nonempty(speaker.display_name.as_deref()) + .or_else(|| nonempty(speaker.name.as_deref())) + }) + .unwrap_or_default(), + text, + start_ms, + end_ms: start_ms, + }) + }) + .collect() +} + +fn summary_text(response: &SummaryResponse) -> Option { + nonempty(response.markdown.as_deref()) + .or_else(|| { + response + .default_summary + .as_ref() + .and_then(|summary| nonempty(summary.markdown.as_deref())) + }) + .or_else(|| nonempty(response.summary.as_deref())) +} diff --git a/crates/meeting-import/src/google_meet.rs b/crates/meeting-import/src/google_meet.rs new file mode 100644 index 0000000000..d5fbb4dd50 --- /dev/null +++ b/crates/meeting-import/src/google_meet.rs @@ -0,0 +1,247 @@ +use anlg_http::HttpClient; +use serde::Deserialize; + +use crate::error::Error; +use crate::json::{ImportedMeeting, TranscriptSegment, nonempty}; +use crate::time::{duration_or_timestamp_to_ms, rfc3339_to_ms}; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ListConferenceRecordsResponse { + #[serde(default, rename = "conferenceRecords")] + pub conference_records: Vec, + #[serde(default, rename = "nextPageToken")] + pub next_page_token: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ConferenceRecord { + #[serde(default)] + pub name: Option, + #[serde(default, rename = "startTime")] + pub start_time: Option, + #[serde(default)] + pub space: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ListTranscriptsResponse { + #[serde(default)] + pub transcripts: Vec, + #[serde(default, rename = "nextPageToken")] + pub next_page_token: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct MeetTranscript { + #[serde(default)] + pub name: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ListEntriesResponse { + #[serde(default, rename = "transcriptEntries")] + pub transcript_entries: Vec, + #[serde(default, rename = "nextPageToken")] + pub next_page_token: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct TranscriptEntry { + #[serde(default)] + pub participant: Option, + #[serde(default)] + pub text: Option, + #[serde(default, rename = "startTime")] + pub start_time: Option, + #[serde(default, rename = "endTime")] + pub end_time: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ListParticipantsResponse { + #[serde(default)] + pub participants: Vec, + #[serde(default, rename = "nextPageToken")] + pub next_page_token: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Participant { + #[serde(default)] + pub name: Option, + #[serde(default, rename = "signedinUser")] + pub signed_in_user: Option, + #[serde(default, rename = "anonymousUser")] + pub anonymous_user: Option, + #[serde(default, rename = "phoneUser")] + pub phone_user: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct NamedUser { + #[serde(default, rename = "displayName")] + pub display_name: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Space { + #[serde(default, rename = "displayName")] + pub display_name: Option, + #[serde(default, rename = "meetingCode")] + pub meeting_code: Option, +} + +pub struct GoogleMeetClient { + http: C, +} + +impl GoogleMeetClient { + pub fn new(http: C) -> Self { + Self { http } + } + + pub async fn list_conference_records( + &self, + page_token: Option<&str>, + ) -> Result { + let mut path = "/v2/conferenceRecords?pageSize=50".to_string(); + if let Some(token) = page_token.filter(|token| !token.is_empty()) { + path.push_str("&pageToken="); + path.push_str(&urlencoding::encode(token)); + } + let bytes = self.http.get(&path).await.map_err(Error::Http)?; + Ok(serde_json::from_slice(&bytes)?) + } + + pub async fn list_transcripts( + &self, + conference_name: &str, + page_token: Option<&str>, + ) -> Result { + let mut path = format!("/v2/{conference_name}/transcripts?pageSize=50"); + if let Some(token) = page_token.filter(|token| !token.is_empty()) { + path.push_str("&pageToken="); + path.push_str(&urlencoding::encode(token)); + } + match self.http.get(&path).await { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(_) => Ok(ListTranscriptsResponse::default()), + } + } + + pub async fn list_entries( + &self, + transcript_name: &str, + page_token: Option<&str>, + ) -> Result { + let mut path = format!("/v2/{transcript_name}/entries?pageSize=100"); + if let Some(token) = page_token.filter(|token| !token.is_empty()) { + path.push_str("&pageToken="); + path.push_str(&urlencoding::encode(token)); + } + match self.http.get(&path).await { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(_) => Ok(ListEntriesResponse::default()), + } + } + + pub async fn list_participants( + &self, + conference_name: &str, + page_token: Option<&str>, + ) -> Result { + let mut path = format!("/v2/{conference_name}/participants?pageSize=100"); + if let Some(token) = page_token.filter(|token| !token.is_empty()) { + path.push_str("&pageToken="); + path.push_str(&urlencoding::encode(token)); + } + match self.http.get(&path).await { + Ok(bytes) => Ok(serde_json::from_slice(&bytes)?), + Err(_) => Ok(ListParticipantsResponse::default()), + } + } + + pub async fn get_space(&self, space_name: &str) -> Result, Error> { + match self.http.get(&format!("/v2/{space_name}")).await { + Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), + Err(_) => Ok(None), + } + } +} + +impl ConferenceRecord { + pub fn resource_name(&self) -> Option<&str> { + self.name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + pub fn imported( + &self, + title: String, + transcript: Vec, + ) -> Option { + let id = self.resource_name()?.to_string(); + Some(ImportedMeeting { + id, + title, + start_time: nonempty(self.start_time.as_deref()), + url: None, + summary: None, + notes: None, + transcript, + action_items: Vec::new(), + }) + } +} + +impl Participant { + pub fn display_name(&self) -> Option { + self.signed_in_user + .as_ref() + .and_then(|user| nonempty(user.display_name.as_deref())) + .or_else(|| { + self.anonymous_user + .as_ref() + .and_then(|user| nonempty(user.display_name.as_deref())) + }) + .or_else(|| { + self.phone_user + .as_ref() + .and_then(|user| nonempty(user.display_name.as_deref())) + }) + } +} + +pub fn entry_segment( + entry: &TranscriptEntry, + origin_ms: u64, + speaker: String, +) -> Option { + let text = nonempty(entry.text.as_deref())?; + let start_ms = entry + .start_time + .as_deref() + .map(|value| duration_or_timestamp_to_ms(value, origin_ms)) + .unwrap_or(0); + let end_ms = entry + .end_time + .as_deref() + .map(|value| duration_or_timestamp_to_ms(value, origin_ms)) + .unwrap_or(start_ms); + Some(TranscriptSegment { + speaker, + text, + start_ms, + end_ms, + }) +} + +pub fn conference_origin_ms(record: &ConferenceRecord) -> u64 { + record + .start_time + .as_deref() + .and_then(rfc3339_to_ms) + .unwrap_or(0) +} diff --git a/crates/meeting-import/src/json.rs b/crates/meeting-import/src/json.rs new file mode 100644 index 0000000000..43db21985f --- /dev/null +++ b/crates/meeting-import/src/json.rs @@ -0,0 +1,146 @@ +use serde::Serialize; +use serde_json::{Map, Value}; + +pub use anlg_zoom::TranscriptSegment; + +#[derive(Debug, Clone, Default, Serialize)] +pub struct ImportedMeeting { + pub id: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub transcript: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub action_items: Vec, +} + +pub struct ImportFile { + pub path: String, + pub name: String, + pub content: String, +} + +pub fn meeting_has_content(meeting: &ImportedMeeting) -> bool { + !meeting.transcript.is_empty() + || nonempty(meeting.summary.as_deref()).is_some() + || nonempty(meeting.notes.as_deref()).is_some() + || meeting + .action_items + .iter() + .any(|item| !item.trim().is_empty()) +} + +pub fn meeting_file(provider: &str, meeting: &ImportedMeeting) -> Result { + meeting_file_with_scheme("oauth", provider, meeting) +} + +pub fn meeting_file_with_scheme( + scheme: &str, + provider: &str, + meeting: &ImportedMeeting, +) -> Result { + let safe_id = safe_file_component(&meeting.id); + let mut record = Map::new(); + record.insert("id".into(), Value::String(meeting.id.clone())); + record.insert("title".into(), Value::String(meeting.title.clone())); + if let Some(start_time) = nonempty(meeting.start_time.as_deref()) { + record.insert("start_time".into(), Value::String(start_time)); + } + if let Some(url) = nonempty(meeting.url.as_deref()) { + record.insert("url".into(), Value::String(url)); + } + if let Some(summary) = nonempty(meeting.summary.as_deref()) { + record.insert("summary".into(), Value::String(summary)); + } + if let Some(notes) = nonempty(meeting.notes.as_deref()) { + record.insert("notes".into(), Value::String(notes)); + } + if !meeting.transcript.is_empty() { + record.insert( + "transcript".into(), + serde_json::to_value(&meeting.transcript).unwrap_or(Value::Array(Vec::new())), + ); + } + let action_items = meeting + .action_items + .iter() + .map(|item| item.trim()) + .filter(|item| !item.is_empty()) + .map(|item| Value::String(item.to_string())) + .collect::>(); + if !action_items.is_empty() { + record.insert("action_items".into(), Value::Array(action_items)); + } + + Ok(ImportFile { + path: format!("{scheme}://{provider}/{safe_id}.json"), + name: format!("{safe_id}.json"), + content: serde_json::to_string(&Value::Object(record)) + .map_err(|error| format!("could not save a meeting: {error}"))?, + }) +} + +pub fn nonempty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +pub fn safe_file_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_') { + character + } else { + '-' + } + }) + .collect::(); + if sanitized.is_empty() { + "meeting".to_string() + } else { + sanitized + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_importable_meeting_json() { + let meeting = ImportedMeeting { + id: "meeting/one".into(), + title: "Weekly planning".into(), + start_time: Some("2026-08-01T10:00:00Z".into()), + url: Some("https://example.com/rec".into()), + summary: Some("We agreed to ship.".into()), + notes: None, + transcript: vec![TranscriptSegment { + speaker: "Ada".into(), + text: "Let's ship it.".into(), + start_ms: 1_000, + end_ms: 2_000, + }], + action_items: vec!["Prepare the release".into()], + }; + let file = meeting_file("fathom", &meeting).unwrap(); + assert_eq!(file.path, "oauth://fathom/meeting-one.json"); + let cli = meeting_file_with_scheme("cli", "plaud", &meeting).unwrap(); + assert_eq!(cli.path, "cli://plaud/meeting-one.json"); + let json: Value = serde_json::from_str(&file.content).unwrap(); + assert_eq!(json["id"], "meeting/one"); + assert_eq!(json["title"], "Weekly planning"); + assert_eq!(json["action_items"][0], "Prepare the release"); + assert_eq!(json["transcript"][0]["speaker"], "Ada"); + } +} diff --git a/crates/meeting-import/src/lib.rs b/crates/meeting-import/src/lib.rs new file mode 100644 index 0000000000..078c315e96 --- /dev/null +++ b/crates/meeting-import/src/lib.rs @@ -0,0 +1,17 @@ +mod error; +pub mod fathom; +pub mod google_meet; +mod json; +pub mod plaud; +pub mod plaud_cli; +pub mod teams; +mod time; +pub mod webex; + +pub use anlg_zoom::{TranscriptSegment, parse_vtt}; +pub use error::Error; +pub use json::{ + ImportFile, ImportedMeeting, meeting_file, meeting_file_with_scheme, meeting_has_content, + nonempty, safe_file_component, +}; +pub use time::{duration_or_timestamp_to_ms, hhmmss_to_ms, rfc3339_to_ms}; diff --git a/crates/meeting-import/src/plaud.rs b/crates/meeting-import/src/plaud.rs new file mode 100644 index 0000000000..d304064ec9 --- /dev/null +++ b/crates/meeting-import/src/plaud.rs @@ -0,0 +1,470 @@ +use crate::json::{ImportedMeeting, nonempty}; +use crate::time::hhmmss_to_ms; +use anlg_zoom::TranscriptSegment; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListedFile { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct FileDetails { + pub id: String, + pub name: String, + pub created_at: Option, + pub start_at: Option, + pub transcript_available: bool, + pub summary_available: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Account { + pub id: Option, + pub email: Option, + pub name: Option, +} + +impl Account { + pub fn display_name(&self) -> String { + nonempty(self.email.as_deref()) + .or_else(|| nonempty(self.name.as_deref())) + .or_else(|| nonempty(self.id.as_deref())) + .unwrap_or_else(|| "plaud".to_string()) + } +} + +pub fn strip_ansi(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + while let Some(character) = chars.next() { + if character == '\u{1b}' && chars.peek() == Some(&'[') { + chars.next(); + for next in chars.by_ref() { + if next.is_ascii_alphabetic() { + break; + } + } + continue; + } + output.push(character); + } + output +} + +pub fn parse_login_url(output: &str) -> Option { + strip_ansi(output).lines().find_map(|line| { + let trimmed = line.trim(); + let url = trimmed + .split_whitespace() + .find(|token| token.starts_with("https://") || token.starts_with("http://"))?; + let url = url.trim_end_matches(['.', ',', ')', ']']); + if url.contains("plaud.ai") { + Some(url.to_string()) + } else { + None + } + }) +} + +pub fn parse_me(output: &str) -> Account { + let fields = parse_labeled_fields(output); + Account { + id: nonempty(fields.get("id").map(String::as_str)), + email: nonempty(fields.get("email").map(String::as_str)), + name: nonempty(fields.get("name").map(String::as_str)) + .or_else(|| nonempty(fields.get("nickname").map(String::as_str))), + } +} + +pub fn parse_files_table(output: &str) -> Vec { + strip_ansi(output) + .lines() + .filter_map(parse_files_row) + .collect() +} + +pub fn parse_file_details(output: &str) -> FileDetails { + let fields = parse_labeled_fields(output); + FileDetails { + id: nonempty(fields.get("id").map(String::as_str)).unwrap_or_default(), + name: nonempty(fields.get("name").map(String::as_str)).unwrap_or_default(), + created_at: nonempty(fields.get("created_at").map(String::as_str)), + start_at: nonempty(fields.get("start_at").map(String::as_str)).filter(|value| value != "-"), + transcript_available: is_available(fields.get("transcript").map(String::as_str)), + summary_available: is_available(fields.get("summary").map(String::as_str)), + } +} + +pub fn parse_transcript(output: &str) -> Vec { + let text = strip_ansi(output); + if is_unavailable_message(&text, "transcript") { + return Vec::new(); + } + + text.lines() + .filter_map(|line| parse_transcript_line(line.trim())) + .collect() +} + +pub fn parse_summary(output: &str) -> Option { + let text = strip_ansi(output); + if is_unavailable_message(&text, "summary") || is_unavailable_message(&text, "note") { + return None; + } + + let mut lines = text.lines().peekable(); + while let Some(line) = lines.peek() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("Summary:") || trimmed.starts_with("Notes:") { + lines.next(); + continue; + } + break; + } + + let body = lines.collect::>().join("\n"); + nonempty(Some(body.trim_end())) +} + +pub fn parse_action_items(markdown: &str) -> Vec { + let mut items = Vec::new(); + let mut in_action_section = false; + for line in markdown.lines() { + let trimmed = line.trim(); + if let Some(heading) = heading_text(trimmed) { + in_action_section = heading.to_ascii_lowercase().contains("action"); + continue; + } + let Some(item) = list_item_text(trimmed) else { + continue; + }; + if in_action_section || checkbox_item(trimmed) { + items.push(item); + } + } + items +} + +pub fn meeting_from_cli( + details: &FileDetails, + transcript: Vec, + summary: Option, +) -> ImportedMeeting { + let action_items = summary + .as_deref() + .map(parse_action_items) + .unwrap_or_default(); + ImportedMeeting { + id: details.id.clone(), + title: if details.name.trim().is_empty() { + details.id.clone() + } else { + details.name.clone() + }, + start_time: details + .start_at + .clone() + .or_else(|| details.created_at.clone()), + url: None, + summary, + notes: None, + transcript, + action_items, + } +} + +fn parse_labeled_fields(output: &str) -> std::collections::HashMap { + strip_ansi(output) + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + let (key, value) = trimmed.split_once(':')?; + let key = key.trim().to_ascii_lowercase(); + if key.is_empty() || key.contains(' ') { + return None; + } + Some((key, value.trim().to_string())) + }) + .collect() +} + +fn parse_files_row(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() + || trimmed.starts_with("Files on this page") + || trimmed.starts_with("Page ") + || trimmed.chars().all(|character| { + character == 'โ”€' || character == '-' || character == ' ' || character == 'โ”' + }) + { + return None; + } + + let id = trimmed.split_whitespace().next()?.to_string(); + if !is_file_id(&id) { + return None; + } + + let rest = trimmed[id.len()..].trim_start(); + let mut parts = rest.split_whitespace().collect::>(); + if parts.len() >= 2 { + let date = parts[parts.len() - 2]; + if date == "-" || is_iso_date(date) { + parts.truncate(parts.len() - 2); + return Some(ListedFile { + id, + name: parts.join(" ").trim_end_matches('โ€ฆ').trim().to_string(), + }); + } + } + + Some(ListedFile { + id, + name: rest.trim_end_matches('โ€ฆ').trim().to_string(), + }) +} + +fn parse_transcript_line(line: &str) -> Option { + let (times, rest) = line.strip_prefix('[')?.split_once(']')?; + let (start, end) = times.split_once('-')?; + let start_ms = hhmmss_to_ms(start.trim()); + let end_ms = hhmmss_to_ms(end.trim()); + let rest = rest.trim(); + if rest.is_empty() { + return None; + } + let (speaker, text) = match rest.split_once(": ") { + Some((speaker, text)) if speaker.len() <= 80 && !speaker.contains('[') => { + (speaker.trim().to_string(), text.trim().to_string()) + } + _ => (String::new(), rest.to_string()), + }; + nonempty(Some(&text)).map(|text| TranscriptSegment { + speaker, + text, + start_ms, + end_ms: if end_ms > start_ms { + end_ms + } else { + start_ms.saturating_add(1_000) + }, + }) +} + +fn heading_text(line: &str) -> Option<&str> { + let stripped = line.trim_start_matches('#').trim(); + if stripped.len() < line.len() && !stripped.is_empty() { + Some(stripped) + } else { + None + } +} + +fn list_item_text(line: &str) -> Option { + let content = line + .strip_prefix("- ") + .or_else(|| line.strip_prefix("* ")) + .or_else(|| { + let (number, rest) = line.split_once('.')?; + if number.chars().all(|character| character.is_ascii_digit()) { + Some(rest.trim_start()) + } else { + None + } + })?; + let text = content + .trim() + .trim_start_matches("[ ]") + .trim_start_matches("[x]") + .trim_start_matches("[X]") + .trim(); + nonempty(Some(text)) +} + +fn checkbox_item(line: &str) -> bool { + let trimmed = line.trim_start_matches(['-', '*']).trim_start(); + trimmed.starts_with("[ ]") || trimmed.starts_with("[x]") || trimmed.starts_with("[X]") +} + +fn is_available(value: Option<&str>) -> bool { + value.is_some_and(|value| value.to_ascii_lowercase().starts_with("available")) +} + +fn is_unavailable_message(text: &str, _kind: &str) -> bool { + let lowered = text.to_ascii_lowercase(); + lowered.contains("not available") + || lowered.contains("hasn't been generated") + || lowered.contains("no \"") +} + +fn is_file_id(value: &str) -> bool { + let len = value.len(); + (8..=64).contains(&len) + && value.chars().all(|character| { + character.is_ascii_alphanumeric() || character == '-' || character == '_' + }) +} + +fn is_iso_date(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[8..10].iter().all(u8::is_ascii_digit) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_ansi_and_reads_login_url() { + let output = "\u{1b}[33mCould not open browser. Open this URL manually:\n https://web.plaud.ai/platform/oauth?state=abc\u{1b}[0m"; + assert_eq!( + parse_login_url(output).as_deref(), + Some("https://web.plaud.ai/platform/oauth?state=abc") + ); + } + + #[test] + fn parses_account_fields() { + let output = "\nUser Info:\n\n id: user-1\n email: ada@example.com\n name: Ada\n"; + assert_eq!( + parse_me(output), + Account { + id: Some("user-1".into()), + email: Some("ada@example.com".into()), + name: Some("Ada".into()), + } + ); + } + + #[test] + fn parses_files_table_rows() { + let id = "abcdef1234567890abcdef1234567890ab"; + let output = format!( + "Files on this page: 2\n\n {:<34} {:<36} {:<12} DURATION\n {}\n {id} Weekly standup 2026-08-01 32m10s\n shorterid Catch-up 2026-07-30 1h05m\n\nPage 1\n", + "ID", + "NAME", + "DATE", + "โ”€".repeat(90), + ); + let files = parse_files_table(&output); + assert_eq!( + files, + vec![ + ListedFile { + id: id.into(), + name: "Weekly standup".into(), + }, + ListedFile { + id: "shorterid".into(), + name: "Catch-up".into(), + }, + ] + ); + } + + #[test] + fn parses_file_details_and_availability() { + let output = " +File Details: + + id: rec-1 + name: Weekly standup + created_at: 2026-08-01T10:00:00Z + start_at: 2026-08-01T09:59:00Z + duration: 32m10s + serial_number: - + audio: available + transcript: available + summary: unavailable +"; + assert_eq!( + parse_file_details(output), + FileDetails { + id: "rec-1".into(), + name: "Weekly standup".into(), + created_at: Some("2026-08-01T10:00:00Z".into()), + start_at: Some("2026-08-01T09:59:00Z".into()), + transcript_available: true, + summary_available: false, + } + ); + } + + #[test] + fn parses_timestamped_transcript() { + let output = " +Transcript: Weekly standup + +[00:01 - 00:04] Ada: Let's ship it. +[00:04 - 00:08] Tom: Agreed. +"; + let transcript = parse_transcript(output); + assert_eq!(transcript.len(), 2); + assert_eq!(transcript[0].speaker, "Ada"); + assert_eq!(transcript[0].text, "Let's ship it."); + assert_eq!(transcript[0].start_ms, 1_000); + assert_eq!(transcript[0].end_ms, 4_000); + assert_eq!(transcript[1].speaker, "Tom"); + } + + #[test] + fn ignores_missing_transcript() { + assert!( + parse_transcript( + "No \"transaction\" transcript for this recording. Available: (none)." + ) + .is_empty() + ); + } + + #[test] + fn parses_summary_and_action_items() { + let output = " +Summary: Weekly standup + +## Overview +We agreed to ship. + +## Action items +- Prepare the release +- [ ] Ping design +"; + let summary = parse_summary(output).unwrap(); + assert!(summary.contains("We agreed to ship.")); + assert_eq!( + parse_action_items(&summary), + vec!["Prepare the release", "Ping design"] + ); + } + + #[test] + fn builds_importable_meeting() { + let details = FileDetails { + id: "rec-1".into(), + name: "Weekly standup".into(), + created_at: Some("2026-08-01T10:00:00Z".into()), + start_at: None, + transcript_available: true, + summary_available: true, + }; + let meeting = meeting_from_cli( + &details, + vec![TranscriptSegment { + speaker: "Ada".into(), + text: "Let's ship it.".into(), + start_ms: 1_000, + end_ms: 4_000, + }], + Some("## Action items\n- Prepare the release".into()), + ); + assert_eq!(meeting.title, "Weekly standup"); + assert_eq!(meeting.start_time.as_deref(), Some("2026-08-01T10:00:00Z")); + assert_eq!(meeting.action_items, vec!["Prepare the release"]); + } +} diff --git a/crates/meeting-import/src/plaud_cli.rs b/crates/meeting-import/src/plaud_cli.rs new file mode 100644 index 0000000000..e57d4ee0a9 --- /dev/null +++ b/crates/meeting-import/src/plaud_cli.rs @@ -0,0 +1,370 @@ +use crate::json::{ImportFile, meeting_file_with_scheme, meeting_has_content}; +use crate::plaud::{ + ListedFile, meeting_from_cli, parse_file_details, parse_files_table, parse_summary, + parse_transcript, strip_ansi, +}; +use serde::Deserialize; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::process::Command; +use tokio::sync::Mutex; + +const PROVIDER_ID: &str = "plaud"; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(45); +const MAX_FILE_PAGES: usize = 20; +const FILE_PAGE_SIZE: u32 = 100; +const MEETING_BATCH_SIZE: usize = 25; + +#[derive(Debug, Deserialize)] +struct CliToken { + #[serde(default)] + kind: Option, + #[serde(default)] + binary: Option, +} + +pub fn resolve_binary() -> Result { + find_plaud_in_dirs(search_dirs()).ok_or_else(|| { + "Install the Plaud CLI (`npm install -g @plaud-ai/cli`) and try again. Anarlog looks for `plaud` on PATH and in common Node.js bin folders.".to_string() + }) +} + +pub fn is_allowed_binary(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| matches!(name, "plaud" | "plaud.exe" | "plaud.cmd" | "plaud.ps1")) +} + +pub fn binary_from_token_json(token_json: &str) -> Result { + let token = serde_json::from_str::(token_json) + .map_err(|_| "Reconnect Plaud to keep importing".to_string())?; + if token.kind.as_deref() != Some("cli") { + return Err("Reconnect Plaud to keep importing".to_string()); + } + let Some(binary) = token.binary.filter(|value| !value.is_empty()) else { + return Err("Reconnect Plaud to keep importing".to_string()); + }; + let path = PathBuf::from(binary); + if path.is_file() && is_allowed_binary(&path) { + Ok(path) + } else { + resolve_binary() + } +} + +pub fn command(binary: &Path, args: &[&str]) -> Command { + let mut command = Command::new(binary); + command + .args(args) + .kill_on_drop(true) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env("NO_COLOR", "1") + .env("CI", "1") + .env("PATH", path_for_binary(binary)); + command +} + +pub async fn run(binary: &Path, args: &[&str], timeout: Duration) -> Result { + let mut child = command(binary, args) + .spawn() + .map_err(|error| missing_cli_error(&error.to_string()))?; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let output = Arc::new(Mutex::new(String::new())); + let capture = if let (Some(stdout), Some(stderr)) = (stdout, stderr) { + let captured = output.clone(); + Some(tokio::spawn(async move { + capture_output(stdout, stderr, captured).await; + })) + } else { + None + }; + + let status = tokio::select! { + status = child.wait() => status.map_err(|error| format!("could not run Plaud CLI: {error}"))?, + _ = tokio::time::sleep(timeout) => { + let _ = child.kill().await; + return Err("Plaud CLI timed out. Try again.".to_string()); + } + }; + if let Some(capture) = capture { + let _ = capture.await; + } + let captured = output.lock().await.clone(); + if !status.success() { + return Err(command_error(status.code().unwrap_or(1), &captured)); + } + Ok(captured) +} + +pub async fn import_new_meetings( + binary: &Path, + known_meeting_ids: &HashSet, +) -> Result<(Vec, Vec), String> { + let listed = list_files(binary).await?; + let mut files = Vec::new(); + let mut warnings = Vec::new(); + + for listed in listed { + if known_meeting_ids.contains(&listed.id) { + continue; + } + if files.len() >= MEETING_BATCH_SIZE { + warnings.push( + "Imported the newest Plaud recordings first. Sync again to bring in older ones." + .to_string(), + ); + break; + } + + match import_file(binary, &listed.id).await { + Ok(Some(file)) => files.push(file), + Ok(None) => {} + Err(error) => warnings.push(error), + } + } + + Ok((files, warnings)) +} + +async fn list_files(binary: &Path) -> Result, String> { + let mut files = Vec::new(); + for page in 1..=MAX_FILE_PAGES { + let stdout = run( + binary, + &[ + "files", + "--page", + &page.to_string(), + "--page-size", + &FILE_PAGE_SIZE.to_string(), + ], + COMMAND_TIMEOUT, + ) + .await?; + let page_files = parse_files_table(&stdout); + let count = page_files.len(); + files.extend(page_files); + if count < FILE_PAGE_SIZE as usize { + break; + } + } + Ok(files) +} + +async fn import_file(binary: &Path, id: &str) -> Result, String> { + let details = parse_file_details(&run(binary, &["file", id], COMMAND_TIMEOUT).await?); + if details.id.is_empty() { + return Err(format!("Plaud recording {id} could not be read")); + } + + let transcript = match run(binary, &["transcript", id], COMMAND_TIMEOUT).await { + Ok(stdout) => parse_transcript(&stdout), + Err(_) => Vec::new(), + }; + let summary = match run(binary, &["summary", id], COMMAND_TIMEOUT).await { + Ok(stdout) => parse_summary(&stdout), + Err(_) => None, + }; + + let meeting = meeting_from_cli(&details, transcript, summary); + if !meeting_has_content(&meeting) { + return Ok(None); + } + meeting_file_with_scheme("cli", PROVIDER_ID, &meeting).map(Some) +} + +async fn capture_output(mut stdout: Out, mut stderr: Err, output: Arc>) +where + Out: AsyncReadExt + Unpin, + Err: AsyncReadExt + Unpin, +{ + let stdout_output = output.clone(); + let stderr_output = output; + let stdout_task = async move { + let mut buf = [0_u8; 1024]; + loop { + match stdout.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(count) => stdout_output + .lock() + .await + .push_str(&String::from_utf8_lossy(&buf[..count])), + } + } + }; + let stderr_task = async move { + let mut buf = [0_u8; 1024]; + loop { + match stderr.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(count) => stderr_output + .lock() + .await + .push_str(&String::from_utf8_lossy(&buf[..count])), + } + } + }; + tokio::join!(stdout_task, stderr_task); +} + +fn search_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(path) = std::env::var_os("PATH") { + dirs.extend(std::env::split_paths(&path)); + } + if !cfg!(test) { + dirs.extend(extra_bin_dirs()); + } + dirs +} + +fn extra_bin_dirs() -> Vec { + let mut dirs = vec![ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/opt/homebrew/opt/node/bin"), + ]; + if let Some(home) = dirs::home_dir() { + dirs.push(home.join(".local/bin")); + dirs.push(home.join(".volta/bin")); + dirs.push(home.join(".asdf/shims")); + dirs.push(home.join(".npm-global/bin")); + dirs.push(home.join(".fnm/aliases/default/bin")); + if let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) { + let mut versions = entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path().join("bin")) + .filter(|path| path.is_dir()) + .collect::>(); + versions.sort(); + dirs.extend(versions); + } + } + dirs +} + +fn path_for_binary(binary: &Path) -> std::ffi::OsString { + let mut dirs = Vec::new(); + if let Some(parent) = binary.parent() { + dirs.push(parent.to_path_buf()); + } + dirs.extend(extra_bin_dirs()); + if let Some(path) = std::env::var_os("PATH") { + dirs.extend(std::env::split_paths(&path)); + } + std::env::join_paths(dirs).unwrap_or_else(|_| std::env::var_os("PATH").unwrap_or_default()) +} + +fn find_plaud_in_dirs(dirs: impl IntoIterator) -> Option { + let names = if cfg!(windows) { + ["plaud.exe", "plaud.cmd", "plaud"].as_slice() + } else { + ["plaud"].as_slice() + }; + for dir in dirs { + for name in names { + let candidate = dir.join(name); + if candidate.is_file() && is_allowed_binary(&candidate) { + return Some(candidate); + } + } + } + None +} + +fn command_error(status: i32, output: &str) -> String { + let output = strip_ansi(output); + if status == 2 || output.contains("AUTH_FAILED") { + return "Plaud sign-in expired. Connect again.".to_string(); + } + let detail = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("unknown error"); + format!("could not run the Plaud CLI: {detail}") +} + +fn missing_cli_error(detail: &str) -> String { + if detail.contains("No such file") || detail.contains("not found") { + "Install the Plaud CLI (`npm install -g @plaud-ai/cli`) and try again.".to_string() + } else { + format!("could not run the Plaud CLI: {detail}") + } +} + +pub fn is_auth_error(error: &str) -> bool { + error.contains("AUTH_FAILED") || error.contains("sign-in expired") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_arbitrary_binaries() { + assert!(is_allowed_binary(Path::new("/usr/local/bin/plaud"))); + assert!(!is_allowed_binary(Path::new("/usr/bin/bash"))); + } + + #[cfg(unix)] + #[tokio::test] + async fn imports_from_official_cli_output() { + let dir = std::env::temp_dir().join(format!( + "plaud-cli-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let binary = dir.join("plaud"); + std::fs::write( + &binary, + r#"#!/bin/sh +set -e +cmd="$1" +case "$cmd" in + me) + printf '\nUser Info:\n\n id: user-1\n email: ada@example.com\n name: Ada\n' + ;; + files) + printf 'Files on this page: 1\n\n rec1234567890 Weekly standup 2026-08-01 32m10s\n\nPage 1\n' + ;; + file) + printf '\nFile Details:\n\n id: rec1234567890\n name: Weekly standup\n created_at: 2026-08-01T10:00:00Z\n start_at: -\n transcript: available\n summary: available\n' + ;; + transcript) + printf '\nTranscript: Weekly standup\n\n[00:01 - 00:04] Ada: Let us ship it.\n' + ;; + summary) + printf '\nSummary: Weekly standup\n\n## Action items\n- Prepare the release\n' + ;; + *) + exit 1 + ;; +esac +"#, + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&binary).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&binary, permissions).unwrap(); + + let (files, warnings) = import_new_meetings(&binary, &HashSet::new()).await.unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "cli://plaud/rec1234567890.json"); + assert!(files[0].content.contains("Weekly standup")); + assert!(files[0].content.contains("Prepare the release")); + assert!(warnings.is_empty()); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/meeting-import/src/teams.rs b/crates/meeting-import/src/teams.rs new file mode 100644 index 0000000000..036b136557 --- /dev/null +++ b/crates/meeting-import/src/teams.rs @@ -0,0 +1,177 @@ +use anlg_http::HttpClient; +use serde::Deserialize; + +use crate::error::Error; +use crate::json::{ImportedMeeting, nonempty}; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct GraphCollection { + #[serde(default)] + pub value: Vec, + #[serde(default, rename = "@odata.nextLink")] + pub next_link: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct CalendarEvent { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub subject: Option, + #[serde(default)] + pub start: Option, + #[serde(default, rename = "isOnlineMeeting")] + pub is_online_meeting: Option, + #[serde(default, rename = "onlineMeeting")] + pub online_meeting: Option, + #[serde(default, rename = "webLink")] + pub web_link: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct DateTimeTimeZone { + #[serde(default, rename = "dateTime")] + pub date_time: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct OnlineMeetingInfo { + #[serde(default, rename = "joinUrl")] + pub join_url: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct OnlineMeeting { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub subject: Option, + #[serde(default, rename = "joinWebUrl")] + pub join_web_url: Option, + #[serde(default, rename = "startDateTime")] + pub start_date_time: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct CallTranscript { + #[serde(default)] + pub id: Option, +} + +pub struct TeamsClient { + http: C, +} + +impl TeamsClient { + pub fn new(http: C) -> Self { + Self { http } + } + + pub async fn list_calendar_view( + &self, + start: &str, + end: &str, + next_link: Option<&str>, + ) -> Result, Error> { + let path = if let Some(next_link) = next_link.filter(|value| !value.is_empty()) { + graph_proxy_path(next_link).unwrap_or_else(|| calendar_view_path(start, end)) + } else { + calendar_view_path(start, end) + }; + let bytes = self.http.get(&path).await.map_err(Error::Http)?; + Ok(serde_json::from_slice(&bytes)?) + } + + pub async fn find_online_meeting( + &self, + join_url: &str, + ) -> Result, Error> { + let filter = format!("JoinWebUrl eq '{}'", join_url.replace('\'', "''")); + let path = format!( + "/v1.0/me/onlineMeetings?$filter={}", + urlencoding::encode(&filter) + ); + match self.http.get(&path).await { + Ok(bytes) => { + let page: GraphCollection = serde_json::from_slice(&bytes)?; + Ok(page.value.into_iter().next()) + } + Err(_) => Ok(None), + } + } + + pub async fn list_transcripts(&self, meeting_id: &str) -> Result, Error> { + let path = format!( + "/v1.0/me/onlineMeetings/{}/transcripts", + urlencoding::encode(meeting_id) + ); + let bytes = self.http.get(&path).await.map_err(Error::Http)?; + let page: GraphCollection = serde_json::from_slice(&bytes)?; + Ok(page.value) + } +} + +impl CalendarEvent { + pub fn join_url(&self) -> Option<&str> { + self.online_meeting + .as_ref() + .and_then(|meeting| meeting.join_url.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + pub fn imported( + &self, + meeting: &OnlineMeeting, + transcript: Vec, + ) -> Option { + let id = meeting + .id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + self.id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + })? + .to_string(); + let title = nonempty(self.subject.as_deref()) + .or_else(|| nonempty(meeting.subject.as_deref())) + .unwrap_or_else(|| "Teams meeting".to_string()); + Some(ImportedMeeting { + id, + title, + start_time: nonempty(meeting.start_date_time.as_deref()).or_else(|| { + self.start + .as_ref() + .and_then(|start| nonempty(start.date_time.as_deref())) + }), + url: nonempty(meeting.join_web_url.as_deref()) + .or_else(|| nonempty(self.join_url())) + .or_else(|| nonempty(self.web_link.as_deref())), + summary: None, + notes: None, + transcript, + action_items: Vec::new(), + }) + } +} + +fn calendar_view_path(start: &str, end: &str) -> String { + format!( + "/v1.0/me/calendarView?startDateTime={}&endDateTime={}&$top=50&$select=id,subject,start,isOnlineMeeting,onlineMeeting,webLink&$filter=isOnlineMeeting eq true", + urlencoding::encode(start), + urlencoding::encode(end) + ) +} + +fn graph_proxy_path(next_link: &str) -> Option { + let url = url::Url::parse(next_link).ok()?; + let path = url.path().to_string(); + match url.query() { + Some(query) => Some(format!("{path}?{query}")), + None => Some(path), + } +} diff --git a/crates/meeting-import/src/time.rs b/crates/meeting-import/src/time.rs new file mode 100644 index 0000000000..6e0a16282c --- /dev/null +++ b/crates/meeting-import/src/time.rs @@ -0,0 +1,59 @@ +pub fn hhmmss_to_ms(value: &str) -> u64 { + let parts = value + .split(':') + .map(|part| part.parse::().ok()) + .collect::>>(); + let Some(parts) = parts else { + return 0; + }; + let (hours, minutes, seconds) = match parts.as_slice() { + [minutes, seconds] => (0, *minutes, *seconds), + [hours, minutes, seconds] => (*hours, *minutes, *seconds), + _ => return 0, + }; + hours + .saturating_mul(3_600_000) + .saturating_add(minutes.saturating_mul(60_000)) + .saturating_add(seconds.saturating_mul(1_000)) +} + +pub fn duration_or_timestamp_to_ms(value: &str, origin_ms: u64) -> u64 { + let trimmed = value.trim(); + if let Some(seconds) = trimmed.strip_suffix('s') + && let Ok(parsed) = seconds.parse::() + { + return (parsed * 1_000.0).round().clamp(0.0, u64::MAX as f64) as u64; + } + if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(trimmed) { + let millis = parsed.timestamp_millis().max(0) as u64; + return millis.saturating_sub(origin_ms); + } + 0 +} + +pub fn rfc3339_to_ms(value: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(value.trim()) + .ok() + .map(|parsed| parsed.timestamp_millis().max(0) as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_clock_timestamps() { + assert_eq!(hhmmss_to_ms("00:05:32"), 332_000); + assert_eq!(hhmmss_to_ms("05:32"), 332_000); + } + + #[test] + fn parses_google_durations() { + assert_eq!(duration_or_timestamp_to_ms("1.500s", 0), 1_500); + let origin = rfc3339_to_ms("2026-08-01T10:00:00Z").unwrap(); + assert_eq!( + duration_or_timestamp_to_ms("2026-08-01T10:00:01.500Z", origin), + 1_500 + ); + } +} diff --git a/crates/meeting-import/src/webex.rs b/crates/meeting-import/src/webex.rs new file mode 100644 index 0000000000..f54f81399f --- /dev/null +++ b/crates/meeting-import/src/webex.rs @@ -0,0 +1,91 @@ +use anlg_http::HttpClient; +use serde::Deserialize; + +use crate::error::Error; +use crate::json::{ImportedMeeting, nonempty}; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ListTranscriptsResponse { + #[serde(default)] + pub items: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct WebexTranscript { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub meeting_id: Option, + #[serde(rename = "meetingId")] + #[serde(default)] + pub meeting_id_camel: Option, + #[serde(default)] + pub meeting_topic: Option, + #[serde(rename = "meetingTopic")] + #[serde(default)] + pub meeting_topic_camel: Option, + #[serde(default)] + pub start_time: Option, + #[serde(rename = "startTime")] + #[serde(default)] + pub start_time_camel: Option, + #[serde(default)] + pub vtt_download_link: Option, + #[serde(rename = "vttDownloadLink")] + #[serde(default)] + pub vtt_download_link_camel: Option, +} + +pub struct WebexClient { + http: C, +} + +impl WebexClient { + pub fn new(http: C) -> Self { + Self { http } + } + + pub async fn list_transcripts(&self, offset: u32) -> Result { + let path = format!("/v1/meetingTranscripts?max=100&offset={offset}"); + let bytes = self.http.get(&path).await.map_err(Error::Http)?; + Ok(serde_json::from_slice(&bytes)?) + } +} + +impl WebexTranscript { + pub fn transcript_id(&self) -> Option<&str> { + self.id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + pub fn vtt_download_link(&self) -> Option<&str> { + self.vtt_download_link + .as_deref() + .or(self.vtt_download_link_camel.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + } + + pub fn imported( + &self, + transcript: Vec, + ) -> Option { + let id = self.transcript_id()?.to_string(); + let title = nonempty(self.meeting_topic.as_deref()) + .or_else(|| nonempty(self.meeting_topic_camel.as_deref())) + .unwrap_or_else(|| "Webex meeting".to_string()); + Some(ImportedMeeting { + id, + title, + start_time: nonempty(self.start_time.as_deref()) + .or_else(|| nonempty(self.start_time_camel.as_deref())), + url: None, + summary: None, + notes: None, + transcript, + action_items: Vec::new(), + }) + } +} diff --git a/crates/zoom/Cargo.toml b/crates/zoom/Cargo.toml new file mode 100644 index 0000000000..620b1fe40e --- /dev/null +++ b/crates/zoom/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zoom" +version = "0.1.0" +edition = "2024" + +[dependencies] +anlg-http = { workspace = true } + +chrono = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +urlencoding = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } diff --git a/crates/zoom/src/client.rs b/crates/zoom/src/client.rs new file mode 100644 index 0000000000..583f25a3f2 --- /dev/null +++ b/crates/zoom/src/client.rs @@ -0,0 +1,45 @@ +use anlg_http::HttpClient; +use chrono::NaiveDate; + +use crate::error::Error; +use crate::types::{ListRecordingsResponse, MeetingSummary}; + +pub struct ZoomClient { + http: C, +} + +impl ZoomClient { + pub fn new(http: C) -> Self { + Self { http } + } + + pub async fn list_user_recordings( + &self, + from: NaiveDate, + to: NaiveDate, + next_page_token: Option<&str>, + ) -> Result { + let mut path = format!("/users/me/recordings?page_size=300&from={from}&to={to}"); + if let Some(token) = next_page_token.filter(|token| !token.is_empty()) { + path.push_str("&next_page_token="); + path.push_str(&urlencoding::encode(token)); + } + + let bytes = self.http.get(&path).await.map_err(Error::Http)?; + Ok(serde_json::from_slice(&bytes)?) + } + + pub async fn get_meeting_summary( + &self, + meeting_id: &str, + ) -> Result, Error> { + let path = format!( + "/meetings/{}/meeting_summary", + urlencoding::encode(meeting_id) + ); + match self.http.get(&path).await { + Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), + Err(_) => Ok(None), + } + } +} diff --git a/crates/zoom/src/error.rs b/crates/zoom/src/error.rs new file mode 100644 index 0000000000..7ba5d35df2 --- /dev/null +++ b/crates/zoom/src/error.rs @@ -0,0 +1,10 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("HTTP client error: {0}")] + Http(Box), + + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), +} diff --git a/crates/zoom/src/lib.rs b/crates/zoom/src/lib.rs new file mode 100644 index 0000000000..b5d1c434b4 --- /dev/null +++ b/crates/zoom/src/lib.rs @@ -0,0 +1,11 @@ +mod client; +mod error; +mod meeting; +mod transcript; +mod types; + +pub use client::ZoomClient; +pub use error::Error; +pub use meeting::{meeting_has_content, meeting_json}; +pub use transcript::parse_vtt; +pub use types::*; diff --git a/crates/zoom/src/meeting.rs b/crates/zoom/src/meeting.rs new file mode 100644 index 0000000000..739eef5ab9 --- /dev/null +++ b/crates/zoom/src/meeting.rs @@ -0,0 +1,167 @@ +use serde_json::{Map, Value}; + +use crate::types::{MeetingSummary, RecordingMeeting, TranscriptSegment}; + +pub fn meeting_has_content( + summary: Option<&MeetingSummary>, + transcript: &[TranscriptSegment], +) -> bool { + !transcript.is_empty() || summary.is_some_and(summary_has_content) +} + +pub fn meeting_json( + recording: &RecordingMeeting, + summary: Option<&MeetingSummary>, + transcript: Vec, +) -> Option { + let id = recording.external_id()?; + let title = recording + .topic + .as_deref() + .filter(|value| !value.is_empty()) + .unwrap_or("Zoom meeting"); + let mut record = Map::new(); + record.insert("id".into(), Value::String(id)); + if let Some(meeting_id) = recording.meeting_id() { + record.insert("meeting_id".into(), Value::String(meeting_id)); + } + record.insert("title".into(), Value::String(title.to_string())); + if let Some(start_time) = recording.start_time.clone() { + record.insert("start_time".into(), Value::String(start_time)); + } + if let Some(url) = recording.share_url.clone() { + record.insert("url".into(), Value::String(url)); + } + if let Some(summary) = summary { + if let Some(text) = summary_markdown(summary) { + record.insert("summary".into(), Value::String(text)); + } + let action_items = summary + .next_steps + .iter() + .map(|item| item.trim()) + .filter(|item| !item.is_empty()) + .map(|item| Value::String(item.to_string())) + .collect::>(); + if !action_items.is_empty() { + record.insert("action_items".into(), Value::Array(action_items)); + } + } + if !transcript.is_empty() { + record.insert( + "transcript".into(), + serde_json::to_value(transcript).unwrap_or(Value::Array(Vec::new())), + ); + } + Some(Value::Object(record)) +} + +fn summary_has_content(summary: &MeetingSummary) -> bool { + [ + summary.edited_summary.as_deref(), + summary.summary_overview.as_deref(), + summary.summary_title.as_deref(), + ] + .into_iter() + .flatten() + .any(|value| !value.trim().is_empty()) + || summary.summary_details.iter().any(|detail| { + detail + .summary + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + }) + || summary + .next_steps + .iter() + .any(|item| !item.trim().is_empty()) +} + +fn summary_markdown(summary: &MeetingSummary) -> Option { + let mut sections = Vec::new(); + if let Some(title) = nonempty(summary.summary_title.as_deref()) { + sections.push(title); + } + if let Some(overview) = nonempty(summary.edited_summary.as_deref()) + .or_else(|| nonempty(summary.summary_overview.as_deref())) + { + sections.push(overview); + } + for detail in &summary.summary_details { + let Some(text) = nonempty(detail.summary.as_deref()) else { + continue; + }; + if let Some(label) = nonempty(detail.label.as_deref()) { + sections.push(format!("**{label}**\n{text}")); + } else { + sections.push(text); + } + } + if sections.is_empty() { + None + } else { + Some(sections.join("\n\n")) + } +} + +fn nonempty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{RecordingFile, SummaryDetail}; + + #[test] + fn builds_importable_meeting_json() { + let recording = RecordingMeeting { + uuid: Some("meeting/one".into()), + id: Some(serde_json::json!(123)), + topic: Some("Weekly planning".into()), + start_time: Some("2026-08-01T10:00:00Z".into()), + share_url: Some("https://zoom.us/rec/share/abc".into()), + recording_files: vec![RecordingFile { + file_type: Some("TRANSCRIPT".into()), + download_url: Some("https://zoom.us/rec/download/abc".into()), + ..RecordingFile::default() + }], + ..RecordingMeeting::default() + }; + let summary = MeetingSummary { + summary_overview: Some("We agreed to ship.".into()), + next_steps: vec!["Prepare the release".into()], + summary_details: vec![SummaryDetail { + label: Some("Decision".into()), + summary: Some("Ship this week.".into()), + }], + ..MeetingSummary::default() + }; + + let json = meeting_json( + &recording, + Some(&summary), + vec![TranscriptSegment { + speaker: "Ada".into(), + text: "Let's ship it.".into(), + start_ms: 1_000, + end_ms: 2_000, + }], + ) + .unwrap(); + + assert_eq!(json["id"], "meeting/one"); + assert_eq!(json["title"], "Weekly planning"); + assert!( + json["summary"] + .as_str() + .unwrap() + .contains("We agreed to ship.") + ); + assert_eq!(json["action_items"][0], "Prepare the release"); + assert_eq!(json["transcript"][0]["speaker"], "Ada"); + } +} diff --git a/crates/zoom/src/transcript.rs b/crates/zoom/src/transcript.rs new file mode 100644 index 0000000000..d0eab9d348 --- /dev/null +++ b/crates/zoom/src/transcript.rs @@ -0,0 +1,130 @@ +use crate::types::TranscriptSegment; + +pub fn parse_vtt(content: &str) -> Vec { + let body = content + .trim_start() + .strip_prefix("WEBVTT") + .map(|rest| rest.trim_start_matches(['\u{feff}', ' ', '\t'])) + .map(|rest| rest.trim_start_matches(['\r', '\n'])) + .unwrap_or(content); + + body.split("\n\n") + .flat_map(|block| block.split("\r\n\r\n")) + .filter_map(parse_vtt_block) + .collect() +} + +fn parse_vtt_block(block: &str) -> Option { + let lines = block + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + let timing_index = lines.iter().position(|line| line.contains("-->"))?; + let mut parts = lines[timing_index].split("-->"); + let start = parse_timestamp(parts.next()?.trim())?; + let end = parse_timestamp(parts.next()?.split_whitespace().next().unwrap_or(""))?; + let raw_text = lines[(timing_index + 1)..].join(" "); + if raw_text.is_empty() { + return None; + } + + let (speaker, text) = if let Some(rest) = raw_text.strip_prefix("')?; + (speaker.trim().to_string(), strip_caption_markup(text)) + } else if let Some((speaker, text)) = raw_text.split_once(": ") + && speaker.len() <= 60 + && !speaker.contains('>') + { + (speaker.trim().to_string(), strip_caption_markup(text)) + } else { + (String::new(), strip_caption_markup(&raw_text)) + }; + + if text.is_empty() { + return None; + } + + Some(TranscriptSegment { + speaker, + text, + start_ms: start, + end_ms: end, + }) +} + +fn strip_caption_markup(value: &str) -> String { + let mut text = String::new(); + let mut chars = value.chars().peekable(); + while let Some(character) = chars.next() { + if character == '<' { + for next in chars.by_ref() { + if next == '>' { + break; + } + } + } else { + text.push(character); + } + } + text.trim().to_string() +} + +fn parse_timestamp(value: &str) -> Option { + let (time, fraction) = match value.split_once('.') { + Some((time, fraction)) => (time, fraction), + None => value.split_once(',').unwrap_or((value, "0")), + }; + let parts = time + .split(':') + .map(|part| part.parse::().ok()) + .collect::>>()?; + let (hours, minutes, seconds) = match parts.as_slice() { + [minutes, seconds] => (0, *minutes, *seconds), + [hours, minutes, seconds] => (*hours, *minutes, *seconds), + _ => return None, + }; + let millis = fraction + .chars() + .take(3) + .collect::() + .parse::() + .unwrap_or(0); + Some( + hours + .saturating_mul(3_600_000) + .saturating_add(minutes.saturating_mul(60_000)) + .saturating_add(seconds.saturating_mul(1_000)) + .saturating_add(millis), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_speaker_labeled_vtt() { + let segments = parse_vtt( + "WEBVTT\n\n00:00:01.000 --> 00:00:04.000\nAda: Let's ship it.\n\n00:00:04.500 --> 00:00:08.000\nSounds good.\n", + ); + + assert_eq!( + segments, + vec![ + TranscriptSegment { + speaker: "Ada".into(), + text: "Let's ship it.".into(), + start_ms: 1_000, + end_ms: 4_000, + }, + TranscriptSegment { + speaker: "Sam".into(), + text: "Sounds good.".into(), + start_ms: 4_500, + end_ms: 8_000, + }, + ] + ); + } +} diff --git a/crates/zoom/src/types.rs b/crates/zoom/src/types.rs new file mode 100644 index 0000000000..3c58f860fd --- /dev/null +++ b/crates/zoom/src/types.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ListRecordingsResponse { + #[serde(default)] + pub meetings: Vec, + #[serde(default)] + pub next_page_token: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RecordingMeeting { + #[serde(default)] + pub uuid: Option, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub topic: Option, + #[serde(default)] + pub start_time: Option, + #[serde(default)] + pub duration: Option, + #[serde(default)] + pub share_url: Option, + #[serde(default)] + pub recording_files: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RecordingFile { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub file_type: Option, + #[serde(default)] + pub file_extension: Option, + #[serde(default)] + pub recording_type: Option, + #[serde(default)] + pub download_url: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MeetingSummary { + #[serde(default)] + pub meeting_host_email: Option, + #[serde(default)] + pub summary_title: Option, + #[serde(default)] + pub summary_overview: Option, + #[serde(default)] + pub edited_summary: Option, + #[serde(default)] + pub summary_details: Vec, + #[serde(default)] + pub next_steps: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SummaryDetail { + #[serde(default)] + pub label: Option, + #[serde(default)] + pub summary: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TranscriptSegment { + pub speaker: String, + pub text: String, + pub start_ms: u64, + pub end_ms: u64, +} + +impl RecordingMeeting { + pub fn meeting_id(&self) -> Option { + match &self.id { + Some(serde_json::Value::String(value)) if !value.is_empty() => Some(value.clone()), + Some(serde_json::Value::Number(value)) => Some(value.to_string()), + _ => None, + } + } + + pub fn external_id(&self) -> Option { + self.uuid + .as_deref() + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| self.meeting_id()) + } + + pub fn transcript_file(&self) -> Option<&RecordingFile> { + self.recording_files.iter().find(|file| { + let file_type = file.file_type.as_deref().unwrap_or(""); + let recording_type = file.recording_type.as_deref().unwrap_or(""); + let extension = file.file_extension.as_deref().unwrap_or(""); + file_type.eq_ignore_ascii_case("TRANSCRIPT") + || recording_type.eq_ignore_ascii_case("audio_transcript") + || extension.eq_ignore_ascii_case("VTT") + }) + } +} diff --git a/docs/imports.mdx b/docs/imports.mdx index 1da4d9904b..c86cc50a41 100644 --- a/docs/imports.mdx +++ b/docs/imports.mdx @@ -17,7 +17,9 @@ Meeting-history import turns official exports from another meeting assistant int 4. Select one or more JSON, CSV, Markdown, text, VTT, or SRT export files. 5. Review the result shown when the import finishes. -Anarlog detects supported meeting-assistant apps installed on your computer. Supported sources include Granola, Circleback, Fireflies.ai, Fathom, Krisp, Otter.ai, Notion AI Meeting Notes, Zoom, Microsoft Teams, Google Meet, Webex, ChatGPT Record, Slack Huddles, and others. +Anarlog detects supported meeting-assistant apps installed on your computer. Supported sources include Granola, Circleback, Fireflies.ai, Fathom, Krisp, Otter.ai, Notion AI Meeting Notes, Zoom, Microsoft Teams, Google Meet, Webex, Plaud, ChatGPT Record, Slack Huddles, and others. + +For assistants that support a direct connection (OAuth, MCP, or a local CLI such as Plaud), choose **Connect & import**. Anarlog signs in through the official integration โ€” for Plaud it runs `plaud` on your machine โ€” and keeps importing new meetings while it is running. The exact content depends on the source export. Anarlog keeps supported titles, dates, notes, summaries, transcripts, speakers, and participants when those fields are available. diff --git a/packages/api-client/src/generated/index.ts b/packages/api-client/src/generated/index.ts index 17a41811b4..bfc5b612bb 100644 --- a/packages/api-client/src/generated/index.ts +++ b/packages/api-client/src/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { cancelAttachmentBackupDeletion, canStartTrial, claimE2EeIdentity, claimSharedNoteHandoff, consumeE2EeDeviceEnrollment, createCredentials, createKey, createLinkSharedNoteHandoff, createPublicSharedNoteHandoff, createReplicaCredentials, createSession, deleteAccount, deleteAttachmentBackup, deleteConnection, deleteDevice, deleteSnapshot, diarize, downloadAccessSharedAttachment, downloadAttachmentBackup, downloadHandoffSharedAttachment, downloadLinkSharedAttachment, downloadPublicSharedAttachment, editSessionShareSnapshot, exportMeeting, finalizeAttachmentBackup, finalizeSharedAttachment, getDevices, getHistory, getJobById, getMediaUploadUrl, getMeeting, getSettings, getTranscript, getWorkspaceE2EeKeyRecipients, githubListRepos, githubListTickets, googleGetAttachment, googleGetMessage, googleGetProfile, googleGetThread, googleListCalendars, googleListEvents, googleListHistory, googleListLabels, googleListMessages, googleListThreads, grantAttachmentBackupUpload, grantSharedAttachmentUpload, identify, linearCreateIssue, linearListTeams, linearListTickets, listConnections, listKeys, listMeetings, listSlackChannels, llmChatCompletions, nangoWebhook, notionAppendUpdate, notionSearchPages, type Options, outlookListCalendars, outlookListEvents, promoteAttachmentBackup, publishE2EeWitness, publishSessionShareSnapshot, publishSnapshot, readCurrentAttachmentBackup, readE2EeWitness, readLinkSharedNote, readLinkSharedNotePreview, readPublicSharedNote, readPublicSharedNotePreview, readShortLinkSharedNotePreview, registerE2EeDeviceEnrollment, reserveAttachmentBackup, reserveSharedAttachment, revokeKey, sealE2EeDeviceEnrollment, sendSharedNoteInvitationEmail, sendSharedNoteRecapEmail, sendSlackMessage, setWorkspaceE2EeKey, startTrial, sttListenBatch, sttListenStream, sttStatus, updateSettings, voiceprint, waitE2EeWitness, whoami } from './sdk.gen'; -export type { AccessRole, ActionItem, ApiKeyInfo, Attachment, AttachmentBackupDownload, AttachmentBackupObjectRequest, AttachmentBackupUploadGrant, AttendeeResponseStatus, AttendeeType, AutoDeclineMode, BatchAlternatives, BatchChannel, BatchListenSuccessResponse, BatchResponse, BatchResults, BatchStreamEvent, BatchWord, BirthdayProperties, BirthdayPropertyType, BodyType, Calendar, CalendarColor, CalendarListEntry, CalendarNotification, CancelAttachmentBackupDeletionData, CancelAttachmentBackupDeletionErrors, CancelAttachmentBackupDeletionResponse, CancelAttachmentBackupDeletionResponses, CanceledAttachmentBackupDeletion, CanStartTrialData, CanStartTrialErrors, CanStartTrialReason, CanStartTrialResponse, CanStartTrialResponse2, CanStartTrialResponses, CasSessionShareSnapshotRequest, CharTask, ChatStatus, ClaimE2EeIdentityData, ClaimE2EeIdentityErrors, ClaimE2EeIdentityRequest, ClaimE2EeIdentityResponse, ClaimE2EeIdentityResponses, ClaimSharedNoteHandoffData, ClaimSharedNoteHandoffErrors, ClaimSharedNoteHandoffResponse, ClaimSharedNoteHandoffResponses, ClientOptions, CloudApiSettings, CloudsyncCredentialResponse, CloudsyncCredentials, CloudsyncWorkspace, CollectionPage, CollectionRef, ConferenceCreateRequest, ConferenceCreateRequestStatus, ConferenceCreateStatusCode, ConferenceData, ConferenceProperties, ConferenceSolution, ConferenceSolutionKey, ConferenceSolutionType, Confidence, ConnectionItem, ConsumeE2EeDeviceEnrollmentData, ConsumeE2EeDeviceEnrollmentErrors, ConsumeE2EeDeviceEnrollmentRequest, ConsumeE2EeDeviceEnrollmentResponse, ConsumeE2EeDeviceEnrollmentResponses, CreateApiKeyBody, CreateCredentialsData, CreateCredentialsErrors, CreateCredentialsResponse, CreateCredentialsResponses, CreatedApiKey, CreateEventRequest, CreateKeyData, CreateKeyError, CreateKeyErrors, CreateKeyResponse, CreateKeyResponses, CreateLinkSharedNoteHandoffData, CreateLinkSharedNoteHandoffErrors, CreateLinkSharedNoteHandoffResponse, CreateLinkSharedNoteHandoffResponses, CreatePublicSharedNoteHandoffData, CreatePublicSharedNoteHandoffErrors, CreatePublicSharedNoteHandoffResponse, CreatePublicSharedNoteHandoffResponses, CreateReplicaCredentialsData, CreateReplicaCredentialsErrors, CreateReplicaCredentialsResponse, CreateReplicaCredentialsResponses, CreateSessionData, CreateSessionErrors, CreateSessionRequest, CreateSessionResponse, CreateSessionResponses, CurrentAttachmentBackup, CustomLocation, DateTimeTimeZone, DayOfWeek, DeleteAccountData, DeleteAccountErrors, DeleteAccountResponse, DeleteAccountResponse2, DeleteAccountResponses, DeleteAttachmentBackupData, DeleteAttachmentBackupErrors, DeleteAttachmentBackupRequest, DeleteAttachmentBackupResponse, DeleteAttachmentBackupResponses, DeleteConnectionData, DeleteConnectionErrors, DeleteConnectionRequest, DeleteConnectionResponse, DeleteConnectionResponse2, DeleteConnectionResponses, DeleteDeviceData, DeleteDeviceErrors, DeleteDeviceResponse, DeleteDeviceResponses, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotResponses, DiarizationJob, DiarizationJobOutput, DiarizationSegment, DiarizeData, DiarizeErrors, DiarizeRequest, DiarizeRequestModel, DiarizeResponse, DiarizeResponses, Document, DownloadAccessSharedAttachmentData, DownloadAccessSharedAttachmentErrors, DownloadAccessSharedAttachmentResponse, DownloadAccessSharedAttachmentResponses, DownloadAttachmentBackupData, DownloadAttachmentBackupErrors, DownloadAttachmentBackupResponse, DownloadAttachmentBackupResponses, DownloadHandoffSharedAttachmentData, DownloadHandoffSharedAttachmentErrors, DownloadHandoffSharedAttachmentResponse, DownloadHandoffSharedAttachmentResponses, DownloadLinkSharedAttachmentData, DownloadLinkSharedAttachmentErrors, DownloadLinkSharedAttachmentResponse, DownloadLinkSharedAttachmentResponses, DownloadPublicSharedAttachmentData, DownloadPublicSharedAttachmentErrors, DownloadPublicSharedAttachmentResponse, DownloadPublicSharedAttachmentResponses, E2EeDeviceEnrollmentPackage, E2EeDeviceEnrollmentStatus, E2EeDeviceEnrollmentSummary, E2EeIdentity, E2EeWitnessEvent, E2EeWitnessPage, E2EeWitnessPageEvent, E2EeWitnessWaitResponse, EditSessionShareSnapshotData, EditSessionShareSnapshotErrors, EditSessionShareSnapshotResponse, EditSessionShareSnapshotResponses, EmailAddress, EntryPoint, EntryPointType, ErrorBody, ErrorEnvelope, EventAttachment, EventDateTime, EventOrderBy, EventPerson, EventShowAs, EventSource, EventStatus, ExportMeetingData, ExportMeetingError, ExportMeetingErrors, ExportMeetingResponses, ExtendedProperties, FinalizeAttachmentBackupData, FinalizeAttachmentBackupErrors, FinalizeAttachmentBackupResponse, FinalizeAttachmentBackupResponses, FinalizedAttachmentBackup, FinalizedSharedAttachment, FinalizeSharedAttachmentData, FinalizeSharedAttachmentErrors, FinalizeSharedAttachmentResponse, FinalizeSharedAttachmentResponses, FocusTimeProperties, Gadget, GadgetDisplay, GetDevicesData, GetDevicesErrors, GetDevicesResponse, GetDevicesResponses, GetHistoryData, GetHistoryError, GetHistoryErrors, GetHistoryResponse, GetHistoryResponses, GetJobByIdData, GetJobByIdErrors, GetJobByIdResponse, GetJobByIdResponses, GetJobsResponse, GetMediaUploadUrl, GetMediaUploadUrlData, GetMediaUploadUrlErrors, GetMediaUploadUrlResponse, GetMediaUploadUrlResponses, GetMeetingData, GetMeetingError, GetMeetingErrors, GetMeetingResponse, GetMeetingResponses, GetMessageRequest, GetSettingsData, GetSettingsError, GetSettingsErrors, GetSettingsResponse, GetSettingsResponses, GetThreadRequest, GetTranscriptData, GetTranscriptError, GetTranscriptErrors, GetTranscriptResponse, GetTranscriptResponses, GetWorkspaceE2EeKeyRecipientsData, GetWorkspaceE2EeKeyRecipientsErrors, GetWorkspaceE2EeKeyRecipientsResponse, GetWorkspaceE2EeKeyRecipientsResponses, GithubListReposData, GithubListReposErrors, GitHubListReposRequest, GithubListReposResponse, GithubListReposResponses, GithubListTicketsData, GithubListTicketsErrors, GitHubListTicketsRequest, GithubListTicketsResponse, GithubListTicketsResponses, GoogleAttendee, GoogleCreateEventBody, GoogleEvent, GoogleEventType, GoogleGetAttachmentData, GoogleGetAttachmentErrors, GoogleGetAttachmentRequest, GoogleGetAttachmentResponse, GoogleGetAttachmentResponses, GoogleGetMessageData, GoogleGetMessageErrors, GoogleGetMessageRequest, GoogleGetMessageResponse, GoogleGetMessageResponses, GoogleGetProfileData, GoogleGetProfileErrors, GoogleGetProfileRequest, GoogleGetProfileResponse, GoogleGetProfileResponses, GoogleGetThreadData, GoogleGetThreadErrors, GoogleGetThreadRequest, GoogleGetThreadResponse, GoogleGetThreadResponses, GoogleListCalendarsData, GoogleListCalendarsErrors, GoogleListCalendarsRequest, GoogleListCalendarsResponse, GoogleListCalendarsResponse2, GoogleListCalendarsResponses, GoogleListEventsData, GoogleListEventsErrors, GoogleListEventsRequest, GoogleListEventsResponse, GoogleListEventsResponse2, GoogleListEventsResponses, GoogleListHistoryData, GoogleListHistoryErrors, GoogleListHistoryRequest, GoogleListHistoryResponse, GoogleListHistoryResponses, GoogleListLabelsData, GoogleListLabelsErrors, GoogleListLabelsRequest, GoogleListLabelsResponse, GoogleListLabelsResponses, GoogleListMessagesData, GoogleListMessagesErrors, GoogleListMessagesRequest, GoogleListMessagesResponse, GoogleListMessagesResponses, GoogleListThreadsData, GoogleListThreadsErrors, GoogleListThreadsRequest, GoogleListThreadsResponse, GoogleListThreadsResponses, GrantAttachmentBackupUploadData, GrantAttachmentBackupUploadErrors, GrantAttachmentBackupUploadRequest, GrantAttachmentBackupUploadResponse, GrantAttachmentBackupUploadResponses, GrantSharedAttachmentUploadData, GrantSharedAttachmentUploadErrors, GrantSharedAttachmentUploadRequest, GrantSharedAttachmentUploadResponse, GrantSharedAttachmentUploadResponses, History, HistoryLabelAdded, HistoryLabelRemoved, HistoryMessageAdded, HistoryMessageDeleted, HistoryType, IdentificationJobOutput, IdentificationSegment, IdentificationVoiceprint, IdentifyData, IdentifyErrors, IdentifyJob, IdentifyRequest, IdentifyRequestModel, IdentifyResponse, IdentifyResponses, Importance, Interval, ItemBody, JobCreated, JobListItem, JobStatus, Label, LabelColor, LabelListVisibility, LabelRef, LabelType, LegacyCloudsyncCredentials, LegacySessionShareSnapshotRequest, LinearCreateIssueData, LinearCreateIssueErrors, LinearCreateIssueRequest, LinearCreateIssueResponse, LinearCreateIssueResponses, LinearListTeamsData, LinearListTeamsErrors, LinearListTeamsRequest, LinearListTeamsResponse, LinearListTeamsResponses, LinearListTicketsData, LinearListTicketsErrors, LinearListTicketsRequest, LinearListTicketsResponse, LinearListTicketsResponses, ListConnectionsData, ListConnectionsErrors, ListConnectionsResponse, ListConnectionsResponse2, ListConnectionsResponses, ListenCallbackRequest, ListenCallbackResponse, ListEventsRequest, ListHistoryRequest, ListHistoryResponse, ListKeysData, ListKeysError, ListKeysErrors, ListKeysResponse, ListKeysResponses, ListLabelsResponse, ListMeetingsData, ListMeetingsError, ListMeetingsErrors, ListMeetingsResponse, ListMeetingsResponses, ListMessagesRequest, ListMessagesResponse, ListSlackChannelsData, ListSlackChannelsErrors, ListSlackChannelsResponse, ListSlackChannelsResponses, ListThreadsRequest, ListThreadsResponse, LlmChatCompletionsData, LlmChatCompletionsErrors, LlmChatCompletionsResponses, Location, LocationType, MatchingOptions, MediaResponse, Meeting, MeetingExport, MeetingListItem, MeetingPage, MeetingRecapEmailRequest, Message, MessageFormat, MessageListVisibility, MessagePart, MessagePartBody, MessagePartHeader, MessageRef, NangoWebhookData, NangoWebhookErrors, NangoWebhookResponse, NangoWebhookResponses, NotificationMethod, NotificationSettings, NotificationType, NotionAppendUpdateData, NotionAppendUpdateErrors, NotionAppendUpdateRequest, NotionAppendUpdateResponse, NotionAppendUpdateResponse2, NotionAppendUpdateResponses, NotionPage, NotionPagesResponse, NotionSearchPagesData, NotionSearchPagesErrors, NotionSearchPagesRequest, NotionSearchPagesResponse, NotionSearchPagesResponses, OfficeLocation, OnlineMeetingInfo, OnlineMeetingProviderType, OutlookAttendee, OutlookCreateEventBody, OutlookEvent, OutlookEventType, OutlookGeoCoordinates, OutlookListCalendarsData, OutlookListCalendarsErrors, OutlookListCalendarsRequest, OutlookListCalendarsResponse, OutlookListCalendarsResponse2, OutlookListCalendarsResponses, OutlookListEventsData, OutlookListEventsErrors, OutlookListEventsRequest, OutlookListEventsResponse, OutlookListEventsResponse2, OutlookListEventsResponses, OutOfOfficeProperties, Pagination, Participant, PatternedRecurrence, PersonRef, PhysicalAddress, PipelineStatus, Profile, PromoteAttachmentBackupData, PromoteAttachmentBackupErrors, PromoteAttachmentBackupRequest, PromoteAttachmentBackupResponse, PromoteAttachmentBackupResponses, PromotedAttachmentBackup, PublishE2EeWitnessData, PublishE2EeWitnessErrors, PublishE2EeWitnessRequest, PublishE2EeWitnessResponse, PublishE2EeWitnessResponse2, PublishE2EeWitnessResponses, PublishedSessionShareSnapshot, PublishSessionShareSnapshotData, PublishSessionShareSnapshotErrors, PublishSessionShareSnapshotRequest, PublishSessionShareSnapshotResponse, PublishSessionShareSnapshotResponses, PublishSnapshotData, PublishSnapshotError, PublishSnapshotErrors, PublishSnapshotResponse, PublishSnapshotResponses, PullRequestDetail, ReadCurrentAttachmentBackupData, ReadCurrentAttachmentBackupErrors, ReadCurrentAttachmentBackupResponse, ReadCurrentAttachmentBackupResponses, ReadE2EeWitnessData, ReadE2EeWitnessErrors, ReadE2EeWitnessResponse, ReadE2EeWitnessResponses, ReadLinkSharedNoteData, ReadLinkSharedNoteErrors, ReadLinkSharedNotePreviewData, ReadLinkSharedNotePreviewErrors, ReadLinkSharedNotePreviewResponse, ReadLinkSharedNotePreviewResponses, ReadLinkSharedNoteResponse, ReadLinkSharedNoteResponses, ReadPublicSharedNoteData, ReadPublicSharedNoteErrors, ReadPublicSharedNotePreviewData, ReadPublicSharedNotePreviewErrors, ReadPublicSharedNotePreviewResponse, ReadPublicSharedNotePreviewResponses, ReadPublicSharedNoteResponse, ReadPublicSharedNoteResponses, ReadShortLinkSharedNotePreviewData, ReadShortLinkSharedNotePreviewErrors, ReadShortLinkSharedNotePreviewResponse, ReadShortLinkSharedNotePreviewResponses, Recipient, RecurrencePattern, RecurrencePatternType, RecurrenceRange, RecurrenceRangeType, RegisterE2EeDeviceEnrollmentData, RegisterE2EeDeviceEnrollmentErrors, RegisterE2EeDeviceEnrollmentRequest, RegisterE2EeDeviceEnrollmentResponse, RegisterE2EeDeviceEnrollmentResponse2, RegisterE2EeDeviceEnrollmentResponses, Reminder, ReminderMethod, Reminders, ReplicaCredentials, ReserveAttachmentBackupData, ReserveAttachmentBackupErrors, ReserveAttachmentBackupRequest, ReserveAttachmentBackupResponse, ReserveAttachmentBackupResponses, ReservedAttachmentBackup, ReservedSharedAttachment, ReserveSharedAttachmentData, ReserveSharedAttachmentErrors, ReserveSharedAttachmentRequest, ReserveSharedAttachmentResponse, ReserveSharedAttachmentResponses, ResponseStatus, ResponseType, RevokeKeyData, RevokeKeyError, RevokeKeyErrors, RevokeKeyResponse, RevokeKeyResponses, ScheduledAttachmentBackupDeletion, SealE2EeDeviceEnrollmentData, SealE2EeDeviceEnrollmentErrors, SealE2EeDeviceEnrollmentResponse, SealE2EeDeviceEnrollmentResponses, SendMessageResponse, SendSharedNoteInvitationEmailData, SendSharedNoteInvitationEmailErrors, SendSharedNoteInvitationEmailResponse, SendSharedNoteInvitationEmailResponses, SendSharedNoteRecapEmailData, SendSharedNoteRecapEmailErrors, SendSharedNoteRecapEmailResponse, SendSharedNoteRecapEmailResponses, SendSlackMessageData, SendSlackMessageErrors, SendSlackMessageResponse, SendSlackMessageResponses, Sensitivity, SessionMode, SessionResponse, SetWorkspaceE2EeKeyData, SetWorkspaceE2EeKeyErrors, SetWorkspaceE2EeKeyRequest, SetWorkspaceE2EeKeyResponse, SetWorkspaceE2EeKeyResponses, SetWorkspaceE2EeKeyResult, SharedAttachmentDownload, SharedAttachmentObjectRequest, SharedAttachmentUploadGrant, SharedNoteAttachment, SharedNoteHandoff, SharedNoteHandoffAttachmentRequest, SharedNoteHandoffClaimRequest, SharedNoteInvitationEmailRequest, SharedNoteLinkPreview, SharedNoteLinkPreviewRequest, SharedNoteLinkRequest, SharedNotePreview, SharedNoteSnapshot, SlackChannel, SlackChannelsResponse, SlackSendRequest, SnapshotReceipt, StartTrialData, StartTrialErrors, StartTrialReason, StartTrialResponse, StartTrialResponse2, StartTrialResponses, StreamAlternatives, StreamChannel, StreamMetadata, StreamModelInfo, StreamResponse, StreamWord, SttListenBatchData, SttListenBatchErrors, SttListenBatchResponse, SttListenBatchResponses, SttListenStreamData, SttListenStreamErrors, SttStatusData, SttStatusErrors, SttStatusResponse, SttStatusResponse2, SttStatusResponses, SyncDeviceRow, SyncDevicesResponse, TestResponse, Thread, ThreadRef, TicketKind, TicketPage, TicketPriority, TicketProviderType, TicketState, TicketSummary, Transcript, TranscriptionConfiguration, TranscriptionConfigurationModel, TranscriptionSegment, TranscriptPage, Transparency, UpdateSettingsBody, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponse, UpdateSettingsResponses, Visibility, Voiceprint, VoiceprintData, VoiceprintErrors, VoiceprintJob, VoiceprintJobResults, VoiceprintRequest, VoiceprintRequestModel, VoiceprintResponse, VoiceprintResponses, WaitE2EeWitnessData, WaitE2EeWitnessErrors, WaitE2EeWitnessResponse, WaitE2EeWitnessResponses, WebhookResponse, WeekIndex, WhoamiData, WhoamiErrors, WhoAmIItem, WhoamiResponse, WhoAmIResponse, WhoamiResponses, WorkingLocationProperties, WorkingLocationType, WorkspaceE2EeKeyGrant, WorkspaceE2EeKeyGrantUpload, WorkspaceE2EeKeyRecipient } from './types.gen'; +export { cancelAttachmentBackupDeletion, canStartTrial, claimE2EeIdentity, claimSharedNoteHandoff, consumeE2EeDeviceEnrollment, createCredentials, createKey, createLinkSharedNoteHandoff, createPublicSharedNoteHandoff, createReplicaCredentials, createSession, deleteAccount, deleteAttachmentBackup, deleteConnection, deleteDevice, deleteSnapshot, diarize, downloadAccessSharedAttachment, downloadAttachmentBackup, downloadHandoffSharedAttachment, downloadLinkSharedAttachment, downloadPublicSharedAttachment, editSessionShareSnapshot, exportMeeting, fathomImportMeetings, finalizeAttachmentBackup, finalizeSharedAttachment, getDevices, getHistory, getJobById, getMediaUploadUrl, getMeeting, getSettings, getTranscript, getWorkspaceE2EeKeyRecipients, githubListRepos, githubListTickets, googleGetAttachment, googleGetMessage, googleGetProfile, googleGetThread, googleListCalendars, googleListEvents, googleListHistory, googleListLabels, googleListMessages, googleListThreads, googleMeetImportMeetings, grantAttachmentBackupUpload, grantSharedAttachmentUpload, identify, linearCreateIssue, linearListTeams, linearListTickets, listConnections, listKeys, listMeetings, listSlackChannels, llmChatCompletions, microsoftTeamsImportMeetings, nangoWebhook, notionAppendUpdate, notionImportMeetings, notionSearchPages, type Options, outlookListCalendars, outlookListEvents, promoteAttachmentBackup, publishE2EeWitness, publishSessionShareSnapshot, publishSnapshot, readCurrentAttachmentBackup, readE2EeWitness, readLinkSharedNote, readLinkSharedNotePreview, readPublicSharedNote, readPublicSharedNotePreview, readShortLinkSharedNotePreview, registerE2EeDeviceEnrollment, reserveAttachmentBackup, reserveSharedAttachment, revokeKey, sealE2EeDeviceEnrollment, sendSharedNoteInvitationEmail, sendSharedNoteRecapEmail, sendSlackMessage, setWorkspaceE2EeKey, startTrial, sttListenBatch, sttListenStream, sttStatus, updateSettings, voiceprint, waitE2EeWitness, webexImportMeetings, whoami, zoomImportMeetings } from './sdk.gen'; +export type { AccessRole, ActionItem, ApiKeyInfo, Attachment, AttachmentBackupDownload, AttachmentBackupObjectRequest, AttachmentBackupUploadGrant, AttendeeResponseStatus, AttendeeType, AutoDeclineMode, BatchAlternatives, BatchChannel, BatchListenSuccessResponse, BatchResponse, BatchResults, BatchStreamEvent, BatchWord, BirthdayProperties, BirthdayPropertyType, BodyType, Calendar, CalendarColor, CalendarListEntry, CalendarNotification, CancelAttachmentBackupDeletionData, CancelAttachmentBackupDeletionErrors, CancelAttachmentBackupDeletionResponse, CancelAttachmentBackupDeletionResponses, CanceledAttachmentBackupDeletion, CanStartTrialData, CanStartTrialErrors, CanStartTrialReason, CanStartTrialResponse, CanStartTrialResponse2, CanStartTrialResponses, CasSessionShareSnapshotRequest, CharTask, ChatStatus, ClaimE2EeIdentityData, ClaimE2EeIdentityErrors, ClaimE2EeIdentityRequest, ClaimE2EeIdentityResponse, ClaimE2EeIdentityResponses, ClaimSharedNoteHandoffData, ClaimSharedNoteHandoffErrors, ClaimSharedNoteHandoffResponse, ClaimSharedNoteHandoffResponses, ClientOptions, CloudApiSettings, CloudsyncCredentialResponse, CloudsyncCredentials, CloudsyncWorkspace, CollectionPage, CollectionRef, ConferenceCreateRequest, ConferenceCreateRequestStatus, ConferenceCreateStatusCode, ConferenceData, ConferenceProperties, ConferenceSolution, ConferenceSolutionKey, ConferenceSolutionType, Confidence, ConnectionItem, ConsumeE2EeDeviceEnrollmentData, ConsumeE2EeDeviceEnrollmentErrors, ConsumeE2EeDeviceEnrollmentRequest, ConsumeE2EeDeviceEnrollmentResponse, ConsumeE2EeDeviceEnrollmentResponses, CreateApiKeyBody, CreateCredentialsData, CreateCredentialsErrors, CreateCredentialsResponse, CreateCredentialsResponses, CreatedApiKey, CreateEventRequest, CreateKeyData, CreateKeyError, CreateKeyErrors, CreateKeyResponse, CreateKeyResponses, CreateLinkSharedNoteHandoffData, CreateLinkSharedNoteHandoffErrors, CreateLinkSharedNoteHandoffResponse, CreateLinkSharedNoteHandoffResponses, CreatePublicSharedNoteHandoffData, CreatePublicSharedNoteHandoffErrors, CreatePublicSharedNoteHandoffResponse, CreatePublicSharedNoteHandoffResponses, CreateReplicaCredentialsData, CreateReplicaCredentialsErrors, CreateReplicaCredentialsResponse, CreateReplicaCredentialsResponses, CreateSessionData, CreateSessionErrors, CreateSessionRequest, CreateSessionResponse, CreateSessionResponses, CurrentAttachmentBackup, CustomLocation, DateTimeTimeZone, DayOfWeek, DeleteAccountData, DeleteAccountErrors, DeleteAccountResponse, DeleteAccountResponse2, DeleteAccountResponses, DeleteAttachmentBackupData, DeleteAttachmentBackupErrors, DeleteAttachmentBackupRequest, DeleteAttachmentBackupResponse, DeleteAttachmentBackupResponses, DeleteConnectionData, DeleteConnectionErrors, DeleteConnectionRequest, DeleteConnectionResponse, DeleteConnectionResponse2, DeleteConnectionResponses, DeleteDeviceData, DeleteDeviceErrors, DeleteDeviceResponse, DeleteDeviceResponses, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotResponses, DiarizationJob, DiarizationJobOutput, DiarizationSegment, DiarizeData, DiarizeErrors, DiarizeRequest, DiarizeRequestModel, DiarizeResponse, DiarizeResponses, Document, DownloadAccessSharedAttachmentData, DownloadAccessSharedAttachmentErrors, DownloadAccessSharedAttachmentResponse, DownloadAccessSharedAttachmentResponses, DownloadAttachmentBackupData, DownloadAttachmentBackupErrors, DownloadAttachmentBackupResponse, DownloadAttachmentBackupResponses, DownloadHandoffSharedAttachmentData, DownloadHandoffSharedAttachmentErrors, DownloadHandoffSharedAttachmentResponse, DownloadHandoffSharedAttachmentResponses, DownloadLinkSharedAttachmentData, DownloadLinkSharedAttachmentErrors, DownloadLinkSharedAttachmentResponse, DownloadLinkSharedAttachmentResponses, DownloadPublicSharedAttachmentData, DownloadPublicSharedAttachmentErrors, DownloadPublicSharedAttachmentResponse, DownloadPublicSharedAttachmentResponses, E2EeDeviceEnrollmentPackage, E2EeDeviceEnrollmentStatus, E2EeDeviceEnrollmentSummary, E2EeIdentity, E2EeWitnessEvent, E2EeWitnessPage, E2EeWitnessPageEvent, E2EeWitnessWaitResponse, EditSessionShareSnapshotData, EditSessionShareSnapshotErrors, EditSessionShareSnapshotResponse, EditSessionShareSnapshotResponses, EmailAddress, EntryPoint, EntryPointType, ErrorBody, ErrorEnvelope, EventAttachment, EventDateTime, EventOrderBy, EventPerson, EventShowAs, EventSource, EventStatus, ExportMeetingData, ExportMeetingError, ExportMeetingErrors, ExportMeetingResponses, ExtendedProperties, FathomImportMeetingsData, FathomImportMeetingsErrors, FathomImportMeetingsResponse, FathomImportMeetingsResponses, FinalizeAttachmentBackupData, FinalizeAttachmentBackupErrors, FinalizeAttachmentBackupResponse, FinalizeAttachmentBackupResponses, FinalizedAttachmentBackup, FinalizedSharedAttachment, FinalizeSharedAttachmentData, FinalizeSharedAttachmentErrors, FinalizeSharedAttachmentResponse, FinalizeSharedAttachmentResponses, FocusTimeProperties, Gadget, GadgetDisplay, GetDevicesData, GetDevicesErrors, GetDevicesResponse, GetDevicesResponses, GetHistoryData, GetHistoryError, GetHistoryErrors, GetHistoryResponse, GetHistoryResponses, GetJobByIdData, GetJobByIdErrors, GetJobByIdResponse, GetJobByIdResponses, GetJobsResponse, GetMediaUploadUrl, GetMediaUploadUrlData, GetMediaUploadUrlErrors, GetMediaUploadUrlResponse, GetMediaUploadUrlResponses, GetMeetingData, GetMeetingError, GetMeetingErrors, GetMeetingResponse, GetMeetingResponses, GetMessageRequest, GetSettingsData, GetSettingsError, GetSettingsErrors, GetSettingsResponse, GetSettingsResponses, GetThreadRequest, GetTranscriptData, GetTranscriptError, GetTranscriptErrors, GetTranscriptResponse, GetTranscriptResponses, GetWorkspaceE2EeKeyRecipientsData, GetWorkspaceE2EeKeyRecipientsErrors, GetWorkspaceE2EeKeyRecipientsResponse, GetWorkspaceE2EeKeyRecipientsResponses, GithubListReposData, GithubListReposErrors, GitHubListReposRequest, GithubListReposResponse, GithubListReposResponses, GithubListTicketsData, GithubListTicketsErrors, GitHubListTicketsRequest, GithubListTicketsResponse, GithubListTicketsResponses, GoogleAttendee, GoogleCreateEventBody, GoogleEvent, GoogleEventType, GoogleGetAttachmentData, GoogleGetAttachmentErrors, GoogleGetAttachmentRequest, GoogleGetAttachmentResponse, GoogleGetAttachmentResponses, GoogleGetMessageData, GoogleGetMessageErrors, GoogleGetMessageRequest, GoogleGetMessageResponse, GoogleGetMessageResponses, GoogleGetProfileData, GoogleGetProfileErrors, GoogleGetProfileRequest, GoogleGetProfileResponse, GoogleGetProfileResponses, GoogleGetThreadData, GoogleGetThreadErrors, GoogleGetThreadRequest, GoogleGetThreadResponse, GoogleGetThreadResponses, GoogleListCalendarsData, GoogleListCalendarsErrors, GoogleListCalendarsRequest, GoogleListCalendarsResponse, GoogleListCalendarsResponse2, GoogleListCalendarsResponses, GoogleListEventsData, GoogleListEventsErrors, GoogleListEventsRequest, GoogleListEventsResponse, GoogleListEventsResponse2, GoogleListEventsResponses, GoogleListHistoryData, GoogleListHistoryErrors, GoogleListHistoryRequest, GoogleListHistoryResponse, GoogleListHistoryResponses, GoogleListLabelsData, GoogleListLabelsErrors, GoogleListLabelsRequest, GoogleListLabelsResponse, GoogleListLabelsResponses, GoogleListMessagesData, GoogleListMessagesErrors, GoogleListMessagesRequest, GoogleListMessagesResponse, GoogleListMessagesResponses, GoogleListThreadsData, GoogleListThreadsErrors, GoogleListThreadsRequest, GoogleListThreadsResponse, GoogleListThreadsResponses, GoogleMeetImportMeetingsData, GoogleMeetImportMeetingsErrors, GoogleMeetImportMeetingsResponse, GoogleMeetImportMeetingsResponses, GrantAttachmentBackupUploadData, GrantAttachmentBackupUploadErrors, GrantAttachmentBackupUploadRequest, GrantAttachmentBackupUploadResponse, GrantAttachmentBackupUploadResponses, GrantSharedAttachmentUploadData, GrantSharedAttachmentUploadErrors, GrantSharedAttachmentUploadRequest, GrantSharedAttachmentUploadResponse, GrantSharedAttachmentUploadResponses, History, HistoryLabelAdded, HistoryLabelRemoved, HistoryMessageAdded, HistoryMessageDeleted, HistoryType, IdentificationJobOutput, IdentificationSegment, IdentificationVoiceprint, IdentifyData, IdentifyErrors, IdentifyJob, IdentifyRequest, IdentifyRequestModel, IdentifyResponse, IdentifyResponses, Importance, ImportMeetingsRequest, ImportMeetingsResponse, ImportTextFile, Interval, ItemBody, JobCreated, JobListItem, JobStatus, Label, LabelColor, LabelListVisibility, LabelRef, LabelType, LegacyCloudsyncCredentials, LegacySessionShareSnapshotRequest, LinearCreateIssueData, LinearCreateIssueErrors, LinearCreateIssueRequest, LinearCreateIssueResponse, LinearCreateIssueResponses, LinearListTeamsData, LinearListTeamsErrors, LinearListTeamsRequest, LinearListTeamsResponse, LinearListTeamsResponses, LinearListTicketsData, LinearListTicketsErrors, LinearListTicketsRequest, LinearListTicketsResponse, LinearListTicketsResponses, ListConnectionsData, ListConnectionsErrors, ListConnectionsResponse, ListConnectionsResponse2, ListConnectionsResponses, ListenCallbackRequest, ListenCallbackResponse, ListEventsRequest, ListHistoryRequest, ListHistoryResponse, ListKeysData, ListKeysError, ListKeysErrors, ListKeysResponse, ListKeysResponses, ListLabelsResponse, ListMeetingsData, ListMeetingsError, ListMeetingsErrors, ListMeetingsResponse, ListMeetingsResponses, ListMessagesRequest, ListMessagesResponse, ListSlackChannelsData, ListSlackChannelsErrors, ListSlackChannelsResponse, ListSlackChannelsResponses, ListThreadsRequest, ListThreadsResponse, LlmChatCompletionsData, LlmChatCompletionsErrors, LlmChatCompletionsResponses, Location, LocationType, MatchingOptions, MediaResponse, Meeting, MeetingExport, MeetingListItem, MeetingPage, MeetingRecapEmailRequest, Message, MessageFormat, MessageListVisibility, MessagePart, MessagePartBody, MessagePartHeader, MessageRef, MicrosoftTeamsImportMeetingsData, MicrosoftTeamsImportMeetingsErrors, MicrosoftTeamsImportMeetingsResponse, MicrosoftTeamsImportMeetingsResponses, NangoWebhookData, NangoWebhookErrors, NangoWebhookResponse, NangoWebhookResponses, NotificationMethod, NotificationSettings, NotificationType, NotionAppendUpdateData, NotionAppendUpdateErrors, NotionAppendUpdateRequest, NotionAppendUpdateResponse, NotionAppendUpdateResponse2, NotionAppendUpdateResponses, NotionImportMeetingsData, NotionImportMeetingsErrors, NotionImportMeetingsRequest, NotionImportMeetingsResponse, NotionImportMeetingsResponse2, NotionImportMeetingsResponses, NotionImportTextFile, NotionPage, NotionPagesResponse, NotionSearchPagesData, NotionSearchPagesErrors, NotionSearchPagesRequest, NotionSearchPagesResponse, NotionSearchPagesResponses, OfficeLocation, OnlineMeetingInfo, OnlineMeetingProviderType, OutlookAttendee, OutlookCreateEventBody, OutlookEvent, OutlookEventType, OutlookGeoCoordinates, OutlookListCalendarsData, OutlookListCalendarsErrors, OutlookListCalendarsRequest, OutlookListCalendarsResponse, OutlookListCalendarsResponse2, OutlookListCalendarsResponses, OutlookListEventsData, OutlookListEventsErrors, OutlookListEventsRequest, OutlookListEventsResponse, OutlookListEventsResponse2, OutlookListEventsResponses, OutOfOfficeProperties, Pagination, Participant, PatternedRecurrence, PersonRef, PhysicalAddress, PipelineStatus, Profile, PromoteAttachmentBackupData, PromoteAttachmentBackupErrors, PromoteAttachmentBackupRequest, PromoteAttachmentBackupResponse, PromoteAttachmentBackupResponses, PromotedAttachmentBackup, PublishE2EeWitnessData, PublishE2EeWitnessErrors, PublishE2EeWitnessRequest, PublishE2EeWitnessResponse, PublishE2EeWitnessResponse2, PublishE2EeWitnessResponses, PublishedSessionShareSnapshot, PublishSessionShareSnapshotData, PublishSessionShareSnapshotErrors, PublishSessionShareSnapshotRequest, PublishSessionShareSnapshotResponse, PublishSessionShareSnapshotResponses, PublishSnapshotData, PublishSnapshotError, PublishSnapshotErrors, PublishSnapshotResponse, PublishSnapshotResponses, PullRequestDetail, ReadCurrentAttachmentBackupData, ReadCurrentAttachmentBackupErrors, ReadCurrentAttachmentBackupResponse, ReadCurrentAttachmentBackupResponses, ReadE2EeWitnessData, ReadE2EeWitnessErrors, ReadE2EeWitnessResponse, ReadE2EeWitnessResponses, ReadLinkSharedNoteData, ReadLinkSharedNoteErrors, ReadLinkSharedNotePreviewData, ReadLinkSharedNotePreviewErrors, ReadLinkSharedNotePreviewResponse, ReadLinkSharedNotePreviewResponses, ReadLinkSharedNoteResponse, ReadLinkSharedNoteResponses, ReadPublicSharedNoteData, ReadPublicSharedNoteErrors, ReadPublicSharedNotePreviewData, ReadPublicSharedNotePreviewErrors, ReadPublicSharedNotePreviewResponse, ReadPublicSharedNotePreviewResponses, ReadPublicSharedNoteResponse, ReadPublicSharedNoteResponses, ReadShortLinkSharedNotePreviewData, ReadShortLinkSharedNotePreviewErrors, ReadShortLinkSharedNotePreviewResponse, ReadShortLinkSharedNotePreviewResponses, Recipient, RecurrencePattern, RecurrencePatternType, RecurrenceRange, RecurrenceRangeType, RegisterE2EeDeviceEnrollmentData, RegisterE2EeDeviceEnrollmentErrors, RegisterE2EeDeviceEnrollmentRequest, RegisterE2EeDeviceEnrollmentResponse, RegisterE2EeDeviceEnrollmentResponse2, RegisterE2EeDeviceEnrollmentResponses, Reminder, ReminderMethod, Reminders, ReplicaCredentials, ReserveAttachmentBackupData, ReserveAttachmentBackupErrors, ReserveAttachmentBackupRequest, ReserveAttachmentBackupResponse, ReserveAttachmentBackupResponses, ReservedAttachmentBackup, ReservedSharedAttachment, ReserveSharedAttachmentData, ReserveSharedAttachmentErrors, ReserveSharedAttachmentRequest, ReserveSharedAttachmentResponse, ReserveSharedAttachmentResponses, ResponseStatus, ResponseType, RevokeKeyData, RevokeKeyError, RevokeKeyErrors, RevokeKeyResponse, RevokeKeyResponses, ScheduledAttachmentBackupDeletion, SealE2EeDeviceEnrollmentData, SealE2EeDeviceEnrollmentErrors, SealE2EeDeviceEnrollmentResponse, SealE2EeDeviceEnrollmentResponses, SendMessageResponse, SendSharedNoteInvitationEmailData, SendSharedNoteInvitationEmailErrors, SendSharedNoteInvitationEmailResponse, SendSharedNoteInvitationEmailResponses, SendSharedNoteRecapEmailData, SendSharedNoteRecapEmailErrors, SendSharedNoteRecapEmailResponse, SendSharedNoteRecapEmailResponses, SendSlackMessageData, SendSlackMessageErrors, SendSlackMessageResponse, SendSlackMessageResponses, Sensitivity, SessionMode, SessionResponse, SetWorkspaceE2EeKeyData, SetWorkspaceE2EeKeyErrors, SetWorkspaceE2EeKeyRequest, SetWorkspaceE2EeKeyResponse, SetWorkspaceE2EeKeyResponses, SetWorkspaceE2EeKeyResult, SharedAttachmentDownload, SharedAttachmentObjectRequest, SharedAttachmentUploadGrant, SharedNoteAttachment, SharedNoteHandoff, SharedNoteHandoffAttachmentRequest, SharedNoteHandoffClaimRequest, SharedNoteInvitationEmailRequest, SharedNoteLinkPreview, SharedNoteLinkPreviewRequest, SharedNoteLinkRequest, SharedNotePreview, SharedNoteSnapshot, SlackChannel, SlackChannelsResponse, SlackSendRequest, SnapshotReceipt, StartTrialData, StartTrialErrors, StartTrialReason, StartTrialResponse, StartTrialResponse2, StartTrialResponses, StreamAlternatives, StreamChannel, StreamMetadata, StreamModelInfo, StreamResponse, StreamWord, SttListenBatchData, SttListenBatchErrors, SttListenBatchResponse, SttListenBatchResponses, SttListenStreamData, SttListenStreamErrors, SttStatusData, SttStatusErrors, SttStatusResponse, SttStatusResponse2, SttStatusResponses, SyncDeviceRow, SyncDevicesResponse, TestResponse, Thread, ThreadRef, TicketKind, TicketPage, TicketPriority, TicketProviderType, TicketState, TicketSummary, Transcript, TranscriptionConfiguration, TranscriptionConfigurationModel, TranscriptionSegment, TranscriptPage, Transparency, UpdateSettingsBody, UpdateSettingsData, UpdateSettingsError, UpdateSettingsErrors, UpdateSettingsResponse, UpdateSettingsResponses, Visibility, Voiceprint, VoiceprintData, VoiceprintErrors, VoiceprintJob, VoiceprintJobResults, VoiceprintRequest, VoiceprintRequestModel, VoiceprintResponse, VoiceprintResponses, WaitE2EeWitnessData, WaitE2EeWitnessErrors, WaitE2EeWitnessResponse, WaitE2EeWitnessResponses, WebexImportMeetingsData, WebexImportMeetingsErrors, WebexImportMeetingsResponse, WebexImportMeetingsResponses, WebhookResponse, WeekIndex, WhoamiData, WhoamiErrors, WhoAmIItem, WhoamiResponse, WhoAmIResponse, WhoamiResponses, WorkingLocationProperties, WorkingLocationType, WorkspaceE2EeKeyGrant, WorkspaceE2EeKeyGrantUpload, WorkspaceE2EeKeyRecipient, ZoomImportMeetingsData, ZoomImportMeetingsErrors, ZoomImportMeetingsRequest, ZoomImportMeetingsResponse, ZoomImportMeetingsResponse2, ZoomImportMeetingsResponses, ZoomImportTextFile } from './types.gen'; diff --git a/packages/api-client/src/generated/sdk.gen.ts b/packages/api-client/src/generated/sdk.gen.ts index ebbb8a9186..6cbfc09058 100644 --- a/packages/api-client/src/generated/sdk.gen.ts +++ b/packages/api-client/src/generated/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { CancelAttachmentBackupDeletionData, CancelAttachmentBackupDeletionErrors, CancelAttachmentBackupDeletionResponses, CanStartTrialData, CanStartTrialErrors, CanStartTrialResponses, ClaimE2EeIdentityData, ClaimE2EeIdentityErrors, ClaimE2EeIdentityResponses, ClaimSharedNoteHandoffData, ClaimSharedNoteHandoffErrors, ClaimSharedNoteHandoffResponses, ConsumeE2EeDeviceEnrollmentData, ConsumeE2EeDeviceEnrollmentErrors, ConsumeE2EeDeviceEnrollmentResponses, CreateCredentialsData, CreateCredentialsErrors, CreateCredentialsResponses, CreateKeyData, CreateKeyErrors, CreateKeyResponses, CreateLinkSharedNoteHandoffData, CreateLinkSharedNoteHandoffErrors, CreateLinkSharedNoteHandoffResponses, CreatePublicSharedNoteHandoffData, CreatePublicSharedNoteHandoffErrors, CreatePublicSharedNoteHandoffResponses, CreateReplicaCredentialsData, CreateReplicaCredentialsErrors, CreateReplicaCredentialsResponses, CreateSessionData, CreateSessionErrors, CreateSessionResponses, DeleteAccountData, DeleteAccountErrors, DeleteAccountResponses, DeleteAttachmentBackupData, DeleteAttachmentBackupErrors, DeleteAttachmentBackupResponses, DeleteConnectionData, DeleteConnectionErrors, DeleteConnectionResponses, DeleteDeviceData, DeleteDeviceErrors, DeleteDeviceResponses, DeleteSnapshotData, DeleteSnapshotResponses, DiarizeData, DiarizeErrors, DiarizeResponses, DownloadAccessSharedAttachmentData, DownloadAccessSharedAttachmentErrors, DownloadAccessSharedAttachmentResponses, DownloadAttachmentBackupData, DownloadAttachmentBackupErrors, DownloadAttachmentBackupResponses, DownloadHandoffSharedAttachmentData, DownloadHandoffSharedAttachmentErrors, DownloadHandoffSharedAttachmentResponses, DownloadLinkSharedAttachmentData, DownloadLinkSharedAttachmentErrors, DownloadLinkSharedAttachmentResponses, DownloadPublicSharedAttachmentData, DownloadPublicSharedAttachmentErrors, DownloadPublicSharedAttachmentResponses, EditSessionShareSnapshotData, EditSessionShareSnapshotErrors, EditSessionShareSnapshotResponses, ExportMeetingData, ExportMeetingErrors, ExportMeetingResponses, FinalizeAttachmentBackupData, FinalizeAttachmentBackupErrors, FinalizeAttachmentBackupResponses, FinalizeSharedAttachmentData, FinalizeSharedAttachmentErrors, FinalizeSharedAttachmentResponses, GetDevicesData, GetDevicesErrors, GetDevicesResponses, GetHistoryData, GetHistoryErrors, GetHistoryResponses, GetJobByIdData, GetJobByIdErrors, GetJobByIdResponses, GetMediaUploadUrlData, GetMediaUploadUrlErrors, GetMediaUploadUrlResponses, GetMeetingData, GetMeetingErrors, GetMeetingResponses, GetSettingsData, GetSettingsErrors, GetSettingsResponses, GetTranscriptData, GetTranscriptErrors, GetTranscriptResponses, GetWorkspaceE2EeKeyRecipientsData, GetWorkspaceE2EeKeyRecipientsErrors, GetWorkspaceE2EeKeyRecipientsResponses, GithubListReposData, GithubListReposErrors, GithubListReposResponses, GithubListTicketsData, GithubListTicketsErrors, GithubListTicketsResponses, GoogleGetAttachmentData, GoogleGetAttachmentErrors, GoogleGetAttachmentResponses, GoogleGetMessageData, GoogleGetMessageErrors, GoogleGetMessageResponses, GoogleGetProfileData, GoogleGetProfileErrors, GoogleGetProfileResponses, GoogleGetThreadData, GoogleGetThreadErrors, GoogleGetThreadResponses, GoogleListCalendarsData, GoogleListCalendarsErrors, GoogleListCalendarsResponses, GoogleListEventsData, GoogleListEventsErrors, GoogleListEventsResponses, GoogleListHistoryData, GoogleListHistoryErrors, GoogleListHistoryResponses, GoogleListLabelsData, GoogleListLabelsErrors, GoogleListLabelsResponses, GoogleListMessagesData, GoogleListMessagesErrors, GoogleListMessagesResponses, GoogleListThreadsData, GoogleListThreadsErrors, GoogleListThreadsResponses, GrantAttachmentBackupUploadData, GrantAttachmentBackupUploadErrors, GrantAttachmentBackupUploadResponses, GrantSharedAttachmentUploadData, GrantSharedAttachmentUploadErrors, GrantSharedAttachmentUploadResponses, IdentifyData, IdentifyErrors, IdentifyResponses, LinearCreateIssueData, LinearCreateIssueErrors, LinearCreateIssueResponses, LinearListTeamsData, LinearListTeamsErrors, LinearListTeamsResponses, LinearListTicketsData, LinearListTicketsErrors, LinearListTicketsResponses, ListConnectionsData, ListConnectionsErrors, ListConnectionsResponses, ListKeysData, ListKeysErrors, ListKeysResponses, ListMeetingsData, ListMeetingsErrors, ListMeetingsResponses, ListSlackChannelsData, ListSlackChannelsErrors, ListSlackChannelsResponses, LlmChatCompletionsData, LlmChatCompletionsErrors, LlmChatCompletionsResponses, NangoWebhookData, NangoWebhookErrors, NangoWebhookResponses, NotionAppendUpdateData, NotionAppendUpdateErrors, NotionAppendUpdateResponses, NotionSearchPagesData, NotionSearchPagesErrors, NotionSearchPagesResponses, OutlookListCalendarsData, OutlookListCalendarsErrors, OutlookListCalendarsResponses, OutlookListEventsData, OutlookListEventsErrors, OutlookListEventsResponses, PromoteAttachmentBackupData, PromoteAttachmentBackupErrors, PromoteAttachmentBackupResponses, PublishE2EeWitnessData, PublishE2EeWitnessErrors, PublishE2EeWitnessResponses, PublishSessionShareSnapshotData, PublishSessionShareSnapshotErrors, PublishSessionShareSnapshotResponses, PublishSnapshotData, PublishSnapshotErrors, PublishSnapshotResponses, ReadCurrentAttachmentBackupData, ReadCurrentAttachmentBackupErrors, ReadCurrentAttachmentBackupResponses, ReadE2EeWitnessData, ReadE2EeWitnessErrors, ReadE2EeWitnessResponses, ReadLinkSharedNoteData, ReadLinkSharedNoteErrors, ReadLinkSharedNotePreviewData, ReadLinkSharedNotePreviewErrors, ReadLinkSharedNotePreviewResponses, ReadLinkSharedNoteResponses, ReadPublicSharedNoteData, ReadPublicSharedNoteErrors, ReadPublicSharedNotePreviewData, ReadPublicSharedNotePreviewErrors, ReadPublicSharedNotePreviewResponses, ReadPublicSharedNoteResponses, ReadShortLinkSharedNotePreviewData, ReadShortLinkSharedNotePreviewErrors, ReadShortLinkSharedNotePreviewResponses, RegisterE2EeDeviceEnrollmentData, RegisterE2EeDeviceEnrollmentErrors, RegisterE2EeDeviceEnrollmentResponses, ReserveAttachmentBackupData, ReserveAttachmentBackupErrors, ReserveAttachmentBackupResponses, ReserveSharedAttachmentData, ReserveSharedAttachmentErrors, ReserveSharedAttachmentResponses, RevokeKeyData, RevokeKeyErrors, RevokeKeyResponses, SealE2EeDeviceEnrollmentData, SealE2EeDeviceEnrollmentErrors, SealE2EeDeviceEnrollmentResponses, SendSharedNoteInvitationEmailData, SendSharedNoteInvitationEmailErrors, SendSharedNoteInvitationEmailResponses, SendSharedNoteRecapEmailData, SendSharedNoteRecapEmailErrors, SendSharedNoteRecapEmailResponses, SendSlackMessageData, SendSlackMessageErrors, SendSlackMessageResponses, SetWorkspaceE2EeKeyData, SetWorkspaceE2EeKeyErrors, SetWorkspaceE2EeKeyResponses, StartTrialData, StartTrialErrors, StartTrialResponses, SttListenBatchData, SttListenBatchErrors, SttListenBatchResponses, SttListenStreamData, SttListenStreamErrors, SttStatusData, SttStatusErrors, SttStatusResponses, UpdateSettingsData, UpdateSettingsErrors, UpdateSettingsResponses, VoiceprintData, VoiceprintErrors, VoiceprintResponses, WaitE2EeWitnessData, WaitE2EeWitnessErrors, WaitE2EeWitnessResponses, WhoamiData, WhoamiErrors, WhoamiResponses } from './types.gen'; +import type { CancelAttachmentBackupDeletionData, CancelAttachmentBackupDeletionErrors, CancelAttachmentBackupDeletionResponses, CanStartTrialData, CanStartTrialErrors, CanStartTrialResponses, ClaimE2EeIdentityData, ClaimE2EeIdentityErrors, ClaimE2EeIdentityResponses, ClaimSharedNoteHandoffData, ClaimSharedNoteHandoffErrors, ClaimSharedNoteHandoffResponses, ConsumeE2EeDeviceEnrollmentData, ConsumeE2EeDeviceEnrollmentErrors, ConsumeE2EeDeviceEnrollmentResponses, CreateCredentialsData, CreateCredentialsErrors, CreateCredentialsResponses, CreateKeyData, CreateKeyErrors, CreateKeyResponses, CreateLinkSharedNoteHandoffData, CreateLinkSharedNoteHandoffErrors, CreateLinkSharedNoteHandoffResponses, CreatePublicSharedNoteHandoffData, CreatePublicSharedNoteHandoffErrors, CreatePublicSharedNoteHandoffResponses, CreateReplicaCredentialsData, CreateReplicaCredentialsErrors, CreateReplicaCredentialsResponses, CreateSessionData, CreateSessionErrors, CreateSessionResponses, DeleteAccountData, DeleteAccountErrors, DeleteAccountResponses, DeleteAttachmentBackupData, DeleteAttachmentBackupErrors, DeleteAttachmentBackupResponses, DeleteConnectionData, DeleteConnectionErrors, DeleteConnectionResponses, DeleteDeviceData, DeleteDeviceErrors, DeleteDeviceResponses, DeleteSnapshotData, DeleteSnapshotResponses, DiarizeData, DiarizeErrors, DiarizeResponses, DownloadAccessSharedAttachmentData, DownloadAccessSharedAttachmentErrors, DownloadAccessSharedAttachmentResponses, DownloadAttachmentBackupData, DownloadAttachmentBackupErrors, DownloadAttachmentBackupResponses, DownloadHandoffSharedAttachmentData, DownloadHandoffSharedAttachmentErrors, DownloadHandoffSharedAttachmentResponses, DownloadLinkSharedAttachmentData, DownloadLinkSharedAttachmentErrors, DownloadLinkSharedAttachmentResponses, DownloadPublicSharedAttachmentData, DownloadPublicSharedAttachmentErrors, DownloadPublicSharedAttachmentResponses, EditSessionShareSnapshotData, EditSessionShareSnapshotErrors, EditSessionShareSnapshotResponses, ExportMeetingData, ExportMeetingErrors, ExportMeetingResponses, FathomImportMeetingsData, FathomImportMeetingsErrors, FathomImportMeetingsResponses, FinalizeAttachmentBackupData, FinalizeAttachmentBackupErrors, FinalizeAttachmentBackupResponses, FinalizeSharedAttachmentData, FinalizeSharedAttachmentErrors, FinalizeSharedAttachmentResponses, GetDevicesData, GetDevicesErrors, GetDevicesResponses, GetHistoryData, GetHistoryErrors, GetHistoryResponses, GetJobByIdData, GetJobByIdErrors, GetJobByIdResponses, GetMediaUploadUrlData, GetMediaUploadUrlErrors, GetMediaUploadUrlResponses, GetMeetingData, GetMeetingErrors, GetMeetingResponses, GetSettingsData, GetSettingsErrors, GetSettingsResponses, GetTranscriptData, GetTranscriptErrors, GetTranscriptResponses, GetWorkspaceE2EeKeyRecipientsData, GetWorkspaceE2EeKeyRecipientsErrors, GetWorkspaceE2EeKeyRecipientsResponses, GithubListReposData, GithubListReposErrors, GithubListReposResponses, GithubListTicketsData, GithubListTicketsErrors, GithubListTicketsResponses, GoogleGetAttachmentData, GoogleGetAttachmentErrors, GoogleGetAttachmentResponses, GoogleGetMessageData, GoogleGetMessageErrors, GoogleGetMessageResponses, GoogleGetProfileData, GoogleGetProfileErrors, GoogleGetProfileResponses, GoogleGetThreadData, GoogleGetThreadErrors, GoogleGetThreadResponses, GoogleListCalendarsData, GoogleListCalendarsErrors, GoogleListCalendarsResponses, GoogleListEventsData, GoogleListEventsErrors, GoogleListEventsResponses, GoogleListHistoryData, GoogleListHistoryErrors, GoogleListHistoryResponses, GoogleListLabelsData, GoogleListLabelsErrors, GoogleListLabelsResponses, GoogleListMessagesData, GoogleListMessagesErrors, GoogleListMessagesResponses, GoogleListThreadsData, GoogleListThreadsErrors, GoogleListThreadsResponses, GoogleMeetImportMeetingsData, GoogleMeetImportMeetingsErrors, GoogleMeetImportMeetingsResponses, GrantAttachmentBackupUploadData, GrantAttachmentBackupUploadErrors, GrantAttachmentBackupUploadResponses, GrantSharedAttachmentUploadData, GrantSharedAttachmentUploadErrors, GrantSharedAttachmentUploadResponses, IdentifyData, IdentifyErrors, IdentifyResponses, LinearCreateIssueData, LinearCreateIssueErrors, LinearCreateIssueResponses, LinearListTeamsData, LinearListTeamsErrors, LinearListTeamsResponses, LinearListTicketsData, LinearListTicketsErrors, LinearListTicketsResponses, ListConnectionsData, ListConnectionsErrors, ListConnectionsResponses, ListKeysData, ListKeysErrors, ListKeysResponses, ListMeetingsData, ListMeetingsErrors, ListMeetingsResponses, ListSlackChannelsData, ListSlackChannelsErrors, ListSlackChannelsResponses, LlmChatCompletionsData, LlmChatCompletionsErrors, LlmChatCompletionsResponses, MicrosoftTeamsImportMeetingsData, MicrosoftTeamsImportMeetingsErrors, MicrosoftTeamsImportMeetingsResponses, NangoWebhookData, NangoWebhookErrors, NangoWebhookResponses, NotionAppendUpdateData, NotionAppendUpdateErrors, NotionAppendUpdateResponses, NotionImportMeetingsData, NotionImportMeetingsErrors, NotionImportMeetingsResponses, NotionSearchPagesData, NotionSearchPagesErrors, NotionSearchPagesResponses, OutlookListCalendarsData, OutlookListCalendarsErrors, OutlookListCalendarsResponses, OutlookListEventsData, OutlookListEventsErrors, OutlookListEventsResponses, PromoteAttachmentBackupData, PromoteAttachmentBackupErrors, PromoteAttachmentBackupResponses, PublishE2EeWitnessData, PublishE2EeWitnessErrors, PublishE2EeWitnessResponses, PublishSessionShareSnapshotData, PublishSessionShareSnapshotErrors, PublishSessionShareSnapshotResponses, PublishSnapshotData, PublishSnapshotErrors, PublishSnapshotResponses, ReadCurrentAttachmentBackupData, ReadCurrentAttachmentBackupErrors, ReadCurrentAttachmentBackupResponses, ReadE2EeWitnessData, ReadE2EeWitnessErrors, ReadE2EeWitnessResponses, ReadLinkSharedNoteData, ReadLinkSharedNoteErrors, ReadLinkSharedNotePreviewData, ReadLinkSharedNotePreviewErrors, ReadLinkSharedNotePreviewResponses, ReadLinkSharedNoteResponses, ReadPublicSharedNoteData, ReadPublicSharedNoteErrors, ReadPublicSharedNotePreviewData, ReadPublicSharedNotePreviewErrors, ReadPublicSharedNotePreviewResponses, ReadPublicSharedNoteResponses, ReadShortLinkSharedNotePreviewData, ReadShortLinkSharedNotePreviewErrors, ReadShortLinkSharedNotePreviewResponses, RegisterE2EeDeviceEnrollmentData, RegisterE2EeDeviceEnrollmentErrors, RegisterE2EeDeviceEnrollmentResponses, ReserveAttachmentBackupData, ReserveAttachmentBackupErrors, ReserveAttachmentBackupResponses, ReserveSharedAttachmentData, ReserveSharedAttachmentErrors, ReserveSharedAttachmentResponses, RevokeKeyData, RevokeKeyErrors, RevokeKeyResponses, SealE2EeDeviceEnrollmentData, SealE2EeDeviceEnrollmentErrors, SealE2EeDeviceEnrollmentResponses, SendSharedNoteInvitationEmailData, SendSharedNoteInvitationEmailErrors, SendSharedNoteInvitationEmailResponses, SendSharedNoteRecapEmailData, SendSharedNoteRecapEmailErrors, SendSharedNoteRecapEmailResponses, SendSlackMessageData, SendSlackMessageErrors, SendSlackMessageResponses, SetWorkspaceE2EeKeyData, SetWorkspaceE2EeKeyErrors, SetWorkspaceE2EeKeyResponses, StartTrialData, StartTrialErrors, StartTrialResponses, SttListenBatchData, SttListenBatchErrors, SttListenBatchResponses, SttListenStreamData, SttListenStreamErrors, SttStatusData, SttStatusErrors, SttStatusResponses, UpdateSettingsData, UpdateSettingsErrors, UpdateSettingsResponses, VoiceprintData, VoiceprintErrors, VoiceprintResponses, WaitE2EeWitnessData, WaitE2EeWitnessErrors, WaitE2EeWitnessResponses, WebexImportMeetingsData, WebexImportMeetingsErrors, WebexImportMeetingsResponses, WhoamiData, WhoamiErrors, WhoamiResponses, ZoomImportMeetingsData, ZoomImportMeetingsErrors, ZoomImportMeetingsResponses } from './types.gen'; export type Options = Options2 & { /** @@ -58,6 +58,26 @@ export const outlookListEvents = (options: } }); +export const fathomImportMeetings = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/fathom/import-meetings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const googleMeetImportMeetings = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/google-meet/import-meetings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const llmChatCompletions = (options?: Options) => (options?.client ?? client).post({ url: '/llm/chat/completions', ...options }); export const googleGetAttachment = (options: Options) => (options.client ?? client).post({ @@ -151,6 +171,16 @@ export const sendSlackMessage = (options: } }); +export const microsoftTeamsImportMeetings = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/microsoft-teams/import-meetings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const deleteConnection = (options: Options) => (options.client ?? client).delete({ security: [{ scheme: 'bearer', type: 'http' }], url: '/nango/connections', @@ -186,6 +216,7 @@ export const whoami = (options?: Options(options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], url: '/notion/append-update', ...options, headers: { @@ -194,7 +225,18 @@ export const notionAppendUpdate = (options } }); +export const notionImportMeetings = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/notion/import-meetings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const notionSearchPages = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], url: '/notion/search-pages', ...options, headers: { @@ -744,3 +786,23 @@ export const publishSnapshot = (options: O ...options.headers } }); + +export const webexImportMeetings = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/webex/import-meetings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +export const zoomImportMeetings = (options: Options) => (options.client ?? client).post({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/zoom/import-meetings', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); diff --git a/packages/api-client/src/generated/types.gen.ts b/packages/api-client/src/generated/types.gen.ts index 124de6eced..b12d5a3922 100644 --- a/packages/api-client/src/generated/types.gen.ts +++ b/packages/api-client/src/generated/types.gen.ts @@ -884,6 +884,22 @@ export type IdentifyRequest = { export type IdentifyRequestModel = 'precision-2'; +export type ImportMeetingsRequest = { + connection_id: string; + known_meeting_ids?: Array; +}; + +export type ImportMeetingsResponse = { + files: Array; + warnings: Array; +}; + +export type ImportTextFile = { + content: string; + name: string; + path: string; +}; + export type Importance = 'low' | 'normal' | 'high' | 'unknown'; export type Interval = 'monthly' | 'yearly'; @@ -1184,6 +1200,22 @@ export type NotionAppendUpdateResponse = { block_count: number; }; +export type NotionImportMeetingsRequest = { + connection_id: string; + known_meeting_ids?: Array; +}; + +export type NotionImportMeetingsResponse = { + files: Array; + warnings: Array; +}; + +export type NotionImportTextFile = { + content: string; + name: string; + path: string; +}; + export type NotionPage = { id: string; title: string; @@ -1910,6 +1942,22 @@ export type WorkspaceE2EeKeyRecipient = { userId: string; }; +export type ZoomImportMeetingsRequest = { + connection_id: string; + known_meeting_ids?: Array; +}; + +export type ZoomImportMeetingsResponse = { + files: Array; + warnings: Array; +}; + +export type ZoomImportTextFile = { + content: string; + name: string; + path: string; +}; + export type GoogleAttendee = { additionalGuests?: number | null; comment?: string | null; @@ -2207,6 +2255,60 @@ export type OutlookListEventsResponses = { export type OutlookListEventsResponse2 = OutlookListEventsResponses[keyof OutlookListEventsResponses]; +export type FathomImportMeetingsData = { + body: ImportMeetingsRequest; + path?: never; + query?: never; + url: '/fathom/import-meetings'; +}; + +export type FathomImportMeetingsErrors = { + /** + * Unauthorized + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type FathomImportMeetingsResponses = { + /** + * Fathom meetings fetched for import + */ + 200: ImportMeetingsResponse; +}; + +export type FathomImportMeetingsResponse = FathomImportMeetingsResponses[keyof FathomImportMeetingsResponses]; + +export type GoogleMeetImportMeetingsData = { + body: ImportMeetingsRequest; + path?: never; + query?: never; + url: '/google-meet/import-meetings'; +}; + +export type GoogleMeetImportMeetingsErrors = { + /** + * Unauthorized + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type GoogleMeetImportMeetingsResponses = { + /** + * Google Meet meetings fetched for import + */ + 200: ImportMeetingsResponse; +}; + +export type GoogleMeetImportMeetingsResponse = GoogleMeetImportMeetingsResponses[keyof GoogleMeetImportMeetingsResponses]; + export type LlmChatCompletionsData = { body?: never; headers?: { @@ -2520,6 +2622,33 @@ export type SendSlackMessageResponses = { export type SendSlackMessageResponse = SendSlackMessageResponses[keyof SendSlackMessageResponses]; +export type MicrosoftTeamsImportMeetingsData = { + body: ImportMeetingsRequest; + path?: never; + query?: never; + url: '/microsoft-teams/import-meetings'; +}; + +export type MicrosoftTeamsImportMeetingsErrors = { + /** + * Unauthorized + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type MicrosoftTeamsImportMeetingsResponses = { + /** + * Microsoft Teams meetings fetched for import + */ + 200: ImportMeetingsResponse; +}; + +export type MicrosoftTeamsImportMeetingsResponse = MicrosoftTeamsImportMeetingsResponses[keyof MicrosoftTeamsImportMeetingsResponses]; + export type DeleteConnectionData = { body: DeleteConnectionRequest; path?: never; @@ -2690,6 +2819,33 @@ export type NotionAppendUpdateResponses = { export type NotionAppendUpdateResponse2 = NotionAppendUpdateResponses[keyof NotionAppendUpdateResponses]; +export type NotionImportMeetingsData = { + body: NotionImportMeetingsRequest; + path?: never; + query?: never; + url: '/notion/import-meetings'; +}; + +export type NotionImportMeetingsErrors = { + /** + * Authentication required + */ + 401: unknown; + /** + * Notion connection unavailable + */ + 500: unknown; +}; + +export type NotionImportMeetingsResponses = { + /** + * Notion meeting notes fetched for import + */ + 200: NotionImportMeetingsResponse; +}; + +export type NotionImportMeetingsResponse2 = NotionImportMeetingsResponses[keyof NotionImportMeetingsResponses]; + export type NotionSearchPagesData = { body: NotionSearchPagesRequest; path?: never; @@ -5162,3 +5318,57 @@ export type PublishSnapshotResponses = { }; export type PublishSnapshotResponse = PublishSnapshotResponses[keyof PublishSnapshotResponses]; + +export type WebexImportMeetingsData = { + body: ImportMeetingsRequest; + path?: never; + query?: never; + url: '/webex/import-meetings'; +}; + +export type WebexImportMeetingsErrors = { + /** + * Unauthorized + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type WebexImportMeetingsResponses = { + /** + * Webex meetings fetched for import + */ + 200: ImportMeetingsResponse; +}; + +export type WebexImportMeetingsResponse = WebexImportMeetingsResponses[keyof WebexImportMeetingsResponses]; + +export type ZoomImportMeetingsData = { + body: ZoomImportMeetingsRequest; + path?: never; + query?: never; + url: '/zoom/import-meetings'; +}; + +export type ZoomImportMeetingsErrors = { + /** + * Unauthorized + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ZoomImportMeetingsResponses = { + /** + * Zoom meetings fetched for import + */ + 200: ZoomImportMeetingsResponse; +}; + +export type ZoomImportMeetingsResponse2 = ZoomImportMeetingsResponses[keyof ZoomImportMeetingsResponses]; diff --git a/plugins/importer/Cargo.toml b/plugins/importer/Cargo.toml index b6f49e0e46..f5221c0f49 100644 --- a/plugins/importer/Cargo.toml +++ b/plugins/importer/Cargo.toml @@ -20,6 +20,7 @@ specta-typescript = { workspace = true } [dependencies] anlg-importer-core = { workspace = true } +anlg-meeting-import = { workspace = true } legacy-db-parser = { workspace = true, optional = true } tauri-plugin-settings = { workspace = true } @@ -35,6 +36,6 @@ chrono = { workspace = true } dirs = { workspace = true } rmcp = { workspace = true, features = ["auth", "client", "transport-streamable-http-client-reqwest"] } thiserror = { workspace = true } -tokio = { workspace = true, features = ["io-util", "net", "rt-multi-thread", "macros", "sync", "time"] } +tokio = { workspace = true, features = ["io-util", "net", "process", "rt-multi-thread", "macros", "sync", "time"] } tokio-util = { workspace = true } uuid = { workspace = true, features = ["v4", "v5"] } diff --git a/plugins/importer/src/commands.rs b/plugins/importer/src/commands.rs index e8b32c54a6..913fa88ec2 100644 --- a/plugins/importer/src/commands.rs +++ b/plugins/importer/src/commands.rs @@ -13,27 +13,42 @@ const SUPPORTED_EXTENSIONS: &[&str] = &["csv", "json", "md", "markdown", "srt", #[specta::specta] pub async fn begin_connected_import( provider_id: String, - state: tauri::State<'_, crate::connected_mcp::ConnectedImportOAuthState>, + mcp_state: tauri::State<'_, crate::connected_mcp::ConnectedImportOAuthState>, + cli_state: tauri::State<'_, crate::connected_cli::ConnectedImportCliState>, ) -> Result { - crate::connected_mcp::begin_connection(&provider_id, &state).await + if crate::connected_cli::is_cli_provider(&provider_id) { + crate::connected_cli::begin_connection(&provider_id, &cli_state).await + } else { + crate::connected_mcp::begin_connection(&provider_id, &mcp_state).await + } } #[tauri::command] #[specta::specta] pub async fn cancel_connected_import( provider_id: String, - state: tauri::State<'_, crate::connected_mcp::ConnectedImportOAuthState>, + mcp_state: tauri::State<'_, crate::connected_mcp::ConnectedImportOAuthState>, + cli_state: tauri::State<'_, crate::connected_cli::ConnectedImportCliState>, ) -> Result { - crate::connected_mcp::cancel_connection(&provider_id, &state).await + if crate::connected_cli::is_cli_provider(&provider_id) { + crate::connected_cli::cancel_connection(&provider_id, &cli_state).await + } else { + crate::connected_mcp::cancel_connection(&provider_id, &mcp_state).await + } } #[tauri::command] #[specta::specta] pub async fn complete_connected_import( provider_id: String, - state: tauri::State<'_, crate::connected_mcp::ConnectedImportOAuthState>, + mcp_state: tauri::State<'_, crate::connected_mcp::ConnectedImportOAuthState>, + cli_state: tauri::State<'_, crate::connected_cli::ConnectedImportCliState>, ) -> Result { - crate::connected_mcp::complete_connection(&provider_id, &state).await + if crate::connected_cli::is_cli_provider(&provider_id) { + crate::connected_cli::complete_connection(&provider_id, &cli_state).await + } else { + crate::connected_mcp::complete_connection(&provider_id, &mcp_state).await + } } #[tauri::command] @@ -43,7 +58,11 @@ pub async fn sync_connected_import( credentials: ConnectedImportCredentials, known_meeting_ids: Vec, ) -> Result { - crate::connected_mcp::sync(&provider_id, credentials, known_meeting_ids).await + if crate::connected_cli::is_cli_provider(&provider_id) { + crate::connected_cli::sync(&provider_id, credentials, known_meeting_ids).await + } else { + crate::connected_mcp::sync(&provider_id, credentials, known_meeting_ids).await + } } #[tauri::command] diff --git a/plugins/importer/src/connected_cli.rs b/plugins/importer/src/connected_cli.rs new file mode 100644 index 0000000000..2c64274a63 --- /dev/null +++ b/plugins/importer/src/connected_cli.rs @@ -0,0 +1,323 @@ +use crate::types::{ + ConnectedImportAuthorization, ConnectedImportCredentials, ConnectedImportSyncResult, + ImportTextFile, +}; +use anlg_meeting_import::plaud::{parse_login_url, parse_me}; +use anlg_meeting_import::plaud_cli; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use tokio::io::AsyncReadExt; +use tokio::process::Child; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +const PROVIDER_ID: &str = "plaud"; +const PROVIDER_NAME: &str = "Plaud"; +const COMMAND_TIMEOUT: Duration = Duration::from_secs(45); +const LOGIN_TIMEOUT: Duration = Duration::from_secs(150); +const LOGIN_URL_WAIT: Duration = Duration::from_secs(5); + +#[derive(Default)] +pub struct ConnectedImportCliState { + pending: Mutex>, +} + +struct PendingCli { + binary: PathBuf, + login: Option, + cancellation: CancellationToken, +} + +struct PendingLogin { + child: Child, + output: Arc>, +} + +pub fn is_cli_provider(provider_id: &str) -> bool { + provider_id == PROVIDER_ID +} + +pub async fn begin_connection( + provider_id: &str, + state: &ConnectedImportCliState, +) -> Result { + ensure_plaud(provider_id)?; + let binary = plaud_cli::resolve_binary()?; + match plaud_cli::run(&binary, &["me"], COMMAND_TIMEOUT).await { + Ok(_) => { + set_pending( + state, + PendingCli { + binary, + login: None, + cancellation: CancellationToken::new(), + }, + ) + .await; + return Ok(authorization("")); + } + Err(error) if plaud_cli::is_auth_error(&error) => {} + Err(error) => return Err(error), + } + + let cancellation = CancellationToken::new(); + let mut login = spawn_login(&binary)?; + let output = login.output.clone(); + let authorization_url = wait_for_login_url(&mut login, output, cancellation.clone()).await?; + + set_pending( + state, + PendingCli { + binary, + login: Some(login), + cancellation, + }, + ) + .await; + Ok(authorization(&authorization_url)) +} + +pub async fn cancel_connection( + provider_id: &str, + state: &ConnectedImportCliState, +) -> Result { + ensure_plaud(provider_id)?; + let Some(pending) = state.pending.lock().await.take() else { + return Ok(false); + }; + pending.cancellation.cancel(); + if let Some(mut login) = pending.login { + let _ = login.child.kill().await; + } + Ok(true) +} + +pub async fn complete_connection( + provider_id: &str, + state: &ConnectedImportCliState, +) -> Result { + ensure_plaud(provider_id)?; + let pending = state + .pending + .lock() + .await + .take() + .ok_or_else(|| "Start Plaud sign-in again".to_string())?; + complete_pending(pending).await +} + +pub async fn sync( + provider_id: &str, + credentials: ConnectedImportCredentials, + known_meeting_ids: Vec, +) -> Result { + ensure_plaud(provider_id)?; + let binary = plaud_cli::binary_from_token_json(&credentials.token_json) + .or_else(|_| plaud_cli::resolve_binary())?; + let account = parse_me( + &plaud_cli::run(&binary, &["me"], COMMAND_TIMEOUT) + .await + .map_err(|error| { + if plaud_cli::is_auth_error(&error) { + "Reconnect Plaud to keep importing".to_string() + } else { + error + } + })?, + ); + let known = known_meeting_ids.into_iter().collect::>(); + let (files, warnings) = plaud_cli::import_new_meetings(&binary, &known).await?; + Ok(ConnectedImportSyncResult { + files: files + .into_iter() + .map(|file| ImportTextFile { + path: file.path, + name: file.name, + content: file.content, + }) + .collect(), + credentials: credentials_for(&binary, &account.display_name()), + warnings, + }) +} + +async fn complete_pending(mut pending: PendingCli) -> Result { + if let Some(mut login) = pending.login.take() { + let output = login.output.clone(); + let status = tokio::select! { + status = login.child.wait() => status.map_err(|error| format!("could not finish Plaud sign-in: {error}"))?, + _ = pending.cancellation.cancelled() => { + let _ = login.child.kill().await; + return Err("Plaud sign-in cancelled.".to_string()); + } + _ = tokio::time::sleep(LOGIN_TIMEOUT) => { + let _ = login.child.kill().await; + return Err("Plaud sign-in timed out. Try again.".to_string()); + } + }; + if !status.success() { + let output = output.lock().await; + return Err(login_error(status.code().unwrap_or(1), &output)); + } + } + + let stdout = plaud_cli::run(&pending.binary, &["me"], COMMAND_TIMEOUT).await?; + let account = parse_me(&stdout); + Ok(credentials_for(&pending.binary, &account.display_name())) +} + +async fn wait_for_login_url( + login: &mut PendingLogin, + output: Arc>, + cancellation: CancellationToken, +) -> Result { + let deadline = Instant::now() + LOGIN_URL_WAIT; + loop { + if cancellation.is_cancelled() { + let _ = login.child.kill().await; + return Err("Plaud sign-in cancelled.".to_string()); + } + if let Some(url) = parse_login_url(&output.lock().await) { + return Ok(url); + } + if let Some(status) = login + .child + .try_wait() + .map_err(|error| format!("could not start Plaud sign-in: {error}"))? + { + if status.success() { + return Ok(String::new()); + } + let output = output.lock().await; + return Err(login_error(status.code().unwrap_or(1), &output)); + } + if Instant::now() >= deadline { + return Ok(String::new()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +fn spawn_login(binary: &std::path::Path) -> Result { + let mut child = plaud_cli::command(binary, &["login"]) + .spawn() + .map_err(|error| format!("could not start Plaud sign-in: {error}"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "could not read Plaud sign-in output".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "could not read Plaud sign-in output".to_string())?; + let output = Arc::new(Mutex::new(String::new())); + let captured = output.clone(); + tokio::spawn(async move { + capture_output(stdout, stderr, captured).await; + }); + Ok(PendingLogin { child, output }) +} + +async fn capture_output(mut stdout: Out, mut stderr: Err, output: Arc>) +where + Out: AsyncReadExt + Unpin, + Err: AsyncReadExt + Unpin, +{ + let stdout_output = output.clone(); + let stderr_output = output; + tokio::join!( + async move { + let mut buf = [0_u8; 1024]; + loop { + match stdout.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(count) => stdout_output + .lock() + .await + .push_str(&String::from_utf8_lossy(&buf[..count])), + } + } + }, + async move { + let mut buf = [0_u8; 1024]; + loop { + match stderr.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(count) => stderr_output + .lock() + .await + .push_str(&String::from_utf8_lossy(&buf[..count])), + } + } + } + ); +} + +fn credentials_for(binary: &std::path::Path, account: &str) -> ConnectedImportCredentials { + ConnectedImportCredentials { + provider_id: PROVIDER_ID.to_string(), + client_id: account.to_string(), + client_secret: None, + token_json: serde_json::json!({ + "kind": "cli", + "binary": binary.to_string_lossy(), + }) + .to_string(), + token_received_at: Some( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0), + ), + } +} + +async fn set_pending(state: &ConnectedImportCliState, pending: PendingCli) { + if let Some(previous) = state.pending.lock().await.replace(pending) { + previous.cancellation.cancel(); + if let Some(mut login) = previous.login { + let _ = login.child.kill().await; + } + } +} + +fn authorization(authorization_url: &str) -> ConnectedImportAuthorization { + ConnectedImportAuthorization { + provider_id: PROVIDER_ID.to_string(), + authorization_url: authorization_url.to_string(), + } +} + +fn ensure_plaud(provider_id: &str) -> Result<(), String> { + if provider_id == PROVIDER_ID { + Ok(()) + } else { + Err(format!("{PROVIDER_NAME} is the only CLI import source")) + } +} + +fn login_error(status: i32, output: &str) -> String { + if status == 2 || output.contains("AUTH_FAILED") { + "Plaud sign-in expired. Connect again.".to_string() + } else { + let detail = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("unknown error"); + format!("could not sign in to Plaud: {detail}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_only_plaud() { + assert!(is_cli_provider("plaud")); + assert!(!is_cli_provider("granola")); + } +} diff --git a/plugins/importer/src/lib.rs b/plugins/importer/src/lib.rs index aeb8e2d572..ad533a9e31 100644 --- a/plugins/importer/src/lib.rs +++ b/plugins/importer/src/lib.rs @@ -1,6 +1,7 @@ use tauri::{Manager, Wry}; mod commands; +mod connected_cli; mod connected_mcp; mod error; mod ext; @@ -37,6 +38,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { .invoke_handler(specta_builder.invoke_handler()) .setup(|app, _api| { app.manage(connected_mcp::ConnectedImportOAuthState::default()); + app.manage(connected_cli::ConnectedImportCliState::default()); Ok(()) }) .build()