diff --git a/Package.resolved b/Package.resolved index 5957f7e..51549fc 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "588bff50c2acc1e7fc8a48e4cd7e69605871ec12cb138ce96710e0b88cebb635", + "originHash" : "cbf9e754c518e5eb56690c6dd9d096349450adf8254fc91d657d65f243cfb57f", "pins" : [ - { - "identity" : "coreatprotocol", - "kind" : "remoteSourceControl", - "location" : "https://tangled.org/@sparrowtek.com/CoreATProtocol", - "state" : { - "branch" : "main", - "revision" : "df2572331f02660378b0c09005b0bac7d39041d2" - } - }, { "identity" : "jwt-kit", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 5f97eaa..bebb3e7 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,8 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), + .package(path: "../CoreATProtocol"), +// .package(url: "https://tangled.org/@sparrowtek.com/CoreATProtocol", branch: "main"), ], targets: [ .target( diff --git a/Sources/bskyKit/BskyAPI.swift b/Sources/bskyKit/BskyAPI.swift index 23b1fe0..0f6704e 100644 --- a/Sources/bskyKit/BskyAPI.swift +++ b/Sources/bskyKit/BskyAPI.swift @@ -8,21 +8,45 @@ import Foundation import CoreATProtocol +struct SendableAny: @unchecked Sendable { + let value: Any +} + enum BskyAPI { // Actor endpoints case getPreferences case getProfile(did: String) case getProfiles(dids: [String]) + case getSuggestions(limit: Int, cursor: String?) case searchActors(query: String, limit: Int) case searchActorsTypeahead(query: String, limit: Int) // Feed endpoints case getFeed(feed: String, limit: Int, cursor: String?) + case getActorFeeds(actor: String, limit: Int, cursor: String?) + case getFeedGenerator(feed: String) case getFeedGenerators(feeds: [String]) + case getListFeed(list: String, limit: Int, cursor: String?) + case getQuotes(uri: String, cid: String?, limit: Int, cursor: String?) + case getSuggestedFeeds(limit: Int, cursor: String?) case getTimeline(limit: Int, cursor: String?) case getAuthorFeed(did: String, limit: Int, cursor: String?, filter: String?) case getPostThread(uri: String, depth: Int) case getPosts(uris: [String]) + case searchPosts( + query: String, + sort: String?, + since: String?, + until: String?, + mentions: String?, + author: String?, + lang: String?, + domain: String?, + url: String?, + tags: [String]?, + limit: Int, + cursor: String? + ) case getActorLikes(did: String, limit: Int, cursor: String?) case getLikes(uri: String, limit: Int, cursor: String?) case getRepostedBy(uri: String, limit: Int, cursor: String?) @@ -32,18 +56,32 @@ enum BskyAPI { case getFollowers(did: String, limit: Int, cursor: String?) case getBlocks(limit: Int, cursor: String?) case getMutes(limit: Int, cursor: String?) + case getRelationships(actor: String, others: [String]) + case muteActor(actor: String) + case unmuteActor(actor: String) + case muteThread(root: String) + case unmuteThread(root: String) + case muteActorList(list: String) + case unmuteActorList(list: String) // Notification endpoints case listNotifications(limit: Int, cursor: String?) case getUnreadCount case updateSeen(seenAt: Date) + + // Generic endpoints for newer/less-common lexicons. + case xrpcQuery(id: String, parameters: [String: SendableAny]) + case xrpcProcedure(id: String, body: [String: SendableAny]?) + case xrpcDataProcedure(id: String, data: Data, contentType: String, accept: String?) } extension BskyAPI: EndpointType { public var baseURL: URL { get async { - guard let host = await APEnvironment.current.host else { fatalError("Host not set.") } - guard let url = URL(string: host) else { fatalError("BskyAPI baseURL not configured.") } + guard let host = await APEnvironment.current.host, + let url = URL(string: host) else { + return URL(string: "https://invalid.invalid")! + } return url } } @@ -54,15 +92,22 @@ extension BskyAPI: EndpointType { case .getPreferences: "/xrpc/app.bsky.actor.getPreferences" case .getProfile: "/xrpc/app.bsky.actor.getProfile" case .getProfiles: "/xrpc/app.bsky.actor.getProfiles" + case .getSuggestions: "/xrpc/app.bsky.actor.getSuggestions" case .searchActors: "/xrpc/app.bsky.actor.searchActors" case .searchActorsTypeahead: "/xrpc/app.bsky.actor.searchActorsTypeahead" // Feed case .getFeed: "/xrpc/app.bsky.feed.getFeed" + case .getActorFeeds: "/xrpc/app.bsky.feed.getActorFeeds" + case .getFeedGenerator: "/xrpc/app.bsky.feed.getFeedGenerator" case .getFeedGenerators: "/xrpc/app.bsky.feed.getFeedGenerators" + case .getListFeed: "/xrpc/app.bsky.feed.getListFeed" + case .getQuotes: "/xrpc/app.bsky.feed.getQuotes" + case .getSuggestedFeeds: "/xrpc/app.bsky.feed.getSuggestedFeeds" case .getTimeline: "/xrpc/app.bsky.feed.getTimeline" case .getAuthorFeed: "/xrpc/app.bsky.feed.getAuthorFeed" case .getPostThread: "/xrpc/app.bsky.feed.getPostThread" case .getPosts: "/xrpc/app.bsky.feed.getPosts" + case .searchPosts: "/xrpc/app.bsky.feed.searchPosts" case .getActorLikes: "/xrpc/app.bsky.feed.getActorLikes" case .getLikes: "/xrpc/app.bsky.feed.getLikes" case .getRepostedBy: "/xrpc/app.bsky.feed.getRepostedBy" @@ -71,22 +116,35 @@ extension BskyAPI: EndpointType { case .getFollowers: "/xrpc/app.bsky.graph.getFollowers" case .getBlocks: "/xrpc/app.bsky.graph.getBlocks" case .getMutes: "/xrpc/app.bsky.graph.getMutes" + case .getRelationships: "/xrpc/app.bsky.graph.getRelationships" + case .muteActor: "/xrpc/app.bsky.graph.muteActor" + case .unmuteActor: "/xrpc/app.bsky.graph.unmuteActor" + case .muteThread: "/xrpc/app.bsky.graph.muteThread" + case .unmuteThread: "/xrpc/app.bsky.graph.unmuteThread" + case .muteActorList: "/xrpc/app.bsky.graph.muteActorList" + case .unmuteActorList: "/xrpc/app.bsky.graph.unmuteActorList" // Notifications case .listNotifications: "/xrpc/app.bsky.notification.listNotifications" case .getUnreadCount: "/xrpc/app.bsky.notification.getUnreadCount" case .updateSeen: "/xrpc/app.bsky.notification.updateSeen" + case .xrpcQuery(let id, _), .xrpcProcedure(let id, _), .xrpcDataProcedure(let id, _, _, _): + "/xrpc/\(id)" } } var httpMethod: HTTPMethod { switch self { - case .getPreferences, .getProfile, .getProfiles, .searchActors, .searchActorsTypeahead, - .getFeed, .getFeedGenerators, .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .getActorLikes, .getLikes, .getRepostedBy, - .getFollows, .getFollowers, .getBlocks, .getMutes, + case .getPreferences, .getProfile, .getProfiles, .getSuggestions, .searchActors, .searchActorsTypeahead, + .getFeed, .getActorFeeds, .getFeedGenerator, .getFeedGenerators, .getListFeed, .getQuotes, .getSuggestedFeeds, + .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .searchPosts, .getActorLikes, .getLikes, .getRepostedBy, + .getFollows, .getFollowers, .getBlocks, .getMutes, .getRelationships, .listNotifications, .getUnreadCount: return .get - case .updateSeen: + case .updateSeen, .muteActor, .unmuteActor, .muteThread, .unmuteThread, .muteActorList, .unmuteActorList, + .xrpcProcedure, .xrpcDataProcedure: return .post + case .xrpcQuery: + return .get } } @@ -102,6 +160,11 @@ extension BskyAPI: EndpointType { case .getProfiles(let dids): return .requestParameters(encoding: .urlEncoding(parameters: ["actors": dids])) + case .getSuggestions(let limit, let cursor): + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + case .searchActors(let query, let limit): return .requestParameters(encoding: .urlEncoding(parameters: [ "q": query, @@ -120,9 +183,33 @@ extension BskyAPI: EndpointType { if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) + case .getActorFeeds(let actor, let limit, let cursor): + var params: Parameters = ["actor": actor, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getFeedGenerator(let feed): + return .requestParameters(encoding: .urlEncoding(parameters: ["feed": feed])) + case .getFeedGenerators(let feeds): return .requestParameters(encoding: .urlEncoding(parameters: ["feeds": feeds])) + case .getListFeed(let list, let limit, let cursor): + var params: Parameters = ["list": list, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getQuotes(let uri, let cid, let limit, let cursor): + var params: Parameters = ["uri": uri, "limit": limit] + if let cid { params["cid"] = cid } + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .getSuggestedFeeds(let limit, let cursor): + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + case .getTimeline(let limit, let cursor): var params: Parameters = ["limit": limit] if let cursor { params["cursor"] = cursor } @@ -143,6 +230,33 @@ extension BskyAPI: EndpointType { case .getPosts(let uris): return .requestParameters(encoding: .urlEncoding(parameters: ["uris": uris])) + case .searchPosts( + let query, + let sort, + let since, + let until, + let mentions, + let author, + let lang, + let domain, + let url, + let tags, + let limit, + let cursor + ): + var params: Parameters = ["q": query, "limit": limit] + if let sort { params["sort"] = sort } + if let since { params["since"] = since } + if let until { params["until"] = until } + if let mentions { params["mentions"] = mentions } + if let author { params["author"] = author } + if let lang { params["lang"] = lang } + if let domain { params["domain"] = domain } + if let url { params["url"] = url } + if let tags, !tags.isEmpty { params["tag"] = tags } + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + case .getActorLikes(let did, let limit, let cursor): var params: Parameters = ["actor": did, "limit": limit] if let cursor { params["cursor"] = cursor } @@ -179,6 +293,21 @@ extension BskyAPI: EndpointType { if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) + case .getRelationships(let actor, let others): + return .requestParameters(encoding: .urlEncoding(parameters: [ + "actor": actor, + "others": others + ])) + + case .muteActor(let actor), .unmuteActor(let actor): + return .requestParameters(encoding: .jsonEncoding(parameters: ["actor": actor])) + + case .muteThread(let root), .unmuteThread(let root): + return .requestParameters(encoding: .jsonEncoding(parameters: ["root": root])) + + case .muteActorList(let list), .unmuteActorList(let list): + return .requestParameters(encoding: .jsonEncoding(parameters: ["list": list])) + // Notification endpoints case .listNotifications(let limit, let cursor): var params: Parameters = ["limit": limit] @@ -191,10 +320,31 @@ extension BskyAPI: EndpointType { return .requestParameters(encoding: .jsonEncoding(parameters: [ "seenAt": formatter.string(from: seenAt) ])) + + case .xrpcQuery(_, let parameters): + let unboxed = parameters.mapValues(\.value) + if parameters.isEmpty { + return .request + } + return .requestParameters(encoding: .urlEncoding(parameters: unboxed)) + + case .xrpcProcedure(_, let body): + guard let body else { return .request } + return .requestParameters(encoding: .jsonEncoding(parameters: body.mapValues(\.value))) + + case .xrpcDataProcedure(_, let data, _, _): + return .requestParameters(encoding: .jsonDataEncoding(data: data)) } } var headers: HTTPHeaders? { - nil + switch self { + case .xrpcDataProcedure(_, _, let contentType, let accept): + var headers: HTTPHeaders = ["Content-Type": contentType] + if let accept { headers["Accept"] = accept } + return headers + default: + return nil + } } } diff --git a/Sources/bskyKit/BskyService.swift b/Sources/bskyKit/BskyService.swift index e258b94..943cde9 100644 --- a/Sources/bskyKit/BskyService.swift +++ b/Sources/bskyKit/BskyService.swift @@ -72,13 +72,50 @@ public struct BskyService: Sendable { /// `setup(hostURL:accessJWT:refreshJWT:)`. public init() {} + private func execute(_ endpoint: BskyAPI) async throws -> T { + try ensureHostConfigured() + return try await router.execute(endpoint) + } + + private func executeQuery( + _ id: String, + parameters: Parameters = [:] + ) async throws -> T { + try await execute(.xrpcQuery(id: id, parameters: box(parameters))) + } + + private func executeProcedure( + _ id: String, + body: Parameters? = nil + ) async throws -> T { + try await execute(.xrpcProcedure(id: id, body: body.map(box))) + } + + private func executeDataProcedure( + _ id: String, + data: Data, + contentType: String, + accept: String? = "application/json" + ) async throws -> T { + try await execute(.xrpcDataProcedure( + id: id, + data: data, + contentType: contentType, + accept: accept + )) + } + + private func box(_ parameters: Parameters) -> [String: SendableAny] { + parameters.mapValues { SendableAny(value: $0) } + } + // MARK: - Actor /// Fetches the authenticated user's preferences. /// - Returns: The user's saved preferences including pinned feeds. /// - Throws: An error if the request fails or the user is not authenticated. public func getPreferences() async throws -> Preferences { - try await router.execute(.getPreferences) + try await execute(.getPreferences) } /// Fetches a user profile by handle or DID. @@ -86,7 +123,7 @@ public struct BskyService: Sendable { /// - Returns: The user's profile. /// - Throws: An error if the profile is not found or the request fails. public func getProfile(for did: String) async throws -> Profile { - try await router.execute(.getProfile(did: did)) + try await execute(.getProfile(did: did)) } /// Fetches multiple user profiles in a single request. @@ -94,7 +131,12 @@ public struct BskyService: Sendable { /// - Returns: The profiles for the requested users. /// - Throws: An error if the request fails. public func getProfiles(for dids: [String]) async throws -> Profiles { - try await router.execute(.getProfiles(dids: dids)) + try await execute(.getProfiles(dids: dids)) + } + + /// Fetches actor suggestions for the authenticated user. + public func getSuggestions(limit: Int = 25, cursor: String? = nil) async throws -> ActorSuggestionsResponse { + try await execute(.getSuggestions(limit: limit, cursor: cursor)) } /// Searches for users matching a query. @@ -104,7 +146,7 @@ public struct BskyService: Sendable { /// - Returns: Matching user profiles with optional cursor for pagination. /// - Throws: An error if the request fails. public func searchActors(query: String, limit: Int = 25) async throws -> SearchActorsResult { - try await router.execute(.searchActors(query: query, limit: limit)) + try await execute(.searchActors(query: query, limit: limit)) } /// Fast search for autocomplete functionality. @@ -114,7 +156,7 @@ public struct BskyService: Sendable { /// - Returns: Matching profiles optimized for autocomplete. /// - Throws: An error if the request fails. public func searchActorsTypeahead(query: String, limit: Int = 10) async throws -> SearchActorsTypeaheadResult { - try await router.execute(.searchActorsTypeahead(query: query, limit: limit)) + try await execute(.searchActorsTypeahead(query: query, limit: limit)) } // MARK: - Feed @@ -127,7 +169,17 @@ public struct BskyService: Sendable { /// - Returns: Feed posts with cursor for pagination. /// - Throws: An error if the feed is not found or the request fails. public func getFeed(feed: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed { - try await router.execute(.getFeed(feed: feed, limit: limit, cursor: cursor)) + try await execute(.getFeed(feed: feed, limit: limit, cursor: cursor)) + } + + /// Fetches feed generators by actor. + public func getActorFeeds(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> FeedPageResponse { + try await execute(.getActorFeeds(actor: actor, limit: limit, cursor: cursor)) + } + + /// Fetches a single feed generator by AT-URI. + public func getFeedGenerator(feed: String) async throws -> FeedGeneratorResponse { + try await execute(.getFeedGenerator(feed: feed)) } /// Fetches information about custom feed generators. @@ -135,7 +187,22 @@ public struct BskyService: Sendable { /// - Returns: Details about the requested feed generators. /// - Throws: An error if the request fails. public func getFeedGenerators(for feeds: [String]) async throws -> Feeds { - try await router.execute(.getFeedGenerators(feeds: feeds)) + try await execute(.getFeedGenerators(feeds: feeds)) + } + + /// Fetches posts from a list feed. + public func getListFeed(list: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed { + try await execute(.getListFeed(list: list, limit: limit, cursor: cursor)) + } + + /// Fetches quotes for a post. + public func getQuotes(uri: String, cid: String? = nil, limit: Int = 50, cursor: String? = nil) async throws -> QuotesResponse { + try await execute(.getQuotes(uri: uri, cid: cid, limit: limit, cursor: cursor)) + } + + /// Fetches suggested feed generators. + public func getSuggestedFeeds(limit: Int = 50, cursor: String? = nil) async throws -> FeedPageResponse { + try await execute(.getSuggestedFeeds(limit: limit, cursor: cursor)) } /// Fetches the authenticated user's home timeline. @@ -145,7 +212,7 @@ public struct BskyService: Sendable { /// - Returns: Timeline posts with cursor for pagination. /// - Throws: An error if the user is not authenticated or the request fails. public func getTimeline(limit: Int = 50, cursor: String? = nil) async throws -> Timeline { - try await router.execute(.getTimeline(limit: limit, cursor: cursor)) + try await execute(.getTimeline(limit: limit, cursor: cursor)) } /// Fetches posts from a specific user's feed. @@ -157,7 +224,7 @@ public struct BskyService: Sendable { /// - Returns: The user's posts with cursor for pagination. /// - Throws: An error if the request fails. public func getAuthorFeed(for did: String, limit: Int = 50, cursor: String? = nil, filter: String? = nil) async throws -> AuthorFeed { - try await router.execute(.getAuthorFeed(did: did, limit: limit, cursor: cursor, filter: filter)) + try await execute(.getAuthorFeed(did: did, limit: limit, cursor: cursor, filter: filter)) } /// Fetches a post and its reply thread. @@ -167,7 +234,7 @@ public struct BskyService: Sendable { /// - Returns: The post thread with nested replies. /// - Throws: An error if the post is not found or the request fails. public func getPostThread(uri: String, depth: Int = 6) async throws -> PostThreadResponse { - try await router.execute(.getPostThread(uri: uri, depth: depth)) + try await execute(.getPostThread(uri: uri, depth: depth)) } /// Fetches multiple posts by URI in a single request. @@ -175,7 +242,38 @@ public struct BskyService: Sendable { /// - Returns: The requested posts. /// - Throws: An error if the request fails. public func getPosts(uris: [String]) async throws -> Posts { - try await router.execute(.getPosts(uris: uris)) + try await execute(.getPosts(uris: uris)) + } + + /// Searches posts. + public func searchPosts( + query: String, + sort: String? = nil, + since: String? = nil, + until: String? = nil, + mentions: String? = nil, + author: String? = nil, + lang: String? = nil, + domain: String? = nil, + url: String? = nil, + tags: [String]? = nil, + limit: Int = 25, + cursor: String? = nil + ) async throws -> SearchPostsResponse { + try await execute(.searchPosts( + query: query, + sort: sort, + since: since, + until: until, + mentions: mentions, + author: author, + lang: lang, + domain: domain, + url: url, + tags: tags, + limit: limit, + cursor: cursor + )) } /// Fetches posts liked by a specific user. @@ -186,7 +284,7 @@ public struct BskyService: Sendable { /// - Returns: The user's liked posts with cursor for pagination. /// - Throws: An error if the request fails. public func getActorLikes(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> AuthorFeed { - try await router.execute(.getActorLikes(did: did, limit: limit, cursor: cursor)) + try await execute(.getActorLikes(did: did, limit: limit, cursor: cursor)) } /// Fetches users who liked a specific post. @@ -197,7 +295,7 @@ public struct BskyService: Sendable { /// - Returns: Users who liked the post with cursor for pagination. /// - Throws: An error if the request fails. public func getLikes(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> Likes { - try await router.execute(.getLikes(uri: uri, limit: limit, cursor: cursor)) + try await execute(.getLikes(uri: uri, limit: limit, cursor: cursor)) } /// Fetches users who reposted a specific post. @@ -208,7 +306,7 @@ public struct BskyService: Sendable { /// - Returns: Users who reposted with cursor for pagination. /// - Throws: An error if the request fails. public func getRepostedBy(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> RepostedBy { - try await router.execute(.getRepostedBy(uri: uri, limit: limit, cursor: cursor)) + try await execute(.getRepostedBy(uri: uri, limit: limit, cursor: cursor)) } // MARK: - Graph @@ -221,7 +319,7 @@ public struct BskyService: Sendable { /// - Returns: Users being followed with cursor for pagination. /// - Throws: An error if the request fails. public func getFollows(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Follows { - try await router.execute(.getFollows(did: did, limit: limit, cursor: cursor)) + try await execute(.getFollows(did: did, limit: limit, cursor: cursor)) } /// Fetches the list of users following a specific user. @@ -232,7 +330,7 @@ public struct BskyService: Sendable { /// - Returns: Followers with cursor for pagination. /// - Throws: An error if the request fails. public func getFollowers(for did: String, limit: Int = 50, cursor: String? = nil) async throws -> Followers { - try await router.execute(.getFollowers(did: did, limit: limit, cursor: cursor)) + try await execute(.getFollowers(did: did, limit: limit, cursor: cursor)) } /// Fetches the authenticated user's blocked accounts. @@ -242,7 +340,7 @@ public struct BskyService: Sendable { /// - Returns: Blocked profiles with cursor for pagination. /// - Throws: An error if not authenticated or the request fails. public func getBlocks(limit: Int = 50, cursor: String? = nil) async throws -> Blocks { - try await router.execute(.getBlocks(limit: limit, cursor: cursor)) + try await execute(.getBlocks(limit: limit, cursor: cursor)) } /// Fetches the authenticated user's muted accounts. @@ -252,7 +350,42 @@ public struct BskyService: Sendable { /// - Returns: Muted profiles with cursor for pagination. /// - Throws: An error if not authenticated or the request fails. public func getMutes(limit: Int = 50, cursor: String? = nil) async throws -> Mutes { - try await router.execute(.getMutes(limit: limit, cursor: cursor)) + try await execute(.getMutes(limit: limit, cursor: cursor)) + } + + /// Fetches relationship state between an actor and other actors. + public func getRelationships(for actor: String, others: [String]) async throws -> RelationshipsResponse { + try await execute(.getRelationships(actor: actor, others: others)) + } + + /// Mutes an actor. + public func muteActor(_ actor: String) async throws { + let _: EmptyResponse = try await execute(.muteActor(actor: actor)) + } + + /// Unmutes an actor. + public func unmuteActor(_ actor: String) async throws { + let _: EmptyResponse = try await execute(.unmuteActor(actor: actor)) + } + + /// Mutes a thread by root URI. + public func muteThread(root: String) async throws { + let _: EmptyResponse = try await execute(.muteThread(root: root)) + } + + /// Unmutes a thread by root URI. + public func unmuteThread(root: String) async throws { + let _: EmptyResponse = try await execute(.unmuteThread(root: root)) + } + + /// Mutes an actor list by URI. + public func muteActorList(_ list: String) async throws { + let _: EmptyResponse = try await execute(.muteActorList(list: list)) + } + + /// Unmutes an actor list by URI. + public func unmuteActorList(_ list: String) async throws { + let _: EmptyResponse = try await execute(.unmuteActorList(list: list)) } // MARK: - Notifications @@ -264,21 +397,462 @@ public struct BskyService: Sendable { /// - Returns: Notifications with cursor for pagination. /// - Throws: An error if not authenticated or the request fails. public func listNotifications(limit: Int = 50, cursor: String? = nil) async throws -> NotificationsResponse { - try await router.execute(.listNotifications(limit: limit, cursor: cursor)) + try await execute(.listNotifications(limit: limit, cursor: cursor)) } /// Fetches the count of unread notifications. /// - Returns: The number of unread notifications. /// - Throws: An error if not authenticated or the request fails. public func getUnreadCount() async throws -> UnreadCount { - try await router.execute(.getUnreadCount) + try await execute(.getUnreadCount) } /// Marks notifications as seen up to the specified time. /// - Parameter date: The timestamp to mark as seen (default: now). /// - Throws: An error if not authenticated or the request fails. public func updateSeen(at date: Date = Date()) async throws { - let _: EmptyResponse = try await router.execute(.updateSeen(seenAt: date)) + let _: EmptyResponse = try await execute(.updateSeen(seenAt: date)) + } + + // MARK: - Priority 2: Account, Bookmark, and Graph Collections + + /// Updates actor preferences payload. + public func putPreferences(_ preferences: [String: Any]) async throws { + let _: EmptyResponse = try await executeProcedure( + "app.bsky.actor.putPreferences", + body: ["preferences": preferences] + ) + } + + /// Creates a bookmark for a post. + public func createBookmark(uri: String, cid: String) async throws { + let _: EmptyResponse = try await executeProcedure( + "app.bsky.bookmark.createBookmark", + body: ["uri": uri, "cid": cid] + ) + } + + /// Deletes a bookmark by post URI. + public func deleteBookmark(uri: String) async throws { + let _: EmptyResponse = try await executeProcedure( + "app.bsky.bookmark.deleteBookmark", + body: ["uri": uri] + ) + } + + /// Lists bookmarks for the authenticated actor. + public func getBookmarks(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.bookmark.getBookmarks", parameters: params) + } + + /// Describes the current feed generator account and feeds. + public func describeFeedGenerator() async throws -> JSONValue { + try await executeQuery("app.bsky.feed.describeFeedGenerator") + } + + /// Fetches raw feed skeleton response for a feed generator. + public func getFeedSkeleton(feed: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["feed": feed, "limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.feed.getFeedSkeleton", parameters: params) + } + + /// Sends interaction signals to ranking services. + public func sendInteractions(_ interactions: [[String: Any]]) async throws { + let _: EmptyResponse = try await executeProcedure( + "app.bsky.feed.sendInteractions", + body: ["interactions": interactions] + ) + } + + /// Fetches starter packs authored by an actor. + public func getActorStarterPacks(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["actor": actor, "limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.getActorStarterPacks", parameters: params) + } + + /// Fetches known followers for an actor. + public func getKnownFollowers(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> Followers { + var params: Parameters = ["actor": actor, "limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.getKnownFollowers", parameters: params) + } + + /// Fetches a list and its membership. + public func getList(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["list": uri, "limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.getList", parameters: params) + } + + /// Fetches lists blocked by the authenticated actor. + public func getListBlocks(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.getListBlocks", parameters: params) + } + + /// Fetches lists muted by the authenticated actor. + public func getListMutes(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.getListMutes", parameters: params) + } + + /// Fetches lists created by an actor. + public func getLists( + for actor: String, + limit: Int = 50, + cursor: String? = nil, + purposes: [String]? = nil + ) async throws -> JSONValue { + var params: Parameters = ["actor": actor, "limit": limit] + if let cursor { params["cursor"] = cursor } + if let purposes, !purposes.isEmpty { params["purposes"] = purposes } + return try await executeQuery("app.bsky.graph.getLists", parameters: params) + } + + /// Fetches lists with membership state for an actor. + public func getListsWithMembership( + for actor: String, + limit: Int = 50, + cursor: String? = nil, + purposes: [String]? = nil + ) async throws -> JSONValue { + var params: Parameters = ["actor": actor, "limit": limit] + if let cursor { params["cursor"] = cursor } + if let purposes, !purposes.isEmpty { params["purposes"] = purposes } + return try await executeQuery("app.bsky.graph.getListsWithMembership", parameters: params) + } + + /// Fetches a single starter pack. + public func getStarterPack(uri: String) async throws -> JSONValue { + try await executeQuery("app.bsky.graph.getStarterPack", parameters: ["starterPack": uri]) + } + + /// Fetches multiple starter packs by URI. + public func getStarterPacks(uris: [String]) async throws -> JSONValue { + try await executeQuery("app.bsky.graph.getStarterPacks", parameters: ["uris": uris]) + } + + /// Fetches starter packs with membership for an actor. + public func getStarterPacksWithMembership(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["actor": actor, "limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.getStarterPacksWithMembership", parameters: params) + } + + /// Fetches suggested follows for an actor. + public func getSuggestedFollowsByActor(for actor: String) async throws -> JSONValue { + try await executeQuery("app.bsky.graph.getSuggestedFollowsByActor", parameters: ["actor": actor]) } -} + /// Searches starter packs. + public func searchStarterPacks(query: String, limit: Int = 25, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["q": query, "limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.searchStarterPacks", parameters: params) + } + + /// Fetches labeler services by DID. + public func getLabelerServices(dids: [String], detailed: Bool? = nil) async throws -> JSONValue { + var params: Parameters = ["dids": dids] + if let detailed { params["detailed"] = detailed } + return try await executeQuery("app.bsky.labeler.getServices", parameters: params) + } + + // MARK: - Priority 3: Notification Preferences and Push + + /// Fetches notification preferences. + public func getNotificationPreferences() async throws -> JSONValue { + try await executeQuery("app.bsky.notification.getPreferences") + } + + /// Lists activity subscriptions. + public func listActivitySubscriptions(limit: Int = 50, cursor: String? = nil) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.notification.listActivitySubscriptions", parameters: params) + } + + /// Upserts an activity subscription for a subject. + public func putActivitySubscription(subject: String, activitySubscription: [String: Any]) async throws -> JSONValue { + try await executeProcedure( + "app.bsky.notification.putActivitySubscription", + body: [ + "subject": subject, + "activitySubscription": activitySubscription + ] + ) + } + + /// Updates legacy notification priority setting. + public func putNotificationPreferences(priority: Bool) async throws { + let _: EmptyResponse = try await executeProcedure( + "app.bsky.notification.putPreferences", + body: ["priority": priority] + ) + } + + /// Updates v2 notification preference payload. + public func putNotificationPreferencesV2(_ preferences: [String: Any]) async throws -> JSONValue { + try await executeProcedure("app.bsky.notification.putPreferencesV2", body: preferences) + } + + /// Registers a push token. + public func registerPush( + serviceDid: String, + token: String, + platform: String, + appID: String, + ageRestricted: Bool? = nil + ) async throws { + var body: Parameters = [ + "serviceDid": serviceDid, + "token": token, + "platform": platform, + "appId": appID + ] + if let ageRestricted { body["ageRestricted"] = ageRestricted } + let _: EmptyResponse = try await executeProcedure("app.bsky.notification.registerPush", body: body) + } + + /// Unregisters a push token. + public func unregisterPush(serviceDid: String, token: String, platform: String, appID: String) async throws { + let _: EmptyResponse = try await executeProcedure( + "app.bsky.notification.unregisterPush", + body: [ + "serviceDid": serviceDid, + "token": token, + "platform": platform, + "appId": appID + ] + ) + } + + // MARK: - Priority 4: Age Assurance, Unspecced, and Video + + /// Starts age-assurance flow. + public func beginAgeAssurance( + email: String, + language: String? = nil, + countryCode: String? = nil, + regionCode: String? = nil + ) async throws { + var body: Parameters = ["email": email] + if let language { body["language"] = language } + if let countryCode { body["countryCode"] = countryCode } + if let regionCode { body["regionCode"] = regionCode } + let _: EmptyResponse = try await executeProcedure("app.bsky.ageassurance.begin", body: body) + } + + /// Fetches age-assurance service config. + public func getAgeAssuranceConfig() async throws -> JSONValue { + try await executeQuery("app.bsky.ageassurance.getConfig") + } + + /// Fetches age-assurance state. + public func getAgeAssuranceState(countryCode: String? = nil, regionCode: String? = nil) async throws -> JSONValue { + var params: Parameters = [:] + if let countryCode { params["countryCode"] = countryCode } + if let regionCode { params["regionCode"] = regionCode } + return try await executeQuery("app.bsky.ageassurance.getState", parameters: params) + } + + /// Unspecced age-assurance state endpoint. + public func getUnspeccedAgeAssuranceState() async throws -> JSONValue { + try await executeQuery("app.bsky.unspecced.getAgeAssuranceState") + } + + /// Unspecced service config endpoint. + public func getUnspeccedConfig() async throws -> JSONValue { + try await executeQuery("app.bsky.unspecced.getConfig") + } + + public func getOnboardingSuggestedStarterPacks(limit: Int = 25) async throws -> JSONValue { + try await executeQuery("app.bsky.unspecced.getOnboardingSuggestedStarterPacks", parameters: ["limit": limit]) + } + + public func getOnboardingSuggestedStarterPacksSkeleton(viewer: String? = nil, limit: Int = 25) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let viewer { params["viewer"] = viewer } + return try await executeQuery("app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton", parameters: params) + } + + public func getPopularFeedGenerators(limit: Int = 50, cursor: String? = nil, query: String? = nil) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + if let query { params["query"] = query } + return try await executeQuery("app.bsky.unspecced.getPopularFeedGenerators", parameters: params) + } + + public func getPostThreadV2( + anchor: String, + above: Int? = nil, + below: Int? = nil, + branchingFactor: Int? = nil, + sort: String? = nil + ) async throws -> JSONValue { + var params: Parameters = ["anchor": anchor] + if let above { params["above"] = above } + if let below { params["below"] = below } + if let branchingFactor { params["branchingFactor"] = branchingFactor } + if let sort { params["sort"] = sort } + return try await executeQuery("app.bsky.unspecced.getPostThreadV2", parameters: params) + } + + public func getPostThreadOtherV2(anchor: String) async throws -> JSONValue { + try await executeQuery("app.bsky.unspecced.getPostThreadOtherV2", parameters: ["anchor": anchor]) + } + + public func getUnspeccedSuggestedFeeds(limit: Int = 50) async throws -> JSONValue { + try await executeQuery("app.bsky.unspecced.getSuggestedFeeds", parameters: ["limit": limit]) + } + + public func getSuggestedFeedsSkeleton(viewer: String? = nil, limit: Int = 50) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let viewer { params["viewer"] = viewer } + return try await executeQuery("app.bsky.unspecced.getSuggestedFeedsSkeleton", parameters: params) + } + + public func getSuggestedStarterPacks(limit: Int = 50) async throws -> JSONValue { + try await executeQuery("app.bsky.unspecced.getSuggestedStarterPacks", parameters: ["limit": limit]) + } + + public func getSuggestedStarterPacksSkeleton(viewer: String? = nil, limit: Int = 50) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let viewer { params["viewer"] = viewer } + return try await executeQuery("app.bsky.unspecced.getSuggestedStarterPacksSkeleton", parameters: params) + } + + public func getSuggestedUsers(category: String? = nil, limit: Int = 25) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let category { params["category"] = category } + return try await executeQuery("app.bsky.unspecced.getSuggestedUsers", parameters: params) + } + + public func getSuggestedUsersSkeleton(viewer: String? = nil, category: String? = nil, limit: Int = 25) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let viewer { params["viewer"] = viewer } + if let category { params["category"] = category } + return try await executeQuery("app.bsky.unspecced.getSuggestedUsersSkeleton", parameters: params) + } + + public func getSuggestionsSkeleton( + viewer: String? = nil, + limit: Int = 50, + cursor: String? = nil, + relativeToDid: String? = nil + ) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let viewer { params["viewer"] = viewer } + if let cursor { params["cursor"] = cursor } + if let relativeToDid { params["relativeToDid"] = relativeToDid } + return try await executeQuery("app.bsky.unspecced.getSuggestionsSkeleton", parameters: params) + } + + public func getTaggedSuggestions() async throws -> JSONValue { + try await executeQuery("app.bsky.unspecced.getTaggedSuggestions") + } + + public func getTrendingTopics(viewer: String? = nil, limit: Int = 10) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let viewer { params["viewer"] = viewer } + return try await executeQuery("app.bsky.unspecced.getTrendingTopics", parameters: params) + } + + public func getTrends(limit: Int = 10) async throws -> JSONValue { + try await executeQuery("app.bsky.unspecced.getTrends", parameters: ["limit": limit]) + } + + public func getTrendsSkeleton(viewer: String? = nil, limit: Int = 10) async throws -> JSONValue { + var params: Parameters = ["limit": limit] + if let viewer { params["viewer"] = viewer } + return try await executeQuery("app.bsky.unspecced.getTrendsSkeleton", parameters: params) + } + + public func initAgeAssurance(email: String, language: String? = nil, countryCode: String? = nil) async throws { + var body: Parameters = ["email": email] + if let language { body["language"] = language } + if let countryCode { body["countryCode"] = countryCode } + let _: EmptyResponse = try await executeProcedure("app.bsky.unspecced.initAgeAssurance", body: body) + } + + public func searchActorsSkeleton( + query: String, + viewer: String? = nil, + typeahead: Bool? = nil, + limit: Int = 25, + cursor: String? = nil + ) async throws -> JSONValue { + var params: Parameters = ["q": query, "limit": limit] + if let viewer { params["viewer"] = viewer } + if let typeahead { params["typeahead"] = typeahead } + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.unspecced.searchActorsSkeleton", parameters: params) + } + + public func searchPostsSkeleton( + query: String, + sort: String? = nil, + since: String? = nil, + until: String? = nil, + mentions: String? = nil, + author: String? = nil, + lang: String? = nil, + domain: String? = nil, + url: String? = nil, + tags: [String]? = nil, + viewer: String? = nil, + limit: Int = 25, + cursor: String? = nil + ) async throws -> JSONValue { + var params: Parameters = ["q": query, "limit": limit] + if let sort { params["sort"] = sort } + if let since { params["since"] = since } + if let until { params["until"] = until } + if let mentions { params["mentions"] = mentions } + if let author { params["author"] = author } + if let lang { params["lang"] = lang } + if let domain { params["domain"] = domain } + if let url { params["url"] = url } + if let tags, !tags.isEmpty { params["tag"] = tags } + if let viewer { params["viewer"] = viewer } + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.unspecced.searchPostsSkeleton", parameters: params) + } + + public func searchStarterPacksSkeleton( + query: String, + viewer: String? = nil, + limit: Int = 25, + cursor: String? = nil + ) async throws -> JSONValue { + var params: Parameters = ["q": query, "limit": limit] + if let viewer { params["viewer"] = viewer } + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.unspecced.searchStarterPacksSkeleton", parameters: params) + } + + /// Fetches video processing status for a job. + public func getVideoJobStatus(jobID: String) async throws -> JSONValue { + try await executeQuery("app.bsky.video.getJobStatus", parameters: ["jobId": jobID]) + } + + /// Fetches current video upload limits for the authenticated actor. + public func getVideoUploadLimits() async throws -> VideoUploadLimits { + try await executeQuery("app.bsky.video.getUploadLimits") + } + + /// Uploads a video blob for asynchronous processing. + public func uploadVideo(_ data: Data, mimeType: String = "video/mp4") async throws -> JSONValue { + try await executeDataProcedure( + "app.bsky.video.uploadVideo", + data: data, + contentType: mimeType + ) + } +} diff --git a/Sources/bskyKit/Configuration.swift b/Sources/bskyKit/Configuration.swift new file mode 100644 index 0000000..ae9acd2 --- /dev/null +++ b/Sources/bskyKit/Configuration.swift @@ -0,0 +1,26 @@ +import Foundation +import CoreATProtocol + +public enum BskyKitConfigurationError: Error, LocalizedError, Sendable { + case hostNotConfigured + case invalidHostURL(String) + + public var errorDescription: String? { + switch self { + case .hostNotConfigured: + return "AT Protocol host is not configured. Call setup(hostURL:accessJWT:refreshJWT:) first." + case .invalidHostURL(let value): + return "Configured AT Protocol host is not a valid URL: \(value)" + } + } +} + +@APActor +func ensureHostConfigured() throws { + guard let host = APEnvironment.current.host else { + throw BskyKitConfigurationError.hostNotConfigured + } + guard URL(string: host) != nil else { + throw BskyKitConfigurationError.invalidHostURL(host) + } +} diff --git a/Sources/bskyKit/Models/Feed.swift b/Sources/bskyKit/Models/Feed.swift index a54908a..b61b815 100644 --- a/Sources/bskyKit/Models/Feed.swift +++ b/Sources/bskyKit/Models/Feed.swift @@ -26,3 +26,16 @@ public struct Feed: Codable, Sendable, Identifiable { public struct Feeds: Codable, Sendable { public let feeds: [Feed] } + +/// Paged feed-generator response shape used by multiple endpoints. +public struct FeedPageResponse: Codable, Sendable { + public let feeds: [Feed] + public let cursor: String? +} + +/// Response from app.bsky.feed.getFeedGenerator +public struct FeedGeneratorResponse: Codable, Sendable { + public let view: Feed + public let isOnline: Bool? + public let isValid: Bool? +} diff --git a/Sources/bskyKit/Models/Graph.swift b/Sources/bskyKit/Models/Graph.swift index dd70e15..afd0f5d 100644 --- a/Sources/bskyKit/Models/Graph.swift +++ b/Sources/bskyKit/Models/Graph.swift @@ -46,3 +46,54 @@ public struct MutedProfile: Codable, Sendable, Identifiable { public var id: String { did } } + +/// Response from app.bsky.graph.getRelationships +public struct RelationshipsResponse: Codable, Sendable { + public let actor: String + public let relationships: [RelationshipResult] +} + +public enum RelationshipResult: Codable, Sendable { + case relationship(Relationship) + case notFound(NotFoundActor) + case unknown + + public init(from decoder: Decoder) throws { + if let relationship = try? Relationship(from: decoder) { + self = .relationship(relationship) + return + } + if let notFound = try? NotFoundActor(from: decoder) { + self = .notFound(notFound) + return + } + self = .unknown + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .relationship(let relationship): + try relationship.encode(to: encoder) + case .notFound(let notFound): + try notFound.encode(to: encoder) + case .unknown: + var container = encoder.singleValueContainer() + try container.encode([String: String]()) + } + } +} + +public struct Relationship: Codable, Sendable { + public let did: String + public let following: String? + public let followedBy: String? + public let blocking: String? + public let blockedBy: String? + public let blockingByList: String? + public let blockedByList: String? +} + +public struct NotFoundActor: Codable, Sendable { + public let actor: String + public let notFound: Bool +} diff --git a/Sources/bskyKit/Models/JSONValue.swift b/Sources/bskyKit/Models/JSONValue.swift new file mode 100644 index 0000000..09236be --- /dev/null +++ b/Sources/bskyKit/Models/JSONValue.swift @@ -0,0 +1,59 @@ +import Foundation + +public enum JSONValue: Codable, Sendable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case array([JSONValue]) + case object([String: JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + if let value = try? container.decode(Double.self) { + self = .number(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode([JSONValue].self) { + self = .array(value) + return + } + if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + return + } + + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} diff --git a/Sources/bskyKit/Models/PostThread.swift b/Sources/bskyKit/Models/PostThread.swift index 3116dae..01bd12b 100644 --- a/Sources/bskyKit/Models/PostThread.swift +++ b/Sources/bskyKit/Models/PostThread.swift @@ -1,80 +1,25 @@ -// -// PostThread.swift -// bskyKit -// -// Created by Thomas Rademaker on 01/02/2026. -// - import Foundation /// Response from app.bsky.feed.getPostThread public struct PostThreadResponse: Codable, Sendable { - public let thread: ThreadViewPost -} - -/// A post in a thread with parent/replies context -/// Uses class for recursive structure support -public final class ThreadViewPost: Codable, Sendable, Identifiable { - public let type: String? - public let post: Post - public let parent: ThreadParent? - public let replies: [ThreadReply]? - - public var id: String { post.uri ?? "" } + public let thread: ThreadNode - enum CodingKeys: String, CodingKey { - case type = "$type" - case post, parent, replies - } - - public init(type: String?, post: Post, parent: ThreadParent?, replies: [ThreadReply]?) { - self.type = type - self.post = post - self.parent = parent - self.replies = replies + public var threadPost: ThreadViewPost? { + if case .post(let post) = thread { + return post + } + return nil } } -/// Parent of a thread post (can be another post or blocked/not found) -public indirect enum ThreadParent: Codable, Sendable { +public enum ThreadNode: Codable, Sendable { case post(ThreadViewPost) case notFound(NotFoundPost) case blocked(BlockedPost) - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decodeIfPresent(String.self, forKey: .type) ?? "" - - if type.contains("notFoundPost") { - self = .notFound(try NotFoundPost(from: decoder)) - } else if type.contains("blockedPost") { - self = .blocked(try BlockedPost(from: decoder)) - } else { - self = .post(try ThreadViewPost(from: decoder)) - } - } - - public func encode(to encoder: Encoder) throws { - switch self { - case .post(let threadPost): - try threadPost.encode(to: encoder) - case .notFound(let notFound): - try notFound.encode(to: encoder) - case .blocked(let blocked): - try blocked.encode(to: encoder) - } - } - enum CodingKeys: String, CodingKey { case type = "$type" } -} - -/// Reply to a thread post (can be another post or blocked/not found) -public indirect enum ThreadReply: Codable, Sendable { - case post(ThreadViewPost) - case notFound(NotFoundPost) - case blocked(BlockedPost) public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -91,17 +36,35 @@ public indirect enum ThreadReply: Codable, Sendable { public func encode(to encoder: Encoder) throws { switch self { - case .post(let threadPost): - try threadPost.encode(to: encoder) - case .notFound(let notFound): - try notFound.encode(to: encoder) - case .blocked(let blocked): - try blocked.encode(to: encoder) + case .post(let post): + try post.encode(to: encoder) + case .notFound(let post): + try post.encode(to: encoder) + case .blocked(let post): + try post.encode(to: encoder) } } +} + +/// A post in a thread with parent/replies context. +public final class ThreadViewPost: Codable, Sendable, Identifiable { + public let type: String? + public let post: Post + public let parent: ThreadNode? + public let replies: [ThreadNode]? + + public var id: String { post.uri } enum CodingKeys: String, CodingKey { case type = "$type" + case post, parent, replies + } + + public init(type: String?, post: Post, parent: ThreadNode?, replies: [ThreadNode]?) { + self.type = type + self.post = post + self.parent = parent + self.replies = replies } } diff --git a/Sources/bskyKit/Models/Posts.swift b/Sources/bskyKit/Models/Posts.swift index 4631de6..a5ea39a 100644 --- a/Sources/bskyKit/Models/Posts.swift +++ b/Sources/bskyKit/Models/Posts.swift @@ -11,3 +11,18 @@ import Foundation public struct Posts: Codable, Sendable { public let posts: [Post] } + +/// Response from app.bsky.feed.searchPosts +public struct SearchPostsResponse: Codable, Sendable { + public let posts: [Post] + public let cursor: String? + public let hitsTotal: Int? +} + +/// Response from app.bsky.feed.getQuotes +public struct QuotesResponse: Codable, Sendable { + public let uri: String + public let cid: String? + public let posts: [Post] + public let cursor: String? +} diff --git a/Sources/bskyKit/Models/SearchActors.swift b/Sources/bskyKit/Models/SearchActors.swift index b4dbe6c..7d6fb79 100644 --- a/Sources/bskyKit/Models/SearchActors.swift +++ b/Sources/bskyKit/Models/SearchActors.swift @@ -13,6 +13,13 @@ public struct SearchActorsResult: Codable, Sendable { public let cursor: String? } +/// Response from app.bsky.actor.getSuggestions +public struct ActorSuggestionsResponse: Codable, Sendable { + public let actors: [ActorProfile] + public let cursor: String? + public let recId: Int? +} + /// Response from app.bsky.actor.searchActorsTypeahead public struct SearchActorsTypeaheadResult: Codable, Sendable { public let actors: [ActorProfile] diff --git a/Sources/bskyKit/Models/Timeline.swift b/Sources/bskyKit/Models/Timeline.swift index d1f9193..10be5b5 100644 --- a/Sources/bskyKit/Models/Timeline.swift +++ b/Sources/bskyKit/Models/Timeline.swift @@ -1,157 +1,118 @@ -// -// Timeline.swift -// bskyKit -// -// Created by Thomas Rademaker on 10/11/25. -// - import Foundation public struct Timeline: Codable, Sendable { public var feed: [TimelineItem] - public var cursor: String + public var cursor: String? } -public struct TimelineItem: Codable, Sendable { +public struct TimelineItem: Codable, Sendable, Identifiable, Equatable { public let post: Post public let reply: Reply? -} -/* -{ - "post": { - "uri": "at://did:plc:gkqxrdozmfap5ehgd5xlhem2/app.bsky.feed.post/3l7r3os55mj2r", - "cid": "bafyreign5fpvfsfj6xriqbdkp3pwf5lmcfzvkedxy6yi3csxmwfkf7cdiu", - "author": { - "did": "did:plc:gkqxrdozmfap5ehgd5xlhem2", - "handle": "atprotesting123.bsky.social", - "viewer": { - "muted": false, - "blockedBy": false, - "following": "at://did:plc:aq5iwu4gjdcg2hq53llism3x/app.bsky.graph.follow/3l7oxdij6km2a", - "followedBy": "at://did:plc:gkqxrdozmfap5ehgd5xlhem2/app.bsky.graph.follow/3kcyrue74z32v" - }, - "labels": [], - "createdAt": "2023-10-30T21:44:11.344Z" - }, - "record": { - "$type": "app.bsky.feed.post", - "createdAt": "2024-10-30T21:30:34.509Z", - "facets": [ - { - "features": [ - { - "$type": "app.bsky.richtext.facet#link", - "uri": "https://x.com" - } - ], - "index": { - "byteEnd": 5, - "byteStart": 0 - } - } - ], - "langs": [ - "en" - ], - "text": "x.com" - }, - "replyCount": 0, - "repostCount": 0, - "likeCount": 0, - "quoteCount": 0, - "indexedAt": "2024-10-30T21:30:34.509Z", - "viewer": { - "threadMuted": false, - "embeddingDisabled": false - }, - "labels": [] - } - },*/ - -extension TimelineItem: Equatable { - public static func == (lhs: TimelineItem, rhs: TimelineItem) -> Bool { - lhs.post.uri == rhs.post.uri && lhs.post.cid == rhs.post.cid - } -} + public let reason: TimelineReason? + public let feedContext: String? + public let reqId: String? -extension TimelineItem: Identifiable { - /// Stable identifier based on post URI and CID public var id: String { - "\(post.uri ?? "")-\(post.cid ?? "")" + "\(post.uri)-\(post.cid)" } -} - -public struct Post: Codable, Sendable { - public let uri: String? - public let cid: String? - public let author: Author - public let record: Record - public let facets: PostFacet? - public let replyCount: Int - public let repostCount: Int - public let likeCount: Int - public let indexedAt: String - public let viewer: Viewer - public let labels: [String] - public let embed: Embed? -} -public struct PostFacet: Codable, Sendable { - public let facets: [Facet] - public let createdAt: Date + public static func == (lhs: TimelineItem, rhs: TimelineItem) -> Bool { + lhs.post.uri == rhs.post.uri && lhs.post.cid == rhs.post.cid + } } -public struct Facet: Codable, Sendable { - public let index: FacetIndex - public let features: [FacetFeature] - -} +public enum TimelineReason: Codable, Sendable, Equatable { + case repost(ReasonRepost) + case pin(ReasonPin) + case unknown(String) -public struct FacetFeature: Codable, Sendable { - public let uri: String? - public let type: FacetType - enum CodingKeys: String, CodingKey { - case uri case type = "$type" } -} -public enum FacetType: Codable, Sendable { - case link(String) - case unknown(String) - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let value = try container.decode(String.self) - - switch value { - case "app.bsky.richtext.facet#link": self = .link(value) - default: self = .unknown(value) + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "app.bsky.feed.defs#reasonRepost": + self = .repost(try ReasonRepost(from: decoder)) + case "app.bsky.feed.defs#reasonPin": + self = .pin(try ReasonPin(from: decoder)) + default: + self = .unknown(type) } } - + public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() switch self { - case .link(let value), .unknown(let value): - try container.encode(value) + case .repost(let reason): + try reason.encode(to: encoder) + case .pin(let reason): + try reason.encode(to: encoder) + case .unknown(let type): + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) } } } -public struct FacetIndex: Codable, Sendable { - public let byteEnd: Int - public let byteStart: Int +public struct ReasonRepost: Codable, Sendable, Equatable { + public let by: Author + public let indexedAt: Date? + + public static func == (lhs: ReasonRepost, rhs: ReasonRepost) -> Bool { + lhs.by.did == rhs.by.did && + lhs.by.handle == rhs.by.handle && + lhs.indexedAt == rhs.indexedAt + } +} + +public struct ReasonPin: Codable, Sendable, Equatable { + public let by: Author + public let indexedAt: Date? + + public static func == (lhs: ReasonPin, rhs: ReasonPin) -> Bool { + lhs.by.did == rhs.by.did && + lhs.by.handle == rhs.by.handle && + lhs.indexedAt == rhs.indexedAt + } +} + +public struct Post: Codable, Sendable { + public let uri: String + public let cid: String + public let author: Author + public let record: Record + public let embed: Embed? + public let bookmarkCount: Int? + public let replyCount: Int? + public let repostCount: Int? + public let likeCount: Int? + public let quoteCount: Int? + public let indexedAt: Date + public let viewer: FeedViewer? + public let labels: [AuthorLabels]? + public let threadgate: ThreadgateView? +} + +public struct FeedViewer: Codable, Sendable { + public let repost: String? + public let like: String? + public let bookmarked: Bool? + public let threadMuted: Bool? + public let replyDisabled: Bool? + public let embeddingDisabled: Bool? + public let pinned: Bool? } public struct Embed: Codable, Sendable { - public let type: String + public let type: String? public let images: [EmbeddedMedia]? public let media: Media? public let record: EmbedRecord? public let external: EmbedExternal? - + enum CodingKeys: String, CodingKey { case images, media, record, external case type = "$type" @@ -161,9 +122,9 @@ public struct Embed: Codable, Sendable { public struct EmbedExternal: Codable, Sendable { public let uri: String? public let thumb: TimelineImage? - public let title: String - public let externalDescription: String - + public let title: String? + public let externalDescription: String? + enum CodingKeys: String, CodingKey { case uri, thumb, title case externalDescription = "description" @@ -177,67 +138,56 @@ public struct EmbedRecord: Codable, Sendable { public let cid: String? public let author: Author? public let value: EmbedRecordValue? -// public let labels: [String] -// public let indexedAt: Date -// public let embeds: [String] // TODO: This isn't correct - - + enum CodingKeys: String, CodingKey { case type = "$type" - case record, uri, cid, author, value/*, labels, indexedAt, embeds*/ + case record, uri, cid, author, value } } public struct EmbedRecordValue: Codable, Sendable { - public let text: String - public let type: String + public let type: String? + public let text: String? public let langs: [String]? public let reply: ReplyDetail? - public let createdAt: String - + public let createdAt: Date? + enum CodingKeys: String, CodingKey { case type = "$type" - case langs, reply, createdAt, text + case text, langs, reply, createdAt } } public struct Media: Codable, Sendable { - public let type: String + public let type: String? public let images: [EmbeddedMedia]? - + enum CodingKeys: String, CodingKey { case type = "$type" case images } } -public enum EmbedType: String, Codable, Sendable { - case image = "app.bsky.embed.images" - case recordWithMedia = "app.bsky.embed.recordWithMedia" - case external = "app.bsky.embed.external" - case record = "app.bsky.embed.record" -} - public enum TimelineImage: Codable, Sendable, Identifiable { case string(String) case image(EmbeddedImage) - + public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - if let string = try? container.decode(String.self) { self = .string(string) return } - if let image = try? container.decode(EmbeddedImage.self) { self = .image(image) return } - - throw DecodingError.typeMismatch(TimelineImage.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for MyProperty")) + throw DecodingError.typeMismatch( + TimelineImage.self, + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Expected string or embedded image") + ) } - + public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { @@ -247,14 +197,13 @@ public enum TimelineImage: Codable, Sendable, Identifiable { try container.encode(image) } } - - /// Stable identifier based on content + public var id: String { switch self { case .string(let value): return value - case .image(let img): - return "\(img.type)-\(img.size)" + case .image(let image): + return "\(image.type ?? "image")-\(image.size ?? -1)" } } } @@ -262,17 +211,17 @@ public enum TimelineImage: Codable, Sendable, Identifiable { public struct EmbeddedMedia: Codable, Sendable { public let thumb: TimelineImage? public let fullsize: String? - public let alt: String + public let alt: String? public let aspectRatio: EmbedImageAspectRatio? public let image: TimelineImage? } public struct EmbeddedImage: Codable, Sendable { - public let type: String - public let ref: [String : String] - public let mimeType: String - public let size: Int - + public let type: String? + public let ref: [String: String]? + public let mimeType: String? + public let size: Int? + enum CodingKeys: String, CodingKey { case type = "$type" case ref, mimeType, size @@ -294,8 +243,9 @@ public struct Author: Codable, Sendable { public let handle: String public let displayName: String? public let avatar: String? - public let viewer: Viewer - public let labels: [AuthorLabels] + public let viewer: Viewer? + public let labels: [AuthorLabels]? + public let createdAt: Date? } public struct AuthorLabels: Codable, Sendable { @@ -307,20 +257,80 @@ public struct AuthorLabels: Codable, Sendable { } public struct Record: Codable, Sendable { - public let text: String - public let type: String + public let type: String? + public let text: String? public let langs: [String]? public let reply: ReplyDetail? - public let createdAt: String + public let createdAt: Date? public let embed: Embed? public let facets: [Facet]? - + + enum CodingKeys: String, CodingKey { + case type = "$type" + case text, langs, reply, createdAt, embed, facets + } +} + +public struct Facet: Codable, Sendable { + public let index: FacetIndex + public let features: [FacetFeature] +} + +public struct FacetFeature: Codable, Sendable { + public let uri: String? + public let did: String? + public let tag: String? + public let type: FacetType + enum CodingKeys: String, CodingKey { + case uri + case did + case tag case type = "$type" - case langs, reply, createdAt, embed, text, facets } } +public enum FacetType: Codable, Sendable { + case link + case mention + case tag + case unknown(String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + switch value { + case "app.bsky.richtext.facet#link": + self = .link + case "app.bsky.richtext.facet#mention": + self = .mention + case "app.bsky.richtext.facet#tag": + self = .tag + default: + self = .unknown(value) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .link: + try container.encode("app.bsky.richtext.facet#link") + case .mention: + try container.encode("app.bsky.richtext.facet#mention") + case .tag: + try container.encode("app.bsky.richtext.facet#tag") + case .unknown(let value): + try container.encode(value) + } + } +} + +public struct FacetIndex: Codable, Sendable { + public let byteEnd: Int + public let byteStart: Int +} + public struct ReplyDetail: Codable, Sendable { public let root: UnpopulatedPost public let parent: UnpopulatedPost @@ -332,39 +342,46 @@ public struct UnpopulatedPost: Codable, Sendable { } public struct Root: Codable, Sendable { - public let type: String + public let type: String? public let uri: String? public let cid: String? public let author: Author public let record: Record - public let replyCount: Int - public let repostCount: Int - public let likeCount: Int - public let indexedAt: String - public let viewer: Viewer - public let labels: [String] - + public let replyCount: Int? + public let repostCount: Int? + public let likeCount: Int? + public let quoteCount: Int? + public let indexedAt: Date? + public let viewer: FeedViewer? + public let labels: [AuthorLabels]? + enum CodingKeys: String, CodingKey { case type = "$type" - case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels + case uri, cid, author, record, replyCount, repostCount, likeCount, quoteCount, indexedAt, viewer, labels } } public struct Parent: Codable, Sendable { - public let type: String + public let type: String? public let uri: String? public let cid: String? public let author: Author public let record: Record - public let replyCount: Int - public let repostCount: Int - public let likeCount: Int - public let indexedAt: String - public let viewer: Viewer - public let labels: [String] - + public let replyCount: Int? + public let repostCount: Int? + public let likeCount: Int? + public let quoteCount: Int? + public let indexedAt: Date? + public let viewer: FeedViewer? + public let labels: [AuthorLabels]? + enum CodingKeys: String, CodingKey { case type = "$type" - case uri, cid, author, record, replyCount, repostCount, likeCount, indexedAt, viewer, labels + case uri, cid, author, record, replyCount, repostCount, likeCount, quoteCount, indexedAt, viewer, labels } } + +public struct ThreadgateView: Codable, Sendable { + public let uri: String? + public let cid: String? +} diff --git a/Sources/bskyKit/Models/Video.swift b/Sources/bskyKit/Models/Video.swift new file mode 100644 index 0000000..cfc09d8 --- /dev/null +++ b/Sources/bskyKit/Models/Video.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Response from app.bsky.video.getUploadLimits +public struct VideoUploadLimits: Codable, Sendable { + public let canUpload: Bool + public let remainingDailyVideos: Int? + public let remainingDailyBytes: Int? + public let message: String? + public let error: String? +} diff --git a/Sources/bskyKit/Models/Viewer.swift b/Sources/bskyKit/Models/Viewer.swift index f7fa699..3fa4076 100644 --- a/Sources/bskyKit/Models/Viewer.swift +++ b/Sources/bskyKit/Models/Viewer.swift @@ -31,4 +31,52 @@ public struct Viewer: Codable, Sendable { self.mutedByList = mutedByList self.blockingByList = blockingByList } + + enum CodingKeys: String, CodingKey { + case muted + case blockedBy + case following + case followedBy + case blocking + case mutedByList + case blockingByList + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + muted = try container.decodeIfPresent(Bool.self, forKey: .muted) + blockedBy = try container.decodeIfPresent(Bool.self, forKey: .blockedBy) + following = try container.decodeIfPresent(String.self, forKey: .following) + followedBy = try container.decodeIfPresent(String.self, forKey: .followedBy) + blocking = try container.decodeIfPresent(String.self, forKey: .blocking) + mutedByList = try container.decodeListURIIfPresent(forKey: .mutedByList) + blockingByList = try container.decodeListURIIfPresent(forKey: .blockingByList) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(muted, forKey: .muted) + try container.encodeIfPresent(blockedBy, forKey: .blockedBy) + try container.encodeIfPresent(following, forKey: .following) + try container.encodeIfPresent(followedBy, forKey: .followedBy) + try container.encodeIfPresent(blocking, forKey: .blocking) + try container.encodeIfPresent(mutedByList, forKey: .mutedByList) + try container.encodeIfPresent(blockingByList, forKey: .blockingByList) + } +} + +private struct ViewerListReference: Decodable { + let uri: String? +} + +private extension KeyedDecodingContainer where Key: CodingKey { + func decodeListURIIfPresent(forKey key: Key) throws -> String? { + if let uri = try decodeIfPresent(String.self, forKey: key) { + return uri + } + if let list = try decodeIfPresent(ViewerListReference.self, forKey: key) { + return list.uri + } + return nil + } } diff --git a/Sources/bskyKit/RepoAPI.swift b/Sources/bskyKit/RepoAPI.swift index 976c976..74ef2d8 100644 --- a/Sources/bskyKit/RepoAPI.swift +++ b/Sources/bskyKit/RepoAPI.swift @@ -11,17 +11,24 @@ import CoreATProtocol /// API endpoints for com.atproto.repo.* lexicons enum RepoAPI: Sendable { case createRecord(body: Data) + case putRecord(body: Data) + case applyWrites(body: Data) case deleteRecord(body: Data) + case describeRepo(repo: String) case getRecord(repo: String, collection: String, rkey: String) case listRecords(repo: String, collection: String, limit: Int, cursor: String?) - // Note: uploadBlob requires CoreATProtocol updates - deferred + case listMissingBlobs(limit: Int, cursor: String?) + case importRepo(data: Data) + case uploadBlob(data: Data, mimeType: String) } extension RepoAPI: EndpointType { public var baseURL: URL { get async { - guard let host = await APEnvironment.current.host else { fatalError("Host not set.") } - guard let url = URL(string: host) else { fatalError("RepoAPI baseURL not configured.") } + guard let host = await APEnvironment.current.host, + let url = URL(string: host) else { + return URL(string: "https://invalid.invalid")! + } return url } } @@ -29,26 +36,40 @@ extension RepoAPI: EndpointType { var path: String { switch self { case .createRecord: "/xrpc/com.atproto.repo.createRecord" + case .putRecord: "/xrpc/com.atproto.repo.putRecord" + case .applyWrites: "/xrpc/com.atproto.repo.applyWrites" case .deleteRecord: "/xrpc/com.atproto.repo.deleteRecord" + case .describeRepo: "/xrpc/com.atproto.repo.describeRepo" case .getRecord: "/xrpc/com.atproto.repo.getRecord" case .listRecords: "/xrpc/com.atproto.repo.listRecords" + case .listMissingBlobs: "/xrpc/com.atproto.repo.listMissingBlobs" + case .importRepo: "/xrpc/com.atproto.repo.importRepo" + case .uploadBlob: "/xrpc/com.atproto.repo.uploadBlob" } } var httpMethod: HTTPMethod { switch self { - case .createRecord, .deleteRecord: + case .createRecord, .putRecord, .applyWrites, .deleteRecord, .importRepo, .uploadBlob: return .post - case .getRecord, .listRecords: + case .describeRepo, .getRecord, .listRecords, .listMissingBlobs: return .get } } var task: HTTPTask { switch self { - case .createRecord(let body), .deleteRecord(let body): + case .createRecord(let body), .putRecord(let body), .applyWrites(let body), .deleteRecord(let body): return .requestParameters(encoding: .jsonDataEncoding(data: body)) + case .importRepo(let data), .uploadBlob(let data, _): + return .requestParameters(encoding: .jsonDataEncoding(data: data)) + + case .describeRepo(let repo): + return .requestParameters(encoding: .urlEncoding(parameters: [ + "repo": repo + ])) + case .getRecord(let repo, let collection, let rkey): return .requestParameters(encoding: .urlEncoding(parameters: [ "repo": repo, @@ -60,10 +81,28 @@ extension RepoAPI: EndpointType { var params: Parameters = ["repo": repo, "collection": collection, "limit": limit] if let cursor { params["cursor"] = cursor } return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .listMissingBlobs(let limit, let cursor): + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) } } var headers: HTTPHeaders? { - nil + switch self { + case .importRepo: + return [ + "Content-Type": "application/vnd.ipld.car", + "Accept": "application/json" + ] + case .uploadBlob(_, let mimeType): + return [ + "Content-Type": mimeType, + "Accept": "application/json" + ] + default: + return nil + } } } diff --git a/Sources/bskyKit/RepoService.swift b/Sources/bskyKit/RepoService.swift index 2f6fe68..8337d74 100644 --- a/Sources/bskyKit/RepoService.swift +++ b/Sources/bskyKit/RepoService.swift @@ -19,6 +19,11 @@ public struct RepoService: Sendable { public init() {} + private func execute(_ endpoint: RepoAPI) async throws -> T { + try ensureHostConfigured() + return try await router.execute(endpoint) + } + // MARK: - Record Operations /// Creates a new record in the repository @@ -36,7 +41,49 @@ public struct RepoService: Sendable { if let rkey { body["rkey"] = rkey } let data = try JSONSerialization.data(withJSONObject: body) - return try await router.execute(.createRecord(body: data)) + return try await execute(.createRecord(body: data)) + } + + /// Writes a record, creating or updating it for the given record key. + public func putRecord( + repo: String, + collection: String, + rkey: String, + record: [String: Any], + validate: Bool? = nil, + swapRecord: String? = nil, + swapCommit: String? = nil + ) async throws -> PutRecordResponse { + var body: [String: Any] = [ + "repo": repo, + "collection": collection, + "rkey": rkey, + "record": record + ] + if let validate { body["validate"] = validate } + if let swapRecord { body["swapRecord"] = swapRecord } + if let swapCommit { body["swapCommit"] = swapCommit } + + let data = try JSONSerialization.data(withJSONObject: body) + return try await execute(.putRecord(body: data)) + } + + /// Applies a batch of create/update/delete writes in a single transaction. + public func applyWrites( + repo: String, + writes: [WriteOperation], + validate: Bool? = nil, + swapCommit: String? = nil + ) async throws -> ApplyWritesResponse { + var body: [String: Any] = [ + "repo": repo, + "writes": writes.map { $0.toRecord() } + ] + if let validate { body["validate"] = validate } + if let swapCommit { body["swapCommit"] = swapCommit } + + let data = try JSONSerialization.data(withJSONObject: body) + return try await execute(.applyWrites(body: data)) } /// Deletes a record from the repository @@ -51,7 +98,12 @@ public struct RepoService: Sendable { "rkey": rkey ] let data = try JSONSerialization.data(withJSONObject: body) - let _: EmptyResponse = try await router.execute(.deleteRecord(body: data)) + let _: EmptyResponse = try await execute(.deleteRecord(body: data)) + } + + /// Describes repository metadata, collection list, and DID document. + public func describeRepo(repo: String) async throws -> DescribeRepoResponse { + try await execute(.describeRepo(repo: repo)) } /// Gets a single record @@ -60,7 +112,7 @@ public struct RepoService: Sendable { collection: String, rkey: String ) async throws -> GetRecordResponse { - try await router.execute(.getRecord(repo: repo, collection: collection, rkey: rkey)) + try await execute(.getRecord(repo: repo, collection: collection, rkey: rkey)) } /// Lists records in a collection @@ -70,10 +122,23 @@ public struct RepoService: Sendable { limit: Int = 50, cursor: String? = nil ) async throws -> ListRecordsResponse { - try await router.execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor)) + try await execute(.listRecords(repo: repo, collection: collection, limit: limit, cursor: cursor)) + } + + /// Lists missing blobs for import/migration workflows. + public func listMissingBlobs(limit: Int = 500, cursor: String? = nil) async throws -> ListMissingBlobsResponse { + try await execute(.listMissingBlobs(limit: limit, cursor: cursor)) } - // Note: uploadBlob deferred until CoreATProtocol is updated + /// Imports a repository archive (CAR format). + public func importRepo(car: Data) async throws { + let _: EmptyResponse = try await execute(.importRepo(data: car)) + } + + /// Uploads a blob to be referenced by later record writes. + public func uploadBlob(data: Data, mimeType: String) async throws -> BlobResponse { + try await execute(.uploadBlob(data: data, mimeType: mimeType)) + } // MARK: - High-Level Operations @@ -173,6 +238,58 @@ public struct CreateRecordResponse: Codable, Sendable { public let cid: String } +public struct CommitMeta: Codable, Sendable { + public let cid: String + public let rev: String +} + +public struct PutRecordResponse: Codable, Sendable { + public let uri: String + public let cid: String + public let commit: CommitMeta? + public let validationStatus: String? +} + +public struct ApplyWritesResponse: Codable, Sendable { + public let commit: CommitMeta? + public let results: [ApplyWritesResult]? +} + +public enum ApplyWritesResult: Codable, Sendable { + case create(CreateRecordResponse) + case update(CreateRecordResponse) + case delete + case unknown + + public init(from decoder: Decoder) throws { + if let result = try? CreateRecordResponse(from: decoder) { + self = .create(result) + return + } + + let container = try decoder.singleValueContainer() + if (try? container.decode([String: String].self))?.isEmpty == true { + self = .delete + return + } + + self = .unknown + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .create(let response), .update(let response): + try response.encode(to: encoder) + case .delete: + var container = encoder.singleValueContainer() + try container.encode([String: String]()) + case .unknown: + var container = encoder.singleValueContainer() + try container.encode([String: String]()) + } + } +} + public struct GetRecordResponse: Codable, Sendable { public let uri: String public let cid: String? @@ -195,6 +312,24 @@ public struct ListRecordsResponse: Codable, Sendable { public let cursor: String? } +public struct DescribeRepoResponse: Codable, Sendable { + public let handle: String + public let did: String + public let didDoc: JSONValue? + public let collections: [String] + public let handleIsCorrect: Bool +} + +public struct ListMissingBlobsResponse: Codable, Sendable { + public let cursor: String? + public let blobs: [MissingBlob] +} + +public struct MissingBlob: Codable, Sendable { + public let cid: String + public let recordUri: String +} + public struct RecordItem: Codable, Sendable { public let uri: String public let cid: String @@ -225,6 +360,38 @@ public struct BlobLink: Codable, Sendable { } } +public enum WriteOperation { + case create(collection: String, rkey: String? = nil, value: [String: Any]) + case update(collection: String, rkey: String, value: [String: Any]) + case delete(collection: String, rkey: String) + + fileprivate func toRecord() -> [String: Any] { + switch self { + case .create(let collection, let rkey, let value): + var record: [String: Any] = [ + "$type": "com.atproto.repo.applyWrites#create", + "collection": collection, + "value": value + ] + if let rkey { record["rkey"] = rkey } + return record + case .update(let collection, let rkey, let value): + return [ + "$type": "com.atproto.repo.applyWrites#update", + "collection": collection, + "rkey": rkey, + "value": value + ] + case .delete(let collection, let rkey): + return [ + "$type": "com.atproto.repo.applyWrites#delete", + "collection": collection, + "rkey": rkey + ] + } + } +} + // MARK: - Post Record /// A record for creating a post @@ -275,25 +442,33 @@ public struct PostRecord: Sendable { ] if let facets, !facets.isEmpty { - record["facets"] = facets.map { facet in + let encodedFacets: [[String: Any]] = facets.compactMap { facet in var dict: [String: Any] = [ "index": [ "byteStart": facet.index.byteStart, "byteEnd": facet.index.byteEnd ] ] - dict["features"] = facet.features.map { feature -> [String: Any] in + let features: [[String: Any]] = facet.features.compactMap { feature in switch feature { case .link(let link): return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri] case .mention(let mention): - return ["$type": "app.bsky.richtext.facet#mention", "did": mention.did ?? ""] + guard let did = mention.did else { return nil } + return ["$type": "app.bsky.richtext.facet#mention", "did": did] case .tag(let tag): return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag] + case .unknown: + return nil } } + guard !features.isEmpty else { return nil } + dict["features"] = features return dict } + if !encodedFacets.isEmpty { + record["facets"] = encodedFacets + } } if let reply { diff --git a/Sources/bskyKit/RichText/RichText.swift b/Sources/bskyKit/RichText/RichText.swift index 1c18d04..bcfab46 100644 --- a/Sources/bskyKit/RichText/RichText.swift +++ b/Sources/bskyKit/RichText/RichText.swift @@ -275,6 +275,9 @@ public enum RichTextFeature: Codable, Sendable { /// A hashtag for discovery. case tag(RichTextTag) + /// Unknown facet feature type for forward compatibility. + case unknown(String) + enum CodingKeys: String, CodingKey { case type = "$type" case uri @@ -297,12 +300,7 @@ public enum RichTextFeature: Codable, Sendable { let tag = try container.decode(String.self, forKey: .tag) self = .tag(RichTextTag(tag: tag)) default: - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Unknown facet type: \(type)" - ) - ) + self = .unknown(type) } } @@ -313,11 +311,22 @@ public enum RichTextFeature: Codable, Sendable { try container.encode("app.bsky.richtext.facet#link", forKey: .type) try container.encode(link.uri, forKey: .uri) case .mention(let mention): + guard let did = mention.did else { + throw EncodingError.invalidValue( + mention, + EncodingError.Context( + codingPath: encoder.codingPath, + debugDescription: "Mention facet requires a resolved DID." + ) + ) + } try container.encode("app.bsky.richtext.facet#mention", forKey: .type) - try container.encode(mention.did ?? mention.handle, forKey: .did) + try container.encode(did, forKey: .did) case .tag(let tag): try container.encode("app.bsky.richtext.facet#tag", forKey: .type) try container.encode(tag.tag, forKey: .tag) + case .unknown(let type): + try container.encode(type, forKey: .type) } } } @@ -386,7 +395,7 @@ extension RichText { ] ] - let features: [[String: Any]] = facet.features.map { feature in + let features: [[String: Any]] = facet.features.compactMap { feature in switch feature { case .link(let link): return ["$type": "app.bsky.richtext.facet#link", "uri": link.uri] @@ -394,14 +403,18 @@ extension RichText { if let did = mention.did { return ["$type": "app.bsky.richtext.facet#mention", "did": did] } - return [:] + return nil case .tag(let tag): return ["$type": "app.bsky.richtext.facet#tag", "tag": tag.tag] + case .unknown: + return nil } } + guard !features.isEmpty else { return [:] } + dict["features"] = features return dict - } + }.filter { !$0.isEmpty } } } diff --git a/Tests/bskyKitTests/ModelDecodingTests.swift b/Tests/bskyKitTests/ModelDecodingTests.swift index e5c4780..730bdfb 100644 --- a/Tests/bskyKitTests/ModelDecodingTests.swift +++ b/Tests/bskyKitTests/ModelDecodingTests.swift @@ -177,7 +177,7 @@ struct ModelDecodingTests { """ let notification = try decode(Notification.self, from: json) - #expect(notification.reason != nil) + #expect(notification.reason != .unknown(reason)) } } @@ -218,6 +218,164 @@ struct ModelDecodingTests { #expect(result.cursor == "next") } + @Test("Decodes actor suggestions response") + func decodesActorSuggestionsResponse() throws { + let json = """ + { + "actors": [ + { + "did": "did:plc:actor1", + "handle": "actor1.bsky.social" + } + ], + "cursor": "next", + "recId": 42 + } + """ + + let result = try decode(ActorSuggestionsResponse.self, from: json) + #expect(result.actors.count == 1) + #expect(result.actors[0].did == "did:plc:actor1") + #expect(result.cursor == "next") + #expect(result.recId == 42) + } + + // MARK: - Feed and Search + + @Test("Decodes search posts response") + func decodesSearchPostsResponse() throws { + let json = """ + { + "cursor": "next", + "hitsTotal": 1, + "posts": [ + { + "uri": "at://did:plc:author/app.bsky.feed.post/1", + "cid": "bafy-post", + "author": { + "did": "did:plc:author", + "handle": "author.bsky.social" + }, + "record": { + "$type": "app.bsky.feed.post", + "text": "Hello world", + "createdAt": "2024-01-15T10:30:00.000Z" + }, + "indexedAt": "2024-01-15T10:30:00.000Z" + } + ] + } + """ + + let result = try decode(SearchPostsResponse.self, from: json) + #expect(result.posts.count == 1) + #expect(result.hitsTotal == 1) + #expect(result.cursor == "next") + } + + @Test("Decodes relationships response with union members") + func decodesRelationshipsResponse() throws { + let json = """ + { + "actor": "did:plc:me", + "relationships": [ + { + "did": "did:plc:alice", + "following": "at://did:plc:me/app.bsky.graph.follow/abc" + }, + { + "actor": "did:plc:missing", + "notFound": true + } + ] + } + """ + + let relationships = try decode(RelationshipsResponse.self, from: json) + #expect(relationships.actor == "did:plc:me") + #expect(relationships.relationships.count == 2) + + if case .relationship(let relationship) = relationships.relationships[0] { + #expect(relationship.did == "did:plc:alice") + } else { + Issue.record("Expected first relationship entry to decode as relationship") + } + + if case .notFound(let missing) = relationships.relationships[1] { + #expect(missing.actor == "did:plc:missing") + #expect(missing.notFound == true) + } else { + Issue.record("Expected second relationship entry to decode as notFound") + } + } + + // MARK: - Repo + + @Test("Decodes describe repo response with didDoc") + func decodesDescribeRepoResponse() throws { + let json = """ + { + "handle": "alice.bsky.social", + "did": "did:plc:alice", + "didDoc": { + "id": "did:plc:alice", + "service": [ + { + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer" + } + ] + }, + "collections": ["app.bsky.feed.post"], + "handleIsCorrect": true + } + """ + + let response = try decode(DescribeRepoResponse.self, from: json) + #expect(response.handle == "alice.bsky.social") + #expect(response.did == "did:plc:alice") + #expect(response.collections == ["app.bsky.feed.post"]) + #expect(response.handleIsCorrect == true) + #expect(response.didDoc != nil) + } + + @Test("Decodes list missing blobs response") + func decodesListMissingBlobsResponse() throws { + let json = """ + { + "cursor": "next", + "blobs": [ + { + "cid": "bafkreiabc", + "recordUri": "at://did:plc:alice/app.bsky.feed.post/1" + } + ] + } + """ + + let response = try decode(ListMissingBlobsResponse.self, from: json) + #expect(response.cursor == "next") + #expect(response.blobs.count == 1) + #expect(response.blobs[0].cid == "bafkreiabc") + } + + @Test("Decodes video upload limits") + func decodesVideoUploadLimits() throws { + let json = """ + { + "canUpload": true, + "remainingDailyVideos": 3, + "remainingDailyBytes": 10485760 + } + """ + + let limits = try decode(VideoUploadLimits.self, from: json) + #expect(limits.canUpload == true) + #expect(limits.remainingDailyVideos == 3) + #expect(limits.remainingDailyBytes == 10485760) + #expect(limits.error == nil) + } + // MARK: - Likes @Test("Decodes likes response") diff --git a/Tests/bskyKitTests/RichTextTests.swift b/Tests/bskyKitTests/RichTextTests.swift index b444779..05f9de1 100644 --- a/Tests/bskyKitTests/RichTextTests.swift +++ b/Tests/bskyKitTests/RichTextTests.swift @@ -120,6 +120,7 @@ struct RichTextTests { case .mention: hasMention = true case .link: hasLink = true case .tag: hasTag = true + case .unknown: break } }