~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
323 lines · 13.4 KB Raw History
//
//  AtircClient.swift
//  AT-IRC
//
//  XRPC calls to the atirc server: chat.atirc.mint, chat.atirc.post,
//  chat.atirc.list, plus the live chat.atirc.subscribe WebSocket.
//

import Foundation

struct AtircHTTPError: LocalizedError {
    let status: Int
    let name: String
    let message: String
    var errorDescription: String? { "\(name) (HTTP \(status)): \(message)" }
    var isUnauthorized: Bool { status == 401 }
}

@MainActor
final class AtircClient {
    private let oauth = OAuthClient()
    private let session: URLSession

    init() {
        let configuration = URLSessionConfiguration.ephemeral
        configuration.timeoutIntervalForRequest = 30
        configuration.waitsForConnectivity = true
        session = URLSession(configuration: configuration)
    }

    // MARK: - Mint

    /// Exchange a short-lived PDS service-auth JWT for a long-lived atirc token.
    func mint(oauthSession: OAuthSession, subhandle: String) async throws -> (MintResult, OAuthSession) {
        let (serviceToken, updatedSession) = try await oauth.serviceAuth(
            session: oauthSession,
            lxm: "chat.atirc.mint",
            aud: AtircService.serviceRef
        )

        var request = URLRequest(url: endpoint("/xrpc/chat.atirc.mint"))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(serviceToken)", forHTTPHeaderField: "Authorization")
        request.httpBody = try JSONSerialization.data(withJSONObject: ["subhandle": subhandle])

        let (data, response) = try await session.data(for: request)
        try Self.check(response: response, data: data)
        let mint = try JSONDecoder().decode(MintResult.self, from: data)
        return (mint, updatedSession)
    }

    // MARK: - Post

    func post(text: String, channel: String?, token: String, baseURL: String = AtircService.defaultBaseURL) async throws -> AtircMessage {
        var body: [String: Any] = ["text": text]
        if let channel, !channel.isEmpty {
            body["channel"] = channel
        }
        var request = URLRequest(url: endpoint("/xrpc/chat.atirc.post", baseURL: baseURL))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.httpBody = try JSONSerialization.data(withJSONObject: body)

        let (data, response) = try await session.data(for: request)
        try Self.check(response: response, data: data)
        return try JSONDecoder().decode(AtircMessage.self, from: data)
    }

    // MARK: - List (public backfill)

    func list(channel: String?, cursor: Int64 = 0, limit: Int = 50, baseURL: String = AtircService.defaultBaseURL) async throws -> AtircListResponse {
        var components = URLComponents(url: endpoint("/xrpc/chat.atirc.list", baseURL: baseURL), resolvingAgainstBaseURL: false)!
        var items = [URLQueryItem(name: "cursor", value: String(cursor)),
                     URLQueryItem(name: "limit", value: String(limit))]
        if let channel, !channel.isEmpty {
            items.append(URLQueryItem(name: "channel", value: channel))
        }
        components.queryItems = items
        guard let url = components.url else {
            throw AtircHTTPError(status: 0, name: "InvalidRequest", message: "Bad list URL")
        }
        let (data, response) = try await session.data(from: url)
        try Self.check(response: response, data: data)
        return try JSONDecoder().decode(AtircListResponse.self, from: data)
    }

    // MARK: - Owner questions

    /// Pending questions addressed to the owner (or every question with
    /// `status = "all"`). Always carries the current owner mode.
    func questions(token: String, status: String = "all", baseURL: String = AtircService.defaultBaseURL) async throws -> AskQuestionsResponse {
        var components = URLComponents(url: endpoint("/xrpc/chat.atirc.questions", baseURL: baseURL), resolvingAgainstBaseURL: false)!
        components.queryItems = [URLQueryItem(name: "status", value: status)]
        guard let url = components.url else {
            throw AtircHTTPError(status: 0, name: "InvalidRequest", message: "Bad questions URL")
        }
        var request = URLRequest(url: url)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        let (data, response) = try await session.data(for: request)
        try Self.check(response: response, data: data)
        return try JSONDecoder().decode(AskQuestionsResponse.self, from: data)
    }

    /// Answer a question. `answer` is the option text (with its 0-based
    /// `choice` index) or free-form text (no choice).
    func answer(questionID: String, answer: String, choice: Int?, token: String, baseURL: String = AtircService.defaultBaseURL) async throws -> AskQuestion {
        var body: [String: Any] = ["id": questionID]
        if !answer.isEmpty {
            body["answer"] = answer
        }
        if let choice {
            body["choice"] = choice
        }
        let data = try await sendJSON("/xrpc/chat.atirc.answer", body: body, token: token, baseURL: baseURL)
        return try JSONDecoder().decode(AskQuestion.self, from: data)
    }

    func ownerMode(token: String, baseURL: String = AtircService.defaultBaseURL) async throws -> OwnerModeResponse {
        var request = URLRequest(url: endpoint("/xrpc/chat.atirc.ownerMode", baseURL: baseURL))
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        let (data, response) = try await session.data(for: request)
        try Self.check(response: response, data: data)
        return try JSONDecoder().decode(OwnerModeResponse.self, from: data)
    }

    /// `mode` is `available` (questions wait for the owner) or `autonomous`
    /// (agents research and decide, recording an ADR). The response body is
    /// not needed; the next questions poll returns the authoritative mode.
    func setOwnerMode(_ mode: String, token: String, baseURL: String = AtircService.defaultBaseURL) async throws {
        _ = try await sendJSON("/xrpc/chat.atirc.ownerMode", body: ["mode": mode], token: token, baseURL: baseURL)
    }

