// // 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? private var watchdogTask: Task? 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)") } } }