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