diff --git a/Package.resolved b/Package.resolved index 5bdd67f..37fbd43 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,22 +1,13 @@ { - "originHash" : "1cc87e98c4114f0c0c0a6fea792a84d353c9e8d6b11e58b00673b46e6086d02d", + "originHash" : "cbf9e754c518e5eb56690c6dd9d096349450adf8254fc91d657d65f243cfb57f", "pins" : [ - { - "identity" : "coreatprotocol", - "kind" : "remoteSourceControl", - "location" : "https://tangled.org/@sparrowtek.com/CoreATProtocol", - "state" : { - "branch" : "main", - "revision" : "dd141af5a2066e9081cb09b67d4a9da7a73324dd" - } - }, { "identity" : "jwt-kit", "kind" : "remoteSourceControl", "location" : "https://github.com/vapor/jwt-kit.git", "state" : { - "revision" : "b5f82fb9dc238f2fcac53d721a222513a152613c", - "version" : "5.3.0" + "revision" : "aa60a211797306bfb05b752ad7b4bf9f0b50d898", + "version" : "5.4.0" } }, { @@ -42,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", - "version" : "1.5.1" + "revision" : "9f542610331815e29cc3821d3b6f488db8715517", + "version" : "1.6.0" } }, { @@ -60,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", - "version" : "4.2.0" + "revision" : "fa308c07a6fa04a727212d793e761460e41049c3", + "version" : "4.3.0" } }, { @@ -69,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "2778fd4e5a12a8aaa30a3ee8285f4ce54c5f3181", - "version" : "1.9.1" + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" } } ], diff --git a/Package.swift b/Package.swift index a91b37c..bebb3e7 100644 --- a/Package.swift +++ b/Package.swift @@ -18,8 +18,8 @@ let package = Package( ), ], dependencies: [ -// .package(path: "../CoreATProtocol"), - .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 b24cbad..7f62bd4 100644 --- a/Sources/bskyKit/BskyAPI.swift +++ b/Sources/bskyKit/BskyAPI.swift @@ -15,7 +15,7 @@ enum BskyAPI { case getProfile(did: String) case getProfiles(dids: [String]) case getSuggestions(limit: Int, cursor: String?) - case searchActors(query: String, limit: Int) + case searchActors(query: String, limit: Int, cursor: String?) case searchActorsTypeahead(query: String, limit: Int) // Feed endpoints @@ -66,6 +66,20 @@ enum BskyAPI { case getUnreadCount case updateSeen(seenAt: Date) + // Chat endpoints (proxied through PDS) + case chatListConvos(limit: Int, cursor: String?) + case chatGetConvo(convoId: String) + case chatGetMessages(convoId: String, limit: Int, cursor: String?) + case chatSendMessage(convoId: String, message: Data) + case chatUpdateRead(convoId: String, messageId: String?) + case chatLeaveConvo(convoId: String) + case chatMuteConvo(convoId: String) + case chatUnmuteConvo(convoId: String) + case chatGetConvoForMembers(members: [String]) + + // Moderation + case createReport(body: Data) + // Generic endpoints for newer/less-common lexicons. case xrpcQuery(id: String, parameters: Parameters) case xrpcProcedure(id: String, body: Parameters?) @@ -124,6 +138,18 @@ extension BskyAPI: EndpointType { case .listNotifications: "/xrpc/app.bsky.notification.listNotifications" case .getUnreadCount: "/xrpc/app.bsky.notification.getUnreadCount" case .updateSeen: "/xrpc/app.bsky.notification.updateSeen" + // Chat + case .chatListConvos: "/xrpc/chat.bsky.convo.listConvos" + case .chatGetConvo: "/xrpc/chat.bsky.convo.getConvo" + case .chatGetMessages: "/xrpc/chat.bsky.convo.getMessages" + case .chatSendMessage: "/xrpc/chat.bsky.convo.sendMessage" + case .chatUpdateRead: "/xrpc/chat.bsky.convo.updateRead" + case .chatLeaveConvo: "/xrpc/chat.bsky.convo.leaveConvo" + case .chatMuteConvo: "/xrpc/chat.bsky.convo.muteConvo" + case .chatUnmuteConvo: "/xrpc/chat.bsky.convo.unmuteConvo" + case .chatGetConvoForMembers: "/xrpc/chat.bsky.convo.getConvoForMembers" + // Moderation + case .createReport: "/xrpc/com.atproto.moderation.createReport" case .xrpcQuery(let id, _), .xrpcProcedure(let id, _), .xrpcDataProcedure(let id, _, _, _): "/xrpc/\(id)" } @@ -135,9 +161,12 @@ extension BskyAPI: EndpointType { .getFeed, .getActorFeeds, .getFeedGenerator, .getFeedGenerators, .getListFeed, .getQuotes, .getSuggestedFeeds, .getTimeline, .getAuthorFeed, .getPostThread, .getPosts, .searchPosts, .getActorLikes, .getLikes, .getRepostedBy, .getFollows, .getFollowers, .getBlocks, .getMutes, .getRelationships, - .listNotifications, .getUnreadCount: + .listNotifications, .getUnreadCount, + .chatListConvos, .chatGetConvo, .chatGetMessages, .chatGetConvoForMembers: return .get case .updateSeen, .muteActor, .unmuteActor, .muteThread, .unmuteThread, .muteActorList, .unmuteActorList, + .chatSendMessage, .chatUpdateRead, .chatLeaveConvo, .chatMuteConvo, .chatUnmuteConvo, + .createReport, .xrpcProcedure, .xrpcDataProcedure: return .post case .xrpcQuery: @@ -162,11 +191,10 @@ extension BskyAPI: EndpointType { 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, - "limit": limit - ])) + case .searchActors(let query, let limit, let cursor): + var params: Parameters = ["q": query, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) case .searchActorsTypeahead(let query, let limit): return .requestParameters(encoding: .urlEncoding(parameters: [ @@ -318,6 +346,44 @@ extension BskyAPI: EndpointType { "seenAt": formatter.string(from: seenAt) ])) + // Chat endpoints + case .chatListConvos(let limit, let cursor): + var params: Parameters = ["limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .chatGetConvo(let convoId): + return .requestParameters(encoding: .urlEncoding(parameters: ["convoId": convoId])) + + case .chatGetMessages(let convoId, let limit, let cursor): + var params: Parameters = ["convoId": convoId, "limit": limit] + if let cursor { params["cursor"] = cursor } + return .requestParameters(encoding: .urlEncoding(parameters: params)) + + case .chatSendMessage(_, let message): + return .requestParameters(encoding: .jsonDataEncoding(data: message)) + + case .chatUpdateRead(let convoId, let messageId): + var params: Parameters = ["convoId": convoId] + if let messageId { params["messageId"] = messageId } + return .requestParameters(encoding: .jsonEncoding(parameters: params)) + + case .chatLeaveConvo(let convoId): + return .requestParameters(encoding: .jsonEncoding(parameters: ["convoId": convoId])) + + case .chatMuteConvo(let convoId): + return .requestParameters(encoding: .jsonEncoding(parameters: ["convoId": convoId])) + + case .chatUnmuteConvo(let convoId): + return .requestParameters(encoding: .jsonEncoding(parameters: ["convoId": convoId])) + + case .chatGetConvoForMembers(let members): + return .requestParameters(encoding: .urlEncoding(parameters: ["members": members])) + + // Moderation + case .createReport(let body): + return .requestParameters(encoding: .jsonDataEncoding(data: body)) + case .xrpcQuery(_, let parameters): if parameters.isEmpty { return .request @@ -339,6 +405,10 @@ extension BskyAPI: EndpointType { var headers: HTTPHeaders = ["Content-Type": contentType] if let accept { headers["Accept"] = accept } return headers + case .chatListConvos, .chatGetConvo, .chatGetMessages, .chatSendMessage, + .chatUpdateRead, .chatLeaveConvo, .chatMuteConvo, .chatUnmuteConvo, + .chatGetConvoForMembers: + return ["atproto-proxy": "did:web:api.bsky.chat#bsky_chat"] default: return nil } diff --git a/Sources/bskyKit/BskyService.swift b/Sources/bskyKit/BskyService.swift index 30e7872..610e03d 100644 --- a/Sources/bskyKit/BskyService.swift +++ b/Sources/bskyKit/BskyService.swift @@ -138,8 +138,8 @@ public struct BskyService: Sendable { /// - limit: Maximum number of results to return (default: 25, max: 100). /// - 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 execute(.searchActors(query: query, limit: limit)) + public func searchActors(query: String, limit: Int = 25, cursor: String? = nil) async throws -> SearchActorsResult { + try await execute(.searchActors(query: query, limit: limit, cursor: cursor)) } /// Fast search for autocomplete functionality. @@ -407,6 +407,98 @@ public struct BskyService: Sendable { let _: EmptyResponse = try await execute(.updateSeen(seenAt: date)) } + // MARK: - Chat + + /// Lists the user's conversations. + public func listConvos(limit: Int = 50, cursor: String? = nil) async throws -> ConvoListResponse { + try await execute(.chatListConvos(limit: limit, cursor: cursor)) + } + + /// Gets a single conversation by ID. + public func getConvo(convoId: String) async throws -> GetConvoForMembersResponse { + try await execute(.chatGetConvo(convoId: convoId)) + } + + /// Gets messages in a conversation. + public func getMessages(convoId: String, limit: Int = 50, cursor: String? = nil) async throws -> ChatMessagesResponse { + try await execute(.chatGetMessages(convoId: convoId, limit: limit, cursor: cursor)) + } + + /// Sends a message in a conversation. + public func sendMessage(convoId: String, text: String) async throws -> ChatSendMessageResponse { + let body: [String: Any] = [ + "convoId": convoId, + "message": ["text": text] + ] + let data = try JSONSerialization.data(withJSONObject: body) + return try await execute(.chatSendMessage(convoId: convoId, message: data)) + } + + /// Marks a conversation as read. + public func updateConvoRead(convoId: String, messageId: String? = nil) async throws -> UpdateReadResponse { + try await execute(.chatUpdateRead(convoId: convoId, messageId: messageId)) + } + + /// Leaves a conversation. + public func leaveConvo(convoId: String) async throws -> LeaveConvoResponse { + try await execute(.chatLeaveConvo(convoId: convoId)) + } + + /// Mutes a conversation. + public func muteConvo(convoId: String) async throws -> GetConvoForMembersResponse { + try await execute(.chatMuteConvo(convoId: convoId)) + } + + /// Unmutes a conversation. + public func unmuteConvo(convoId: String) async throws -> GetConvoForMembersResponse { + try await execute(.chatUnmuteConvo(convoId: convoId)) + } + + /// Gets or creates a conversation with the given members. + public func getConvoForMembers(members: [String]) async throws -> GetConvoForMembersResponse { + try await execute(.chatGetConvoForMembers(members: members)) + } + + // MARK: - Moderation + + /// Reports a post or user. + public func createReport(reasonType: String, reason: String?, subjectDid: String?, subjectUri: String?, subjectCid: String?) async throws -> JSONValue { + var subject: [String: Any] = [:] + if let subjectUri, let subjectCid { + subject["$type"] = "com.atproto.repo.strongRef" + subject["uri"] = subjectUri + subject["cid"] = subjectCid + } else if let subjectDid { + subject["$type"] = "com.atproto.admin.defs#repoRef" + subject["did"] = subjectDid + } + + var body: [String: Any] = [ + "reasonType": reasonType, + "subject": subject + ] + if let reason { body["reason"] = reason } + + let data = try JSONSerialization.data(withJSONObject: body) + return try await execute(.createReport(body: data)) + } + + // MARK: - Typed List Methods + + /// Fetches lists created by an actor with typed response. + public func getListsTyped(for actor: String, limit: Int = 50, cursor: String? = nil) async throws -> GraphListsResponse { + var params: Parameters = ["actor": actor, "limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.getLists", parameters: params) + } + + /// Fetches a list and its membership with typed response. + public func getListTyped(uri: String, limit: Int = 50, cursor: String? = nil) async throws -> GraphListResponse { + var params: Parameters = ["list": uri, "limit": limit] + if let cursor { params["cursor"] = cursor } + return try await executeQuery("app.bsky.graph.getList", parameters: params) + } + // MARK: - Priority 2: Account, Bookmark, and Graph Collections /// Updates actor preferences payload. diff --git a/Sources/bskyKit/Models/Chat.swift b/Sources/bskyKit/Models/Chat.swift new file mode 100644 index 0000000..87e7ff6 --- /dev/null +++ b/Sources/bskyKit/Models/Chat.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Response from chat.bsky.convo.listConvos +public struct ConvoListResponse: Codable, Sendable { + public let convos: [ConvoView] + public let cursor: String? +} + +/// A conversation view +public struct ConvoView: Codable, Sendable, Identifiable { + public let id: String + public let rev: String + public let members: [ChatMember] + public let lastMessage: ChatMessageUnion? + public let muted: Bool + public let unreadCount: Int +} + +/// Union type for messages +public enum ChatMessageUnion: Codable, Sendable { + case message(ChatMessageView) + case deleted(DeletedMessageView) + case unknown + + enum CodingKeys: String, CodingKey { + case type = "$type" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decodeIfPresent(String.self, forKey: .type) + + switch type { + case "chat.bsky.convo.defs#messageView": + self = .message(try ChatMessageView(from: decoder)) + case "chat.bsky.convo.defs#deletedMessageView": + self = .deleted(try DeletedMessageView(from: decoder)) + default: + self = .unknown + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .message(let msg): + try msg.encode(to: encoder) + case .deleted(let msg): + try msg.encode(to: encoder) + case .unknown: + var container = encoder.singleValueContainer() + try container.encode([String: String]()) + } + } +} + +/// A chat message +public struct ChatMessageView: Codable, Sendable, Identifiable { + public let id: String + public let rev: String + public let text: String + public let facets: [Facet]? + public let sender: ChatSender + public let sentAt: Date +} + +/// Deleted message placeholder +public struct DeletedMessageView: Codable, Sendable, Identifiable { + public let id: String + public let rev: String + public let sender: ChatSender + public let sentAt: Date +} + +/// Chat message sender +public struct ChatSender: Codable, Sendable { + public let did: String +} + +/// Chat member profile +public struct ChatMember: Codable, Sendable, Identifiable { + public let did: String + public let handle: String + public let displayName: String? + public let avatar: String? + public let chatDisabled: Bool? + + public var id: String { did } +} + +/// Response from chat.bsky.convo.getMessages +public struct ChatMessagesResponse: Codable, Sendable { + public let messages: [ChatMessageUnion] + public let cursor: String? +} + +/// Response from chat.bsky.convo.sendMessage +public struct ChatSendMessageResponse: Codable, Sendable { + public let id: String + public let rev: String + public let text: String + public let facets: [Facet]? + public let sender: ChatSender + public let sentAt: Date +} + +/// Response from chat.bsky.convo.getConvoForMembers +public struct GetConvoForMembersResponse: Codable, Sendable { + public let convo: ConvoView +} + +/// Response from chat.bsky.convo.updateRead +public struct UpdateReadResponse: Codable, Sendable { + public let convo: ConvoView +} + +/// Response from chat.bsky.convo.leaveConvo +public struct LeaveConvoResponse: Codable, Sendable { + public let convoId: String + public let rev: String +} diff --git a/Sources/bskyKit/Models/Lists.swift b/Sources/bskyKit/Models/Lists.swift new file mode 100644 index 0000000..52fa4c0 --- /dev/null +++ b/Sources/bskyKit/Models/Lists.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Response from app.bsky.graph.getLists +public struct GraphListsResponse: Codable, Sendable { + public let lists: [GraphListView] + public let cursor: String? +} + +/// Response from app.bsky.graph.getList +public struct GraphListResponse: Codable, Sendable { + public let list: GraphListView + public let items: [GraphListItemView] + public let cursor: String? +} + +/// A list view +public struct GraphListView: Codable, Sendable, Identifiable { + public let uri: String + public let cid: String + public let creator: Author + public let name: String + public let purpose: String + public let description: String? + public let descriptionFacets: [Facet]? + public let avatar: String? + public let listItemCount: Int? + public let indexedAt: Date? + public let viewer: ListViewer? + public let labels: [AuthorLabels]? + + public var id: String { uri } + + public var purposeLabel: String { + switch purpose { + case "app.bsky.graph.defs#curatelist": + return "Curation" + case "app.bsky.graph.defs#modlist": + return "Moderation" + case "app.bsky.graph.defs#referencelist": + return "Reference" + default: + return "List" + } + } + + public var isCurationList: Bool { + purpose == "app.bsky.graph.defs#curatelist" + } + + public var isModList: Bool { + purpose == "app.bsky.graph.defs#modlist" + } +} + +/// Viewer state for a list +public struct ListViewer: Codable, Sendable { + public let muted: Bool? + public let blocked: String? +} + +/// An item (member) in a list +public struct GraphListItemView: Codable, Sendable, Identifiable { + public let uri: String + public let subject: ActorProfile + + public var id: String { uri } +} diff --git a/Sources/bskyKit/Models/Preferences.swift b/Sources/bskyKit/Models/Preferences.swift index 75ca488..4c93b23 100644 --- a/Sources/bskyKit/Models/Preferences.swift +++ b/Sources/bskyKit/Models/Preferences.swift @@ -28,6 +28,13 @@ public struct SavedFeedItem: Codable, Sendable, Identifiable { public let type: String public let value: String public let pinned: Bool + + public init(id: String, type: String, value: String, pinned: Bool) { + self.id = id + self.type = type + self.value = value + self.pinned = pinned + } } // MARK: - Convenience diff --git a/Sources/bskyKit/RepoService.swift b/Sources/bskyKit/RepoService.swift index 261be56..fea9209 100644 --- a/Sources/bskyKit/RepoService.swift +++ b/Sources/bskyKit/RepoService.swift @@ -201,6 +201,45 @@ public struct RepoService: Sendable { try await deleteRecord(repo: repo, collection: "app.bsky.graph.follow", rkey: rkey) } + /// Creates a list + public func createList(repo: String, name: String, purpose: String, description: String?) async throws -> CreateRecordResponse { + var record: [String: Any] = [ + "$type": "app.bsky.graph.list", + "name": name, + "purpose": purpose, + "createdAt": ISO8601DateFormatter().string(from: Date()) + ] + if let description { record["description"] = description } + return try await createRecord(repo: repo, collection: "app.bsky.graph.list", record: record) + } + + /// Deletes a list + public func deleteList(uri: String, repo: String) async throws { + guard let rkey = extractRkey(from: uri) else { + throw RepoError.invalidUri(uri) + } + try await deleteRecord(repo: repo, collection: "app.bsky.graph.list", rkey: rkey) + } + + /// Adds a user to a list + public func addListItem(listUri: String, subjectDid: String, repo: String) async throws -> CreateRecordResponse { + let record: [String: Any] = [ + "$type": "app.bsky.graph.listitem", + "subject": subjectDid, + "list": listUri, + "createdAt": ISO8601DateFormatter().string(from: Date()) + ] + return try await createRecord(repo: repo, collection: "app.bsky.graph.listitem", record: record) + } + + /// Removes a user from a list + public func removeListItem(uri: String, repo: String) async throws { + guard let rkey = extractRkey(from: uri) else { + throw RepoError.invalidUri(uri) + } + try await deleteRecord(repo: repo, collection: "app.bsky.graph.listitem", rkey: rkey) + } + /// Blocks a user public func block(did: String, repo: String) async throws -> CreateRecordResponse { let record: [String: Any] = [