Quellcode durchsuchen

Fix Zoom OAuth scopes, AI Companion loading, and meetings display.

Accept classic meeting:read and meeting:write tokens when validating
granular list and write scopes so scheduled meetings load reliably.

Add validAccessTokenForMeetingSummaries and fall back to listing past
meetings plus per-meeting summary fetches when the bulk list_summaries
API is unavailable on user-managed apps. Try the bulk summaries
endpoint first when the token allows it.

Improve home meeting day selection when all meetings are in the past
so the selected day jumps to the most recent meeting instead of
staying empty on the wrong calendar day.

Adjust OAuth authorize scopes and user-facing messages to match
supported Marketplace permissions.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 vor 2 Monaten
Ursprung
Commit
648702218a
1 geänderte Dateien mit 231 neuen und 24 gelöschten Zeilen
  1. 231 24
      zoom_app/ViewController.swift

+ 231 - 24
zoom_app/ViewController.swift

@@ -1166,7 +1166,10 @@ class ViewController: NSViewController {
                     return
                 }
                 let oauthParent = await MainActor.run { self.scheduleMeetingWindow ?? self.view.window }
-                let token = try await zoomOAuth.validAccessToken(presentingWindow: oauthParent)
+                let token = try await zoomOAuth.validAccessToken(
+                    presentingWindow: oauthParent,
+                    requiringAnyOf: ["meeting:write:meeting"]
+                )
                 let result = try await createZoomMeeting(
                     accessToken: token,
                     topic: topic,
@@ -2481,6 +2484,10 @@ class ViewController: NSViewController {
                 .sorted(by: { $0.start < $1.start })
                 .first {
                 selectedMeetingsDayStart = calendar.startOfDay(for: firstUpcoming.start)
+            } else if selectedDayHasMeetings == false,
+                      let latest = meetings.max(by: { $0.start < $1.start }) {
+                // All instances are in the past: jump to the most recent meeting day so the list is not stuck empty.
+                selectedMeetingsDayStart = calendar.startOfDay(for: latest.start)
             }
         }
         applyFilteredMeetings()
@@ -3039,7 +3046,10 @@ class ViewController: NSViewController {
             self.setMeetingsLoadingUI(true)
         }
         do {
-            let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
+            let zoomToken = try await zoomOAuth.validAccessToken(
+                presentingWindow: view.window,
+                requiringAnyOf: ["meeting:read:list_meetings"]
+            )
             if zoomAccountTimeZone == nil {
                 if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
                     let profileTZ = parseZoomTimeZone(profile.timezone)
@@ -3085,7 +3095,7 @@ class ViewController: NSViewController {
         let alert = NSAlert()
         alert.alertStyle = .informational
         alert.messageText = "Configure Zoom OAuth"
-        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable meeting:read and meeting:write scopes on the app so listing and scheduling work."
+        alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable granular scopes for listing meetings (meeting:read:list_meetings), scheduling (meeting:write:meeting), listing AI summaries (meeting:read:list_summaries), and reading a single summary (meeting:read:summary)."
 
         let wrapper = NSStackView()
         wrapper.orientation = .vertical
@@ -3450,9 +3460,29 @@ class ViewController: NSViewController {
         }
     }
 
-    /// Fetches the signed-in user's AI Companion meeting summaries via Zoom's
-    /// `GET /v2/meetings/meeting_summaries` endpoint. Requires the `meeting:read:summary` scope.
+    /// Fetches AI Companion summaries: prefers `GET /v2/meetings/meeting_summaries` when the token has
+    /// `meeting:read:list_summaries`. User-managed apps usually lack that scope; we then list **past**
+    /// meetings via `meeting:read:list_meetings` and load each `meeting:read:summary`.
     private func fetchZoomMeetingSummaries(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
+        if let bulk = try await fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: accessToken, from: from, to: to) {
+            return bulk
+        }
+        return try await fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: accessToken, from: from, to: to)
+    }
+
+    private func zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: Data, statusCode: Int) -> Bool {
+        guard (200..<300).contains(statusCode) == false else { return false }
+        let raw = String(data: data, encoding: .utf8) ?? ""
+        if raw.localizedCaseInsensitiveContains("does not contain scopes") { return true }
+        if raw.localizedCaseInsensitiveContains("list_summaries") { return true }
+        if raw.localizedCaseInsensitiveContains("meeting_summaries") && raw.localizedCaseInsensitiveContains("scope") {
+            return true
+        }
+        return false
+    }
+
+    /// Returns `nil` when the token cannot use the bulk list endpoint (typical for user-managed apps).
+    private func fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary]? {
         struct Page: Decodable {
             let summaries: [ZoomMeetingSummaryAPIItem]?
             let meeting_summaries: [ZoomMeetingSummaryAPIItem]?
@@ -3497,6 +3527,9 @@ class ViewController: NSViewController {
                 throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
             }
             guard (200..<300).contains(http.statusCode) else {
+                if safetyCounter == 0, zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: data, statusCode: http.statusCode) {
+                    return nil
+                }
                 let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summaries"
                 if raw.localizedCaseInsensitiveContains("does not contain scopes") {
                     throw ZoomOAuthError.missingRequiredScope(raw)
@@ -3522,6 +3555,104 @@ class ViewController: NSViewController {
         return collected
     }
 
+    private func fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
+        struct PastMeeting: Decodable {
+            let id: Int?
+            let uuid: String?
+            let topic: String?
+            let start_time: String?
+        }
+
+        struct PastMeetingsPage: Decodable {
+            let meetings: [PastMeeting]
+            let next_page_token: String?
+        }
+
+        let iso = ISO8601DateFormatter()
+        iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+        let fallbackISO = ISO8601DateFormatter()
+        fallbackISO.formatOptions = [.withInternetDateTime]
+
+        let dayFormatter = DateFormatter()
+        dayFormatter.dateFormat = "yyyy-MM-dd"
+        dayFormatter.timeZone = TimeZone(identifier: "UTC")
+
+        func parseStart(_ raw: String?) -> Date? {
+            guard let raw, raw.isEmpty == false else { return nil }
+            return iso.date(from: raw) ?? fallbackISO.date(from: raw)
+        }
+
+        func isInSelectedRange(start: Date?) -> Bool {
+            guard let start else { return true }
+            if let from, start < from { return false }
+            if let to, start > to { return false }
+            return true
+        }
+
+        var candidates: [(meetingId: String, start: Date?)] = []
+        var nextPageToken: String?
+        var pageIdx = 0
+        repeat {
+            var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
+            var items: [URLQueryItem] = [
+                URLQueryItem(name: "type", value: "past"),
+                URLQueryItem(name: "page_size", value: "30")
+            ]
+            if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
+            if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
+            if let nextPageToken, nextPageToken.isEmpty == false {
+                items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
+            }
+            components.queryItems = items
+            var request = URLRequest(url: components.url!)
+            request.httpMethod = "GET"
+            request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+
+            let (data, response) = try await URLSession.shared.data(for: request)
+            guard let http = response as? HTTPURLResponse else {
+                throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (past meetings)")
+            }
+            if http.statusCode == 429 {
+                let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
+                let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
+                throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
+            }
+            guard (200..<300).contains(http.statusCode) else {
+                let raw = String(data: data, encoding: .utf8) ?? "Failed to load past meetings"
+                if raw.localizedCaseInsensitiveContains("does not contain scopes") {
+                    throw ZoomOAuthError.missingRequiredScope(raw)
+                }
+                throw GoogleOAuthError.tokenExchangeFailed(raw)
+            }
+
+            let decoded = try JSONDecoder().decode(PastMeetingsPage.self, from: data)
+            for m in decoded.meetings {
+                guard let numericId = m.id else { continue }
+                let start = parseStart(m.start_time)
+                guard isInSelectedRange(start: start) else { continue }
+                candidates.append((String(numericId), start))
+            }
+
+            let t = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
+            nextPageToken = (t?.isEmpty == false) ? t : nil
+            pageIdx += 1
+            if pageIdx >= 15 { break }
+            if candidates.count >= 80 { break }
+        } while nextPageToken != nil
+
+        candidates.sort { ($0.start ?? .distantPast) > ($1.start ?? .distantPast) }
+
+        var summaries: [AICompanionSummary] = []
+        let maxDetailFetches = 40
+        for c in candidates.prefix(maxDetailFetches) {
+            if let detail = try await fetchZoomMeetingSummaryDetail(accessToken: accessToken, meetingIdOrUUID: c.meetingId) {
+                summaries.append(detail)
+            }
+        }
+
+        return summaries
+    }
+
     /// Fetches a single AI Companion summary by meeting ID or UUID via
     /// `GET /v2/meetings/{meetingId}/meeting_summary`. UUIDs containing `/` or `//` must be
     /// double-URL-encoded per Zoom's API conventions.
@@ -6859,7 +6990,7 @@ class ViewController: NSViewController {
         }
 
         do {
-            let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
+            let token = try await zoomOAuth.validAccessTokenForMeetingSummaries(presentingWindow: view.window)
             let (from, to) = aiCompanionDateRange()
             let summaries = try await fetchZoomMeetingSummaries(accessToken: token, from: from, to: to)
             await MainActor.run {
@@ -6896,7 +7027,7 @@ class ViewController: NSViewController {
 
                 if case ZoomOAuthError.missingRequiredScope = error {
                     self.zoomOAuth.clearSavedTokens()
-                    self.aiCompanionStatusLabel?.stringValue = "Zoom permissions are missing. Add meeting:read:summary in your Zoom Marketplace app, then sign in again."
+                    self.aiCompanionStatusLabel?.stringValue = "Zoom permissions are missing. Enable \"View a user's meetings\" and \"View a meeting's summary\" on your Marketplace app, then sign in again."
                 } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
                     let minutes = max(1, Int(ceil(Double(retryAfterSeconds ?? 300) / 60.0)))
                     self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
@@ -8379,19 +8510,65 @@ final class ZoomOAuthService: NSObject {
     }
 
     func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
+        // Default: baseline meeting access used throughout the app.
+        try await validAccessToken(presentingWindow: presentingWindow, requiringAnyOf: [
+            // Zoom's newer granular scopes for listing and scheduling meetings.
+            "meeting:read:list_meetings",
+            "meeting:write:meeting"
+        ])
+    }
+
+    func validAccessToken(presentingWindow: NSWindow?, requiringAnyOf requiredScopes: [String]) async throws -> String {
         if let tokens = try tokenStore.readTokens(),
            tokens.expiresAt.timeIntervalSinceNow > 60,
-           tokenHasRequiredScope(tokens.scope) {
+           tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
             return tokens.accessToken
         } else if var tokens = try tokenStore.readTokens(),
                   let refreshed = try await refreshTokens(tokens) {
             tokens = refreshed
             try tokenStore.writeTokens(tokens)
+            if tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
+                return tokens.accessToken
+            }
+        }
+
+        let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
+        try tokenStore.writeTokens(tokens)
+        guard tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) else {
+            throw ZoomOAuthError.missingRequiredScope(tokens.scope ?? "Missing required scope(s).")
+        }
+        return tokens.accessToken
+    }
+
+    /// User-managed OAuth apps often **cannot** add `meeting:read:list_summaries` (admin/master only).
+    /// In that case we load AI summaries by listing past meetings (`meeting:read:list_meetings`) and
+    /// calling `meeting:read:summary` per meeting.
+    func validAccessTokenForMeetingSummaries(presentingWindow: NSWindow?) async throws -> String {
+        func canLoadSummaries(_ scope: String?) -> Bool {
+            tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_summaries"])
+                || (tokenHasScopes(scope, requiringAnyOf: ["meeting:read:summary"])
+                    && tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_meetings"]))
+        }
+
+        if let tokens = try tokenStore.readTokens(),
+           tokens.expiresAt.timeIntervalSinceNow > 60,
+           canLoadSummaries(tokens.scope) {
             return tokens.accessToken
+        } else if var tokens = try tokenStore.readTokens(),
+                  let refreshed = try await refreshTokens(tokens) {
+            tokens = refreshed
+            try tokenStore.writeTokens(tokens)
+            if canLoadSummaries(tokens.scope) {
+                return tokens.accessToken
+            }
         }
 
         let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
         try tokenStore.writeTokens(tokens)
+        guard canLoadSummaries(tokens.scope) else {
+            let hint = " Grant meeting:read:list_summaries (if available), or both meeting:read:list_meetings and meeting:read:summary."
+            throw ZoomOAuthError.missingRequiredScope((tokens.scope ?? "Missing required scope(s).") + hint)
+        }
         return tokens.accessToken
     }
 
@@ -8402,13 +8579,27 @@ final class ZoomOAuthService: NSObject {
 
         let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
         let state = UUID().uuidString
+        // Explicitly request the scopes we rely on.
+        // Without this, Zoom can reuse a previously granted token that doesn't include newly added scopes.
+        // Note: `meeting:read:list_summaries` is often unavailable on user-managed General apps; we
+        // fall back to per-meeting summary fetches using `meeting:read:summary` + past meeting list.
+        let requestedScopes = [
+            "meeting:read:list_meetings",
+            "meeting:write:meeting",
+            "meeting:read:summary",
+            "zoomapp:inmeeting"
+        ]
 
         var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
-        // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
         components.queryItems = [
             URLQueryItem(name: "response_type", value: "code"),
             URLQueryItem(name: "client_id", value: clientId),
             URLQueryItem(name: "redirect_uri", value: redirectURI),
+            URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
+            // Force consent so updated scopes are actually granted.
+            URLQueryItem(name: "prompt", value: "consent"),
+            // Zoom-specific: forces interactive verification even if already signed in.
+            URLQueryItem(name: "force_verify", value: "true"),
             URLQueryItem(name: "state", value: state)
         ]
         guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
@@ -8514,24 +8705,40 @@ final class ZoomOAuthService: NSObject {
         )
     }
 
-    private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
-        guard let scopeValue, scopeValue.isEmpty == false else { return false }
-        let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
-        return parts.contains { part in
-            part == "meeting:read"
-                || part == "meeting:read:admin"
-                || part == "meeting:write"
-                || part == "meeting:write:admin"
-                || part == "meeting:read:summary"
-                || part == "meeting:read:summary:admin"
-                || part.contains("meeting:read")
-                || part.contains("meeting:write")
-                || part.contains("list_meetings")
-                || part.contains("list_user_meetings")
-                || part.contains("meeting:read:summary")
+    private func normalizedGrantedScopes(_ scopeValue: String?) -> Set<String> {
+        guard let scopeValue, scopeValue.isEmpty == false else { return [] }
+        let parts = scopeValue.split { $0 == " " || $0 == "," }
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { $0.isEmpty == false }
+        return Set(parts)
+    }
+
+    /// Whether `granted` includes the Zoom scope `req` (granular exact match, admin/master variants,
+    /// or legacy broad scopes Zoom still returns on some tokens).
+    private func grantedScopeSatisfies(_ granted: Set<String>, requirement req: String) -> Bool {
+        if granted.contains(req)
+            || granted.contains("\(req):admin")
+            || granted.contains("\(req):master") {
+            return true
+        }
+        // Classic scopes: listing / reading meetings and summaries often appear as `meeting:read`
+        // instead of `meeting:read:list_meetings` / `meeting:read:summary` on the token string.
+        switch req {
+        case "meeting:read:list_meetings", "meeting:read:summary":
+            return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
+        case "meeting:write:meeting":
+            return granted.contains("meeting:write") || granted.contains("meeting:write:admin")
+        default:
+            return false
         }
     }
 
+    private func tokenHasScopes(_ scopeValue: String?, requiringAnyOf requiredScopes: [String]) -> Bool {
+        let granted = normalizedGrantedScopes(scopeValue)
+        if granted.isEmpty { return false }
+        return requiredScopes.contains { grantedScopeSatisfies(granted, requirement: $0) }
+    }
+
     private static func basicAuth(clientId: String, clientSecret: String) -> String {
         let joined = "\(clientId):\(clientSecret)"
         return Data(joined.utf8).base64EncodedString()