Files
andros be5030ac0d Add BOT property support; fix %2B cache-buster bug; bump 1.3 (18)
- 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
2026-05-21 20:16:22 +02:00

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
}
}