import Foundation /// Errors from relay API calls. public enum RelayError: Error, Sendable { case httpError(statusCode: Int) case invalidResponse case networkError(underlying: String) } extension RelayError: LocalizedError { public var errorDescription: String? { switch self { case .httpError(let code): return "Relay returned HTTP \(code)." case .invalidResponse: return "Relay response could not be parsed." case .networkError(let m): return "Network error: \(m)" } } } /// Interacts with an Org Social relay server. /// /// The relay exposes a `GET /feeds/` endpoint that returns the list of all /// registered feed URLs in the network. public struct RelayClient: Sendable { private let session: URLSession public init(session: URLSession = .shared) { self.session = session } /// Fetches the list of all feed URLs known to the relay. /// /// - Parameter relayURL: Base URL of the relay (e.g. `https://relay.org-social.org`). /// - Returns: Array of feed URLs. /// - Throws: `RelayError` on failure. public func fetchFeeds(from relayURL: URL) async throws -> [URL] { let endpoint = relayURL .absoluteString .trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let url = URL(string: endpoint + "/feeds/") else { throw RelayError.invalidResponse } let data: Data let response: URLResponse do { (data, response) = try await session.data(from: url) } catch { throw RelayError.networkError(underlying: error.localizedDescription) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw RelayError.httpError(statusCode: http.statusCode) } // Expected: {"data": ["https://...", "https://...", ...]} guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let rawFeeds = json["data"] as? [String] else { throw RelayError.invalidResponse } return rawFeeds.compactMap { URL(string: $0) } } /// Fetches vote counts for each option of a poll post. /// /// - Parameters: /// - postURL: Canonical URL of the poll post (`feed#timestamp`). /// - relayURL: Base URL of the relay. public func fetchPollVotes(for postURL: String, from relayURL: URL) async throws -> [OrgSocialPollVote] { let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) var allowed = CharacterSet.urlQueryAllowed allowed.remove(charactersIn: "+") let encoded = postURL.addingPercentEncoding(withAllowedCharacters: allowed) ?? postURL guard let url = URL(string: "\(base)/polls/votes/?post=\(encoded)") else { throw RelayError.invalidResponse } let (data, response) = try await fetchData(from: url) if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw RelayError.httpError(statusCode: http.statusCode) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let items = json["data"] as? [[String: Any]] else { throw RelayError.invalidResponse } return items.compactMap { item in guard let option = item["option"] as? String else { return nil } let votes = (item["votes"] as? [String]) ?? [] return OrgSocialPollVote(option: option, voterURLs: votes) } } /// Fetches the list of groups known to the relay. /// /// - Returns: Array of `(name, slug)` pairs. public func fetchGroupList(from relayURL: URL) async throws -> [(name: String, slug: String)] { let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let url = URL(string: "\(base)/groups/") else { throw RelayError.invalidResponse } let (data, response) = try await fetchData(from: url) if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw RelayError.httpError(statusCode: http.statusCode) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let links = json["_links"] as? [String: Any], let groupLinks = links["groups"] as? [[String: Any]] else { throw RelayError.invalidResponse } return groupLinks.compactMap { g in guard let name = g["name"] as? String, let href = g["href"] as? String, let slug = href.split(separator: "/").last.map(String.init) else { return nil } return (name: name, slug: slug) } } /// Fetches post URLs in a group (raw, unresolved). /// /// - Parameters: /// - slug: Group slug (e.g. `"emacs"`, `"org-social"`). /// - relayURL: Base URL of the relay. public func fetchGroupPostURLs(slug: String, from relayURL: URL) async throws -> [String] { let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let url = URL(string: "\(base)/groups/\(slug)/") else { throw RelayError.invalidResponse } let (data, response) = try await fetchData(from: url) if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw RelayError.httpError(statusCode: http.statusCode) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let items = json["data"] as? [[String: Any]] else { throw RelayError.invalidResponse } return items.compactMap { $0["post"] as? String } } /// Searches for post URLs matching `query` (text or tag search). /// /// - Parameters: /// - query: Search term or tag name. /// - isTag: If `true`, uses `?tag=` parameter; otherwise `?q=`. /// - relayURL: Base URL of the relay. /// - Returns: Array of matching post URLs. public func searchPostURLs(query: String, isTag: Bool, from relayURL: URL) async throws -> [String] { let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let param = isTag ? "tag" : "q" let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query guard let url = URL(string: "\(base)/search/?\(param)=\(encoded)") else { throw RelayError.invalidResponse } let (data, response) = try await fetchData(from: url) if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw RelayError.httpError(statusCode: http.statusCode) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] } // The relay returns `data` as a flat array of post URL strings. if let urls = json["data"] as? [String] { return urls } // Accept the older object form in case the relay changes its schema. if let items = json["data"] as? [[String: Any]] { return items.compactMap { $0["post"] as? String } } return [] } /// Registers a feed URL with the relay so it gets indexed. /// /// - Parameters: /// - feedURL: The public feed URL to register. /// - relayURL: Base URL of the relay. /// - Throws: `RelayError` on failure (ignores 4xx as feed may already be registered). public func registerFeed(_ feedURL: URL, with relayURL: URL) async throws { let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let url = URL(string: "\(base)/feeds/") else { throw RelayError.invalidResponse } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: ["feed": feedURL.absoluteString]) let (_, response) = try await session.data(for: request) if let http = response as? HTTPURLResponse, http.statusCode >= 500 { throw RelayError.httpError(statusCode: http.statusCode) } } /// Fetches notifications (mentions, reactions, replies, boosts) for `feedURL`. /// /// - Parameters: /// - feedURL: The public feed URL of the user (e.g. `https://andros.dev/social.org`). /// - relayURL: Base URL of the relay. /// - Returns: Array of notifications, most recent first. /// - Throws: `RelayError` on failure. public func fetchNotifications(for feedURL: URL, from relayURL: URL) async throws -> [OrgSocialNotification] { let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) let encodedFeed = feedURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? feedURL.absoluteString guard let url = URL(string: "\(base)/notifications/?feed=\(encodedFeed)") else { throw RelayError.invalidResponse } let data: Data let response: URLResponse do { (data, response) = try await session.data(from: url) } catch { throw RelayError.networkError(underlying: error.localizedDescription) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw RelayError.httpError(statusCode: http.statusCode) } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let items = json["data"] as? [[String: Any]] else { throw RelayError.invalidResponse } return items.compactMap { item in guard let post = item["post"] as? String, let typeStr = item["type"] as? String else { return nil } let kind = OrgSocialNotification.Kind(rawValue: typeStr) ?? .unknown let emoji = item["emoji"] as? String let parent = item["parent"] as? String return OrgSocialNotification(kind: kind, post: post, emoji: emoji, parent: parent) } } // MARK: - Private helpers private func fetchData(from url: URL) async throws -> (Data, URLResponse) { var request = URLRequest(url: url) request.timeoutInterval = 10 do { return try await session.data(for: request) } catch { throw RelayError.networkError(underlying: error.localizedDescription) } } }