import Foundation final class FeishuService { private let baseURL = "https://open.feishu.cn/open-apis" private let session: URLSession private let decoder: JSONDecoder init(session: URLSession = .shared) { self.session = session let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase self.decoder = decoder } func fetchBugs( config: AppConfig, assigneeName: String, accessToken: String, userOpenId: String? = nil ) async throws -> [Bug] { let records = try await fetchRecordsFiltered( config: config, assigneeName: assigneeName, accessToken: accessToken, userOpenId: userOpenId ) return records .map { BugMapper.map($0, config: config) } .filter { bug in bug.assignee.localizedCaseInsensitiveContains(assigneeName) } } func fetchRecordCount( appToken: String, tableId: String, accessToken: String ) async throws -> Int { let page = try await fetchPage( appToken: appToken, tableId: tableId, pageToken: nil, pageSize: 1, accessToken: accessToken ) return page.total ?? page.items.count } // MARK: - Filtered fetch (3-tier fallback) /// Fetch records using a 3-tier strategy: /// 1. Search endpoint with open_id filter (Person-type fields, requires OAuth) /// 2. List endpoint with CurrentValue filter (text-type fields) /// 3. Unfiltered list (current behavior — fetch all, filter client-side) private func fetchRecordsFiltered( config: AppConfig, assigneeName: String, accessToken: String, userOpenId: String? ) async throws -> [RecordItem] { // Tier 1: search endpoint with open_id (Person fields) if let openId = userOpenId, !openId.isEmpty { do { let records = try await searchRecords( config: config, accessToken: accessToken, assigneeField: config.fieldMappings.assigneeField, openId: openId ) if !records.isEmpty { BuggerLog.debug("FeishuService: tier 1 (search by open_id) returned \(records.count) records") return records } BuggerLog.debug("FeishuService: tier 1 returned 0 records, falling back") } catch { BuggerLog.debug("FeishuService: tier 1 failed (\(error.localizedDescription)), falling back") } } // Tier 2: list endpoint with CurrentValue filter (text fields) let filterStr = "CurrentValue.[\(config.fieldMappings.assigneeField)]=\"\(assigneeName)\"" do { let records = try await listAllRecords( config: config, accessToken: accessToken, filter: filterStr ) if !records.isEmpty { BuggerLog.debug("FeishuService: tier 2 (list with filter) returned \(records.count) records") return records } BuggerLog.debug("FeishuService: tier 2 returned 0 records, falling back") } catch { BuggerLog.debug("FeishuService: tier 2 failed (\(error.localizedDescription)), falling back") } // Tier 3: unfiltered list (original behavior) BuggerLog.debug("FeishuService: tier 3 (unfiltered list)") return try await listAllRecords( config: config, accessToken: accessToken, filter: nil ) } // MARK: - List endpoint (GET /records) private func listAllRecords( config: AppConfig, accessToken: String, filter: String? ) async throws -> [RecordItem] { var allRecords: [RecordItem] = [] var pageToken: String? var pages = 0 let maxPages = 50 repeat { pages += 1 guard pages <= maxPages else { throw FeishuError.paginationLimitExceeded } let page = try await fetchPage( appToken: config.appToken, tableId: config.tableId, pageToken: pageToken, accessToken: accessToken, filter: filter ) allRecords.append(contentsOf: page.items) pageToken = page.hasMore ? page.pageToken : nil } while pageToken != nil return allRecords } private func fetchPage( appToken: String, tableId: String, pageToken: String?, pageSize: Int = 500, accessToken: String, filter: String? = nil ) async throws -> RecordListData { guard !appToken.isEmpty, !tableId.isEmpty else { throw FeishuError.invalidConfiguration("App token and table ID must not be empty.") } guard var components = URLComponents( string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records" ) else { throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.") } var queryItems = [URLQueryItem(name: "page_size", value: String(pageSize))] if let pageToken { queryItems.append(URLQueryItem(name: "page_token", value: pageToken)) } if let filter, !filter.isEmpty { queryItems.append(URLQueryItem(name: "filter", value: filter)) } components.queryItems = queryItems guard let url = components.url else { throw FeishuError.invalidConfiguration("Could not build records request URL.") } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } let rawBody = String(data: data, encoding: .utf8) ?? "" BuggerLog.debug("fetchPage: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } let apiResponse: FeishuAPIResponse do { apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) } catch { BuggerLog.error("fetchPage: decode FAILED: \(error)") BuggerLog.error("fetchPage: raw body: \(String(rawBody.prefix(1000)))") throw FeishuError.decodingError(error, rawBody: String(rawBody.prefix(200))) } guard apiResponse.code == 0, let pageData = apiResponse.data else { throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) } return pageData } // MARK: - Attachment download (GET /drive/v1/medias/{file_token}/download) /// Downloads a Bitable attachment by its file_token. Bitables with advanced /// permissions require an `extra` query param for auth, e.g. /// `{"bitablePerm":{"tableId":"...","attachments":{"":{"":[""]}}}}`. func downloadAttachment(fileToken: String, accessToken: String, extra: String? = nil) async throws -> Data { guard var components = URLComponents( string: "\(baseURL)/drive/v1/medias/\(fileToken)/download" ) else { throw FeishuError.invalidConfiguration("Invalid attachment file token.") } if let extra { components.queryItems = [URLQueryItem(name: "extra", value: extra)] } guard let url = components.url else { throw FeishuError.invalidConfiguration("Could not build attachment download URL.") } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 60 let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } guard (200..<300).contains(httpResponse.statusCode) else { let body = String(data: data, encoding: .utf8) ?? "" BuggerLog.error("downloadAttachment: HTTP \(httpResponse.statusCode) body=\(String(body.prefix(300)))") throw FeishuError.apiError(code: httpResponse.statusCode, message: String(body.prefix(200))) } return data } // MARK: - Field metadata (GET /fields) /// Returns the table's field metadata. Select fields carry their options /// (name/id/color); field IDs (fldXXX) are needed to build the `extra` /// auth parameter for attachment downloads. func fetchFields( appToken: String, tableId: String, accessToken: String ) async throws -> [FieldItem] { var result: [FieldItem] = [] var pageToken: String? repeat { guard var components = URLComponents( string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/fields" ) else { throw FeishuError.invalidConfiguration("App token or table ID contains invalid characters.") } var queryItems = [URLQueryItem(name: "page_size", value: "100")] if let pageToken { queryItems.append(URLQueryItem(name: "page_token", value: pageToken)) } components.queryItems = queryItems guard let url = components.url else { throw FeishuError.invalidConfiguration("Could not build fields request URL.") } var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.timeoutInterval = 30 let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } let apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) guard apiResponse.code == 0, let pageData = apiResponse.data else { throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) } result.append(contentsOf: pageData.items) pageToken = pageData.hasMore ? pageData.pageToken : nil } while pageToken != nil return result } // MARK: - Update record (PUT /records/{record_id}) /// Updates fields of a single record. Keys are field names. Included fields /// are fully replaced; omitted fields are untouched. Select fields take the /// option name; text fields take a plain string; attachment fields take a /// full array of ["file_token": ...] entries. func updateRecord( appToken: String, tableId: String, recordId: String, fields: [String: Any], accessToken: String ) async throws { guard let url = URL( string: "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records/\(recordId)" ) else { throw FeishuError.invalidConfiguration("Could not build record update URL.") } var request = URLRequest(url: url) request.httpMethod = "PUT" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 30 request.httpBody = try JSONSerialization.data(withJSONObject: ["fields": fields]) let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } let rawBody = String(data: data, encoding: .utf8) ?? "" BuggerLog.debug("updateRecord: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } let apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) guard apiResponse.code == 0 else { throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) } } // MARK: - Media upload (POST /drive/v1/medias/upload_all) /// Uploads an image into the Bitable's media store and returns its /// file_token, which can then be added to an attachment field via /// updateRecord. Max 20 MB; uploads must be sequential. func uploadMedia( appToken: String, fileName: String, data: Data, mimeType: String, accessToken: String ) async throws -> String { guard let url = URL(string: "\(baseURL)/drive/v1/medias/upload_all") else { throw FeishuError.invalidConfiguration("Could not build media upload URL.") } let boundary = "BuggerBoundary-\(UUID().uuidString)" var body = Data() func appendField(_ name: String, _ value: String) { body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!) body.append("\(value)\r\n".data(using: .utf8)!) } appendField("file_name", fileName) appendField("parent_type", "bitable_image") appendField("parent_node", appToken) appendField("size", String(data.count)) appendField("checksum", String(adler32(data))) appendField("extra", "{\"drive_route_token\":\"\(appToken)\"}") body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) body.append(data) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 120 request.httpBody = body let (responseData, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } let rawBody = String(data: responseData, encoding: .utf8) ?? "" BuggerLog.debug("uploadMedia: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") let apiResponse = try decoder.decode(FeishuAPIResponse.self, from: responseData) guard apiResponse.code == 0, let token = apiResponse.data?.fileToken else { throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) } return token } /// Adler-32 checksum as required by the media upload API. private func adler32(_ data: Data) -> UInt32 { var a: UInt32 = 1 var b: UInt32 = 0 for byte in data { a = (a + UInt32(byte)) % 65521 b = (b + a) % 65521 } return (b << 16) | a } // MARK: - Search endpoint (POST /records/search) private func searchRecords( config: AppConfig, accessToken: String, assigneeField: String, openId: String ) async throws -> [RecordItem] { var allRecords: [RecordItem] = [] var pageToken: String? var pages = 0 let maxPages = 50 repeat { pages += 1 guard pages <= maxPages else { throw FeishuError.paginationLimitExceeded } let page = try await searchPage( appToken: config.appToken, tableId: config.tableId, pageToken: pageToken, accessToken: accessToken, assigneeField: assigneeField, openId: openId ) allRecords.append(contentsOf: page.items) pageToken = page.hasMore ? page.pageToken : nil } while pageToken != nil return allRecords } private func searchPage( appToken: String, tableId: String, pageToken: String?, accessToken: String, assigneeField: String, openId: String ) async throws -> RecordListData { guard !appToken.isEmpty, !tableId.isEmpty else { throw FeishuError.invalidConfiguration("App token and table ID must not be empty.") } let urlString = "\(baseURL)/bitable/v1/apps/\(appToken)/tables/\(tableId)/records/search" guard let url = URL(string: urlString) else { throw FeishuError.invalidConfiguration("Could not build search request URL.") } var body: [String: Any] = [ "page_size": 500, "filter": [ "conjunction": "and", "conditions": [ [ "field_name": assigneeField, "operator": "is", "value": [openId], ] ] ] ] if let pageToken, !pageToken.isEmpty { body["page_token"] = pageToken } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.timeoutInterval = 30 request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, response) = try await session.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { throw FeishuError.networkError(nil) } let rawBody = String(data: data, encoding: .utf8) ?? "" BuggerLog.debug("searchPage: HTTP \(httpResponse.statusCode) body=\(String(rawBody.prefix(500)))") if httpResponse.statusCode == 401 { throw FeishuError.unauthorized } let apiResponse: FeishuAPIResponse do { apiResponse = try decoder.decode(FeishuAPIResponse.self, from: data) } catch { BuggerLog.error("searchPage: decode FAILED: \(error)") throw FeishuError.decodingError(error, rawBody: String(rawBody.prefix(200))) } guard apiResponse.code == 0, let pageData = apiResponse.data else { throw FeishuError.apiError(code: apiResponse.code, message: apiResponse.msg) } return pageData } }