    // MARK: - Helpers

    private func sendJSON(_ path: String, body: [String: Any], token: String, baseURL: String) async throws -> Data {
        var request = URLRequest(url: endpoint(path, baseURL: baseURL))
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.httpBody = try JSONSerialization.data(withJSONObject: body)
        let (data, response) = try await session.data(for: request)
        try Self.check(response: response, data: data)
        return data
    }

    func endpoint(_ path: String, baseURL: String = AtircService.defaultBaseURL) -> URL {
        URL(string: baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path)!
    }

    static func check(response: URLResponse, data: Data) throws {
        guard let http = response as? HTTPURLResponse else {
            throw AtircHTTPError(status: 0, name: "BadResponse", message: "Not an HTTP response")
        }
        guard (200..<300).contains(http.statusCode) else {
            var name = "HTTP\(http.statusCode)"
            var message = String(data: data, encoding: .utf8) ?? ""
            if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
                name = object["error"] as? String ?? name
                message = object["message"] as? String ?? message
            }
            throw AtircHTTPError(status: http.statusCode, name: name, message: message)
        }
    }
}

// MARK: - Subscription

/// Live chat.atirc.subscribe client.
///
/// atirc uses Event Stream v0 framing: each binary WebSocket message is two
/// concatenated CBOR objects — a header (`{"op":1,"t":"#message"}`) and a body
/// with the message object.
@MainActor
final class AtircWebSocket: NSObject, URLSessionWebSocketDelegate {
    private var session: URLSession?
    private var task: URLSessionWebSocketTask?
    private var isClosed = false
    private var pingTask: Task<Void, Never>?
    private var watchdogTask: Task<Void, Never>?
    private var lastActivity = Date()

    var onFrame: ((AtircFrame) -> Void)?
    var onStatus: ((String) -> Void)?

    func connect(url: URL, token: String) {
        disconnect()
        isClosed = false

        // URLSessionWebSocketTask issues a proper HTTP Upgrade request for the
        // full URL (path + query), unlike NWConnection's WebSocket options.
        var request = URLRequest(url: url)
        request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        request.setValue("atirc/xrpc/1", forHTTPHeaderField: "Sec-WebSocket-Protocol")

        let configuration = URLSessionConfiguration.ephemeral
        configuration.waitsForConnectivity = true
        let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
        self.session = session

        let task = session.webSocketTask(with: request)
        self.task = task
        task.resume()
        onStatus?("connecting")
        receiveLoop()
        startPing()
    }

    func disconnect() {
        isClosed = true
        pingTask?.cancel()
        pingTask = nil
        watchdogTask?.cancel()
        watchdogTask = nil
        task?.cancel(with: .goingAway, reason: nil)
        task = nil
        session?.invalidateAndCancel()
        session = nil
    }

    /// A server exec-upgrade can leave the client socket hung with no FIN or
    /// Close frame. A ping every 25s and a 60s receive watchdog surface that
    /// promptly so ChatStore can reconnect with backoff.
    private func startPing() {
        pingTask?.cancel()
        watchdogTask?.cancel()
        lastActivity = Date()
        pingTask = Task { @MainActor [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(25))
                guard let self, !self.isClosed, let task = self.task else { return }
                task.sendPing { [weak self] error in
                    Task { @MainActor in
                        guard let self, !self.isClosed else { return }
                        if let error {
                            let nsError = error as NSError
                            self.onStatus?("error: ping failed: \(error.localizedDescription) [\(nsError.domain):\(nsError.code)]")
                        } else {
                            self.lastActivity = Date()
                        }
                    }
                }
            }
        }
        watchdogTask = Task { @MainActor [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(5))
                guard let self, !self.isClosed else { return }
                if Date().timeIntervalSince(self.lastActivity) > 60 {
                    self.onStatus?("error: receive watchdog timeout (60s)")
                    return
                }
            }
        }
    }

    // MARK: URLSessionWebSocketDelegate

    nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
        Task { @MainActor in
            guard !self.isClosed else { return }
            self.lastActivity = Date()
            self.onStatus?("connected")
        }
    }

    nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
        Task { @MainActor in
            guard !self.isClosed else { return }
            var status = "closed (\(closeCode.rawValue))"
            if let reason, let text = String(data: reason, encoding: .utf8), !text.isEmpty {
                status += ": \(text)"
            }
            self.onStatus?(status)
        }
    }

    nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
        guard let error else { return }
        Task { @MainActor in
            guard !self.isClosed else { return }
            let nsError = error as NSError
            self.onStatus?("error: \(error.localizedDescription) [\(nsError.domain):\(nsError.code)]")
        }
    }

    // MARK: Frame decoding

    private func receiveLoop() {
        guard let task, !isClosed else { return }
        task.receive { [weak self] result in
            Task { @MainActor in
                guard let self, !self.isClosed else { return }
                switch result {
                case .success(let message):
                    switch message {
                    case .data(let data):
                        self.handle(data: data)
                    case .string(let text):
                        if let data = text.data(using: .utf8) {
                            self.handle(data: data)
                        }
                    @unknown default:
                        break
                    }
                    self.receiveLoop()
                case .failure(let error):
                    self.onStatus?("error: \(error.localizedDescription)")
                }
            }
        }
    }

    private func handle(data: Data) {
        lastActivity = Date()
        if let frame = try? AtircFrame.decode(data) {
            onFrame?(frame)
        } else {
            onStatus?("undecodable frame (\(data.count) bytes)")
        }
    }
}