be5030ac0d
- OrgSocialPost: add bot: String? field (never displayed, reserved for bot-aware rendering per spec) - OrgSocialParser: parse :BOT: property into post.bot - RelayClient.appendCacheBuster: use percentEncodedQueryItems instead of queryItems so %2B timezone offsets are preserved; fixes poll-votes 404 - Tests: add BotPropertyTests (parsed, not-in-body, nil-when-absent); all 240 tests now pass
264 lines
12 KiB
Swift
264 lines
12 KiB
Swift
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, response) = try await fetchData(from: url)
|
|
|
|
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, 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 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
|
|
// Relay sends "parent" for replies/reactions and "boosted" for boosts.
|
|
let parent = item["parent"] as? String ?? item["boosted"] as? String
|
|
return OrgSocialNotification(kind: kind, post: post, emoji: emoji, parent: parent)
|
|
}
|
|
}
|
|
|
|
/// Returns the number of followers for a feed, or nil if the relay does
|
|
/// not support the endpoint or the request fails.
|
|
public func fetchFollowerCount(for feedURL: URL, from relayURL: URL) async -> Int? {
|
|
let base = relayURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
var allowed = CharacterSet.urlQueryAllowed
|
|
allowed.remove(charactersIn: "+")
|
|
let encoded = feedURL.absoluteString.addingPercentEncoding(withAllowedCharacters: allowed) ?? feedURL.absoluteString
|
|
guard let url = URL(string: "\(base)/profile/?feed=\(encoded)"),
|
|
let (data, response) = try? await fetchData(from: url),
|
|
let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let meta = json["meta"] as? [String: Any],
|
|
let count = meta["total_followers"] as? Int else { return nil }
|
|
return count
|
|
}
|
|
|
|
// MARK: - Private helpers
|
|
|
|
/// Performs a GET that bypasses every layer of caching (URLSession on
|
|
/// device, intermediate proxies, and the CDN in front of the relay). A
|
|
/// stale response cached on the device once was enough to leave Discover,
|
|
/// Notifications and Search permanently broken with "Relay response could
|
|
/// not be parsed.", because URLSession kept replaying the bad body.
|
|
private func fetchData(from url: URL) async throws -> (Data, URLResponse) {
|
|
let busted = appendCacheBuster(to: url)
|
|
var request = URLRequest(url: busted)
|
|
request.timeoutInterval = 10
|
|
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
|
request.setValue("no-cache", forHTTPHeaderField: "Cache-Control")
|
|
request.setValue("no-cache", forHTTPHeaderField: "Pragma")
|
|
do { return try await session.data(for: request) }
|
|
catch { throw RelayError.networkError(underlying: error.localizedDescription) }
|
|
}
|
|
|
|
private func appendCacheBuster(to url: URL) -> URL {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
|
return url
|
|
}
|
|
// Use percentEncodedQueryItems to preserve existing percent-encoding
|
|
// (e.g. %2B in timezone offsets). queryItems auto-decodes and then
|
|
// re-encodes without %2B, turning "+0100" into a space on the server.
|
|
var items = components.percentEncodedQueryItems ?? []
|
|
items.append(URLQueryItem(name: "_t", value: String(Int(Date().timeIntervalSince1970))))
|
|
components.percentEncodedQueryItems = items
|
|
return components.url ?? url
|
|
}
|
|
}
|