From 41bded676c443a9daf14fc17e37a2de85afaa5b8 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 02:09:48 -0500 Subject: [PATCH 01/34] Resolve merge conflict --- .../Lexicons/Models/com.atproto/ComAtprotoLexicon.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/ATProtoKit/ATProtoKit.docc/Extensions/Lexicons/Models/com.atproto/ComAtprotoLexicon.md b/Sources/ATProtoKit/ATProtoKit.docc/Extensions/Lexicons/Models/com.atproto/ComAtprotoLexicon.md index 5ed213465d..76af507bc8 100644 --- a/Sources/ATProtoKit/ATProtoKit.docc/Extensions/Lexicons/Models/com.atproto/ComAtprotoLexicon.md +++ b/Sources/ATProtoKit/ATProtoKit.docc/Extensions/Lexicons/Models/com.atproto/ComAtprotoLexicon.md @@ -4,8 +4,4 @@ ### com.atproto.lexicon.schema -<<<<<<< HEAD - ``ComAtprotoLexicon/Lexicon/SchemaRecord`` -======= -- ``SchemaRecord`` ->>>>>>> main From 4c759189f47ccd0f00b8364e15c3d92bc14a3594 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 02:10:38 -0500 Subject: [PATCH 02/34] Remove SessionConfiguration conformance --- .../APIReference/SessionManager/ATProtocolConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/ATProtocolConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/ATProtocolConfiguration.swift index 7a9d4a0783..c0c6968388 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/ATProtocolConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/ATProtocolConfiguration.swift @@ -9,7 +9,7 @@ import Foundation import Logging /// Manages authentication and session operations for the a user account in the ATProtocol. -public class ATProtocolConfiguration: SessionConfiguration { +public class ATProtocolConfiguration { /// The user's handle identifier in their account. public var handle: String From ad934a83a1d9366200e6114a9085fe5f9cc89e05 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 02:12:06 -0500 Subject: [PATCH 03/34] Begin to merge UserSession properties to SessionConfiguration --- .../SessionManager/SessionConfiguration.swift | 64 ++++++++++++++++++- 1 file changed, 61 insertions(+), 3 deletions(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index 5b105d3e39..5b496e08af 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -6,27 +6,85 @@ // import Foundation +import Logging /// Defines the requirements for session configurations within ATProtoKit. -public protocol SessionConfiguration { +public protocol SessionConfiguration: Sendable { - /// The user's unique handle used for authentication purposes. + /// The user's handle within the AT Protocol. var handle: String { get } - /// The app password associated with the user's account, used for authentication. + /// The password associated with the user's account, used for authentication. var password: String { get } + /// The decentralized identifier (DID), serving as a persistent and long-term account + /// identifier according to the W3C standard. + var sessionDID: String { get } + + /// The user's email address. Optional. + var email: String? { get } + + /// Indicates whether the user's email address has been confirmed. Optional. + var isEmailConfirmed: Bool? { get } + + /// Indicates whether Two-Factor Authentication (via email) is enabled. Optional. + var isEmailAuthenticationFactorEnabled: Bool? { get } + + /// The access token used for API requests that requests authentication. + var accessToken: String { get } + + /// The refresh token used to generate a new access token. + var refreshToken: String { get } + + /// The DID document associated with the user, which contains AT Protocol-specific + /// information. Optional. + var didDocument: DIDDocument? { get } + + /// Indicates whether the user account is active. Optional. + var isActive: Bool? { get } + + /// Indicates the possible reason for why the user account is inactive. Optional. + var status: UserAccountStatus? { get } + + /// The user account's endpoint used for sending authentication requests. + var serviceEndpoint: URL { get } + /// The base URL of the Personal Data Server (PDS) with which the AT Protocol interacts. /// /// This URL is used to make network requests to the PDS for various operations, such as /// session creation, refresh, and deletion. var pdsURL: String { get } + /// Specifies the logger that will be used for emitting log messages. Optional. + /// + /// - Note: This is not included when initalizing `UserSession`. Instead, it's added + /// after the successful initalizing. + var logger: Logger? { get } + /// The object attached to the configuration class that holds the session. Optional. /// /// This also includes things such as retry limits and logging. var session: UserSession? { get } + /// The number of times a request can be attempted before it's considered a failure. + /// + /// By default, ATProtoKit will retry a request attempt for 1 second. + /// + /// - Note: This is not included when initalizing `UserSession`. Instead, it's added + /// after the successful initalizing. + var maxRetryCount: Int? { get } + + /// The length of time to wait before attempting to retry a request. + /// + /// By default, ATProtoKit will wait for 1 second before attempting to retry a request. + /// ATProtoKit will change the number exponentally in order to help prevent overloading + /// the server. + /// + /// - Note: This is not included when initalizing `UserSession`. Instead, it's added + /// after the successful initalizing. + var retryTimeDelay: TimeInterval? { get } + + /// Attempts to authenticate with the PDS using the `handle` and `appPassword`. /// /// This method should implement the necessary logic to authenticate the user against the PDS, From 6af85729ea57c276bd32aabe05b2a35192e6a680 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 02:42:00 -0500 Subject: [PATCH 04/34] Add methods inside SessionConfiguration --- .../SessionManager/SessionConfiguration.swift | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index 5b496e08af..8057b82c3b 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -97,4 +97,118 @@ public protocol SessionConfiguration: Sendable { /// /// - Throws: An error if there are issues creating the request or communicating with the PDS. func authenticate(authenticationFactorToken: String?) async throws + + /// Creates an a new account for the user. + /// + /// - Note: `plcOp` may be updated when full account migration is implemented. + /// + /// - Bug: `plcOp` is currently broken: there's nothing that can be used for this at the + /// moment while Bluesky continues to work on account migration. Until everything settles + /// and they have a concrete example of what to do, don't use it. In the meantime, leave it + /// at `nil`. + /// + /// - Parameters: + /// - email: The email of the user. Optional + /// - handle: The handle the user wishes to use. + /// - existingDID: A decentralized identifier (DID) that has existed before and will be + /// used to be imported to the new account. Optional. + /// - inviteCode: The invite code for the user. Optional. + /// - verificationCode: A verification code. + /// - verificationPhone: A code that has come from a text message in the user's + /// phone. Optional. + /// - password: The password the user will use for the account. Optional. + /// - recoveryKey: DID PLC rotation key (aka, recovery key) to be included in PLC + /// creation operation. Optional. + /// - plcOperation: A signed DID PLC operation to be submitted as part of importing an + /// existing account to this instance. Optional. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func createAccount( + email: String?, + password: String, + existingDID: String?, + inviteCode: String?, + verificationCode: String?, + verificationPhone: String?, + password: String?, + recoveryKey: String?, + plcOperation: UnknownType? + ) async throws + + /// Fetches an existing session using an access token. + /// + /// If the access token is invalid, then a new one will be created, either by refeshing a + /// session, or by re-authenticating. + /// + /// - Parameters: + /// - accessToken: The access token used for the session. Optional. + /// Defaults to `nil`. + /// - authenticationFactorToken: A token used for Two-Factor Authentication. Optional. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func getSession(by accessToken: String?, authenticationFactorToken: String?) async throws + + /// Refreshes the user's session using a refresh token. + /// + /// If the refresh token is invalid, the method will re-authenticate and try again. + /// + /// - Note: If the method throws an error saying that an authentication token is required, + /// re-trying the method with the `authenticationFactorToken` argument filled should + /// solve the issue. + /// + /// - Note: If you rely on ``ATProtocolConfiguration/session`` for managing the session, + /// there's no need to use the `refreshToken` argument. + /// + /// When the method completes, ``ATProtocolConfiguration/session`` will be updated with a + /// new instance of an authenticated user session within the AT Protocol. It may also have + /// logging information, as well as the URL of the Personal Data Server (PDS). + /// + /// - Parameters: + /// - refreshToken: The refresh token used for the session. Optional. + /// Defaults to `nil`. + /// - authenticationFactorToken: A token used for Two-Factor Authentication. Optional. + /// Defaults to `nil`. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func refreshSession(by refreshToken: String?, authenticationFactorToken: String?) async throws + + /// Resumes a session. + /// + /// This is useful for cases where a user is opening the app and they've already logged in. + /// + /// While inserting the access token is optional, the refresh token is not, as it lasts much + /// longer than the refresh token. + /// + /// - Warning: This is an experimental method. This may be removed at a later date if + /// it doesn't prove to be helpful. + /// + /// If the refresh token fails for whatever reason, it's recommended to call + /// ``ATProtocolConfiguration/authenticate(authenticationFactorToken:)`` + /// in the `catch` block. + /// + /// - Parameters: + /// - accessToken: The access token of the session. Optional. + /// - refreshToken: The refresh token of the session. + /// - pdsURL: The URL of the Personal Data Server (PDS). Defaults to `https://bsky.social`. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func resumeSession(accessToken: String?, refreshToken: String) async throws + + /// Deletes the user session. + /// + /// - Note: If you rely on ``ATProtocolConfiguration/session`` for managing the session, + /// there's no need to use the `refreshToken` argument. + /// + /// - Parameter refreshToken: The refresh token used for the session. Optional. + /// Defaults to `nil`. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func deleteSession(with refreshToken: String?) async throws +} + } From 662d4043785afef6b83ee7de41889b9da7294aba Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 03:25:09 -0500 Subject: [PATCH 05/34] Add SessionConfigurationTools All helper methods of ATProtocolConfiguration have been transferred to this class. --- Sources/ATProtoKit/Errors/ATProtoError.swift | 17 ++ .../Utilities/SessionConfigurationTools.swift | 239 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift diff --git a/Sources/ATProtoKit/Errors/ATProtoError.swift b/Sources/ATProtoKit/Errors/ATProtoError.swift index 07bb43028a..4e99655d86 100644 --- a/Sources/ATProtoKit/Errors/ATProtoError.swift +++ b/Sources/ATProtoKit/Errors/ATProtoError.swift @@ -404,6 +404,23 @@ extension ATProtocolConfiguration { } } +extension SessionConfigurationTools { + + /// An error type related to ``ATProtocolConfiguration``. + public enum SessionConfigurationToolsError: ATProtoError { + + /// No token was found. + /// + /// - Parameter message: The message for the error. + case noSessionToken(message: String) + + /// The access and refresh tokens have both expired. + /// + /// - Parameter message: The message for the error. + case tokensExpired(message: String) + } +} + /// An error type related to CBOR processing issues. public enum CBORProcessingError: Error { diff --git a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift new file mode 100644 index 0000000000..52e94fbdaa --- /dev/null +++ b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift @@ -0,0 +1,239 @@ +// +// SessionConfigurationTools.swift +// ATProtoKit +// +// Created by Christopher Jr Riley on 2025-02-02. +// + +import Foundation + +/// A class of helper methods for ``SessionConfiguration``-conforming classes. +public class SessionConfigurationTools { + + /// An instance of ``SessionConfiguration``. + public let sessionConfiguration: SessionConfiguration + + /// Creates an instance for the class. + /// + /// - Parameter sessionConfiguration: An instance of ``SessionConfiguration``. + public init(sessionConfiguration: SessionConfiguration) { + self.sessionConfiguration = sessionConfiguration + } + + // MARK: - Common Helpers + /// Converts the DID document from an ``UnknownType`` object to a ``DIDDocument`` object. + /// + /// - Parameter didDocument: The DID document as an ``UnknownType`` object. Optional. + /// Defaults to `nil`. + /// - Returns: A ``DIDDocument`` object (if there's a value) or `nil` (if not). + public func convertDIDDocument(_ didDocument: UnknownType? = nil) -> DIDDocument? { + var decodedDidDocument: DIDDocument? = nil + + do { + if let didDocument = didDocument, + let jsonData = try didDocument.toJSON() { + let decoder = JSONDecoder() + decodedDidDocument = try decoder.decode(DIDDocument.self, from: jsonData) + } + } catch { + return nil + } + + return decodedDidDocument + } + + /// Checks the refresh token and refreshes the session. + /// + /// - Parameter refreshToken: The refresh token of the session. + /// + /// - Throws: ``ATProtocolConfigurationError`` if the current date is past the token's + /// expiry date. + public func checkRefreshToken(refreshToken: String, pdsURL: String = "https://bsky.social") async throws { + let expiryDate = try SessionToken(sessionToken: refreshToken).payload.expiresAt + let currentDate = Date() + + if currentDate > expiryDate { + throw SessionConfigurationToolsError.tokensExpired(message: "The access and refresh tokens have expired.") + } + + do { + _ = try await self.sessionConfiguration.refreshSession(by: refreshToken, authenticationFactorToken: nil) + } catch { + throw error + } + } + + // MARK: - getSession Helpers + /// Validates and retrieves a valid access token from the provided argument or the session object. + /// + /// - Parameter accessToken: An optional access token to validate. + /// - Returns: A valid access token. + /// + /// - Throws: An `ATProtoError` if no access token is available. + public func getValidAccessToken(from accessToken: String?) throws -> String { + if let token = accessToken { + return token + } + + if let token = self.session?.accessToken { + return token + } + + throw SessionConfigurationToolsError.noSessionToken(message: "No session token available.") + } + + /// Handles re-authentication and session refresh when the token is expired. + /// + /// - Parameter authenticationFactorToken: A token used for Two-Factor Authentication. + /// Optional. Defaults to `nil`. + /// - Returns: A refreshed session object. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError``, ``ATRequestPrepareError``, and + /// ``ATProtocolConfigurationError`` for more details. + public func handleExpiredTokenFromGetSession( + authenticationFactorToken: String? = nil + ) async throws -> ComAtprotoLexicon.Server.GetSessionOutput { + do { + _ = try await self.sessionConfiguration.refreshSession(by: nil, authenticationFactorToken: authenticationFactorToken) + + guard let session = self.session else { + throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") + } + + var refreshedSessionStatus: ComAtprotoLexicon.Server.GetSession.UserAccountStatus? = nil + + // UserAccountStatus conversion. + let sessionStatus = session.status + switch sessionStatus { + case .suspended: + refreshedSessionStatus = .suspended + case .takedown: + refreshedSessionStatus = .takedown + case .deactivated: + refreshedSessionStatus = .deactivated + default: + refreshedSessionStatus = nil + } + + // DIDDocument conversion. + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + let jsonData = try encoder.encode(session.didDocument) + + guard let rawDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Failed to serialize DIDDocument into [String: Any].") + ) + } + + var codableDictionary = [String: CodableValue]() + for (key, value) in rawDictionary { + codableDictionary[key] = try CodableValue.fromAny(value) + } + + let didDocument = UnknownType.unknown(codableDictionary) + + return ComAtprotoLexicon.Server + .GetSessionOutput( + handle: session.handle, + did: session.sessionDID, + email: session.email, + isEmailConfirmed: session.isEmailConfirmed, + isEmailAuthenticationFactor: session.isEmailAuthenticationFactorEnabled, + didDocument: didDocument, + isActive: session.isActive, + status: refreshedSessionStatus + ) + } catch { + throw error + } + } + + // MARK: - refreshSession Helpers + /// Validates and retrieves a valid access token from the provided argument or the session object. + /// + /// - Parameter accessToken: An optional access token to validate. + /// - Returns: A valid access token. + /// + /// - Throws: An `ATProtoError` if no access token is available. + public func getValidRefreshToken(from refreshToken: String?) throws -> String { + if let token = refreshToken { + return token + } + + if let token = self.session?.refreshToken { + return token + } + + throw SessionConfigurationToolsError.noSessionToken(message: "No session token available.") + } + + /// Handles re-authentication and session refresh when the token is expired. + /// + /// - Parameter authenticationFactorToken: A token used for Two-Factor Authentication. + /// Optional. Defaults to `nil`. + /// - Returns: A refreshed session object. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError``, ``ATRequestPrepareError``, and + /// ``ATProtocolConfigurationError`` for more details. + public func handleExpiredTokenFromRefreshSession( + authenticationFactorToken: String? = nil + ) async throws -> ComAtprotoLexicon.Server.RefreshSessionOutput { + do { + try await self.authenticate(authenticationFactorToken: authenticationFactorToken) + + guard let session = self.session else { + throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") + } + + var refreshedSessionStatus: ComAtprotoLexicon.Server.RefreshSession.UserAccountStatus? = nil + + // UserAccountStatus conversion. + let sessionStatus = session.status + switch sessionStatus { + case .suspended: + refreshedSessionStatus = .suspended + case .takedown: + refreshedSessionStatus = .takedown + case .deactivated: + refreshedSessionStatus = .deactivated + default: + refreshedSessionStatus = nil + } + + // DIDDocument conversion. + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + let jsonData = try encoder.encode(session.didDocument) + + guard let rawDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Failed to serialize DIDDocument into [String: Any].") + ) + } + + var codableDictionary = [String: CodableValue]() + for (key, value) in rawDictionary { + codableDictionary[key] = try CodableValue.fromAny(value) + } + + let didDocument = UnknownType.unknown(codableDictionary) + + return ComAtprotoLexicon.Server.RefreshSessionOutput( + accessToken: session.accessToken, + refreshToken: session.refreshToken, + handle: session.handle, + did: session.sessionDID, + didDocument: didDocument, + isActive: session.isActive, + status: refreshedSessionStatus + ) + } catch { + throw error + } + } +} From 0318abb2b874b867527cbee702b5f7de7210a47e Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 04:25:52 -0500 Subject: [PATCH 06/34] Fix errors in SessionConfigurationTools --- .../Utilities/SessionConfigurationTools.swift | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift index 52e94fbdaa..ce7e1098f6 100644 --- a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift +++ b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift @@ -75,7 +75,7 @@ public class SessionConfigurationTools { return token } - if let token = self.session?.accessToken { + if let token = self.sessionConfiguration.accessToken { return token } @@ -97,14 +97,15 @@ public class SessionConfigurationTools { do { _ = try await self.sessionConfiguration.refreshSession(by: nil, authenticationFactorToken: authenticationFactorToken) - guard let session = self.session else { + guard let status = self.sessionConfiguration.status, + let didDocument = self.sessionConfiguration.didDocument else { throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") } var refreshedSessionStatus: ComAtprotoLexicon.Server.GetSession.UserAccountStatus? = nil // UserAccountStatus conversion. - let sessionStatus = session.status + let sessionStatus = status switch sessionStatus { case .suspended: refreshedSessionStatus = .suspended @@ -120,7 +121,7 @@ public class SessionConfigurationTools { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted - let jsonData = try encoder.encode(session.didDocument) + let jsonData = try encoder.encode(didDocument) guard let rawDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { throw DecodingError.dataCorrupted( @@ -133,17 +134,17 @@ public class SessionConfigurationTools { codableDictionary[key] = try CodableValue.fromAny(value) } - let didDocument = UnknownType.unknown(codableDictionary) + let unknownDIDDocument = UnknownType.unknown(codableDictionary) return ComAtprotoLexicon.Server .GetSessionOutput( - handle: session.handle, - did: session.sessionDID, - email: session.email, - isEmailConfirmed: session.isEmailConfirmed, - isEmailAuthenticationFactor: session.isEmailAuthenticationFactorEnabled, - didDocument: didDocument, - isActive: session.isActive, + handle: self.sessionConfiguration.handle, + did: self.sessionConfiguration.sessionDID, + email: self.sessionConfiguration.email, + isEmailConfirmed: self.sessionConfiguration.isEmailConfirmed, + isEmailAuthenticationFactor: self.sessionConfiguration.isEmailAuthenticationFactorEnabled, + didDocument: unknownDIDDocument, + isActive: self.sessionConfiguration.isActive, status: refreshedSessionStatus ) } catch { @@ -163,11 +164,8 @@ public class SessionConfigurationTools { return token } - if let token = self.session?.refreshToken { - return token - } - - throw SessionConfigurationToolsError.noSessionToken(message: "No session token available.") + let token = self.sessionConfiguration.refreshToken + return token } /// Handles re-authentication and session refresh when the token is expired. @@ -183,16 +181,17 @@ public class SessionConfigurationTools { authenticationFactorToken: String? = nil ) async throws -> ComAtprotoLexicon.Server.RefreshSessionOutput { do { - try await self.authenticate(authenticationFactorToken: authenticationFactorToken) + try await self.sessionConfiguration.authenticate(authenticationFactorToken: authenticationFactorToken) - guard let session = self.session else { + guard let status = self.sessionConfiguration.status, + let didDocument = self.sessionConfiguration.didDocument else { throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") } var refreshedSessionStatus: ComAtprotoLexicon.Server.RefreshSession.UserAccountStatus? = nil // UserAccountStatus conversion. - let sessionStatus = session.status + let sessionStatus = status switch sessionStatus { case .suspended: refreshedSessionStatus = .suspended @@ -208,7 +207,7 @@ public class SessionConfigurationTools { let encoder = JSONEncoder() encoder.outputFormatting = .prettyPrinted - let jsonData = try encoder.encode(session.didDocument) + let jsonData = try encoder.encode(didDocument) guard let rawDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { throw DecodingError.dataCorrupted( @@ -221,15 +220,15 @@ public class SessionConfigurationTools { codableDictionary[key] = try CodableValue.fromAny(value) } - let didDocument = UnknownType.unknown(codableDictionary) + let unknownDIDDocument = UnknownType.unknown(codableDictionary) return ComAtprotoLexicon.Server.RefreshSessionOutput( - accessToken: session.accessToken, - refreshToken: session.refreshToken, - handle: session.handle, - did: session.sessionDID, - didDocument: didDocument, - isActive: session.isActive, + accessToken: self.sessionConfiguration.accessToken!, + refreshToken: self.sessionConfiguration.refreshToken, + handle: self.sessionConfiguration.handle, + did: self.sessionConfiguration.sessionDID, + didDocument: unknownDIDDocument, + isActive: self.sessionConfiguration.isActive, status: refreshedSessionStatus ) } catch { From 8d4790d834669b53401fa914a02ee6e541e8088e Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 04:30:59 -0500 Subject: [PATCH 07/34] Fix warnings in SessionConfigurationTools --- .../Utilities/SessionConfigurationTools.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift index ce7e1098f6..04ada58600 100644 --- a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift +++ b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift @@ -97,15 +97,14 @@ public class SessionConfigurationTools { do { _ = try await self.sessionConfiguration.refreshSession(by: nil, authenticationFactorToken: authenticationFactorToken) - guard let status = self.sessionConfiguration.status, - let didDocument = self.sessionConfiguration.didDocument else { + guard let didDocument = self.sessionConfiguration.didDocument else { throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") } var refreshedSessionStatus: ComAtprotoLexicon.Server.GetSession.UserAccountStatus? = nil // UserAccountStatus conversion. - let sessionStatus = status + let sessionStatus = self.sessionConfiguration.status switch sessionStatus { case .suspended: refreshedSessionStatus = .suspended @@ -183,15 +182,14 @@ public class SessionConfigurationTools { do { try await self.sessionConfiguration.authenticate(authenticationFactorToken: authenticationFactorToken) - guard let status = self.sessionConfiguration.status, - let didDocument = self.sessionConfiguration.didDocument else { + guard let didDocument = self.sessionConfiguration.didDocument else { throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") } var refreshedSessionStatus: ComAtprotoLexicon.Server.RefreshSession.UserAccountStatus? = nil // UserAccountStatus conversion. - let sessionStatus = status + let sessionStatus = self.sessionConfiguration.status switch sessionStatus { case .suspended: refreshedSessionStatus = .suspended From 51da06d2e5dca5383b84756fc3630888cb31e432 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 04:33:30 -0500 Subject: [PATCH 08/34] Correct method order --- .../APIReference/SessionManager/SessionConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index 8057b82c3b..aee4a00d7c 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -126,7 +126,7 @@ public protocol SessionConfiguration: Sendable { /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. func createAccount( email: String?, - password: String, + handle: String, existingDID: String?, inviteCode: String?, verificationCode: String?, From 57357601a18a0fb986c6d5cb6fb68fd32a922eba Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 04:36:52 -0500 Subject: [PATCH 09/34] Set protocol properties to "get set" --- .../SessionManager/SessionConfiguration.swift | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index aee4a00d7c..e19fa137a2 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -12,59 +12,59 @@ import Logging public protocol SessionConfiguration: Sendable { /// The user's handle within the AT Protocol. - var handle: String { get } + var handle: String { get set } /// The password associated with the user's account, used for authentication. - var password: String { get } + var password: String { get set } /// The decentralized identifier (DID), serving as a persistent and long-term account /// identifier according to the W3C standard. - var sessionDID: String { get } + var sessionDID: String { get set } /// The user's email address. Optional. - var email: String? { get } + var email: String? { get set } /// Indicates whether the user's email address has been confirmed. Optional. - var isEmailConfirmed: Bool? { get } + var isEmailConfirmed: Bool? { get set } /// Indicates whether Two-Factor Authentication (via email) is enabled. Optional. - var isEmailAuthenticationFactorEnabled: Bool? { get } + var isEmailAuthenticationFactorEnabled: Bool? { get set } /// The access token used for API requests that requests authentication. - var accessToken: String { get } + var accessToken: String? { get set } /// The refresh token used to generate a new access token. - var refreshToken: String { get } + var refreshToken: String { get set } /// The DID document associated with the user, which contains AT Protocol-specific /// information. Optional. - var didDocument: DIDDocument? { get } + var didDocument: DIDDocument? { get set } /// Indicates whether the user account is active. Optional. - var isActive: Bool? { get } + var isActive: Bool? { get set } /// Indicates the possible reason for why the user account is inactive. Optional. - var status: UserAccountStatus? { get } + var status: UserAccountStatus? { get set } /// The user account's endpoint used for sending authentication requests. - var serviceEndpoint: URL { get } + var serviceEndpoint: URL { get set } /// The base URL of the Personal Data Server (PDS) with which the AT Protocol interacts. /// /// This URL is used to make network requests to the PDS for various operations, such as /// session creation, refresh, and deletion. - var pdsURL: String { get } + var pdsURL: String { get set } /// Specifies the logger that will be used for emitting log messages. Optional. /// /// - Note: This is not included when initalizing `UserSession`. Instead, it's added /// after the successful initalizing. - var logger: Logger? { get } + var logger: Logger? { get set } /// The object attached to the configuration class that holds the session. Optional. /// /// This also includes things such as retry limits and logging. - var session: UserSession? { get } + var session: UserSession? { get set } /// The number of times a request can be attempted before it's considered a failure. /// @@ -72,7 +72,7 @@ public protocol SessionConfiguration: Sendable { /// /// - Note: This is not included when initalizing `UserSession`. Instead, it's added /// after the successful initalizing. - var maxRetryCount: Int? { get } + var maxRetryCount: Int? { get set } /// The length of time to wait before attempting to retry a request. /// @@ -82,7 +82,7 @@ public protocol SessionConfiguration: Sendable { /// /// - Note: This is not included when initalizing `UserSession`. Instead, it's added /// after the successful initalizing. - var retryTimeDelay: TimeInterval? { get } + var retryTimeDelay: TimeInterval? { get set } /// Attempts to authenticate with the PDS using the `handle` and `appPassword`. From a97ce25b6006a4fba229bb318cdb34ac0d4dc1cd Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 07:04:58 -0500 Subject: [PATCH 10/34] Remove "throws" from getValidRefreshToken() --- Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift index 04ada58600..fd0eddd1d0 100644 --- a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift +++ b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift @@ -156,9 +156,7 @@ public class SessionConfigurationTools { /// /// - Parameter accessToken: An optional access token to validate. /// - Returns: A valid access token. - /// - /// - Throws: An `ATProtoError` if no access token is available. - public func getValidRefreshToken(from refreshToken: String?) throws -> String { + public func getValidRefreshToken(from refreshToken: String?) -> String { if let token = refreshToken { return token } From 686e9fe7bfb53f319baf2f9b100919d9bf16a62e Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 07:16:17 -0500 Subject: [PATCH 11/34] Make refreshToken optional --- Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift index fd0eddd1d0..0eb3c699f8 100644 --- a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift +++ b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift @@ -180,7 +180,8 @@ public class SessionConfigurationTools { do { try await self.sessionConfiguration.authenticate(authenticationFactorToken: authenticationFactorToken) - guard let didDocument = self.sessionConfiguration.didDocument else { + guard let refreshToken = sessionConfiguration.refreshToken, + let didDocument = self.sessionConfiguration.didDocument else { throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") } @@ -220,7 +221,7 @@ public class SessionConfigurationTools { return ComAtprotoLexicon.Server.RefreshSessionOutput( accessToken: self.sessionConfiguration.accessToken!, - refreshToken: self.sessionConfiguration.refreshToken, + refreshToken: refreshToken, handle: self.sessionConfiguration.handle, did: self.sessionConfiguration.sessionDID, didDocument: unknownDIDDocument, From 355a45546d9093e148a23ffffcc41f04528f94a6 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 07:17:43 -0500 Subject: [PATCH 12/34] Make return statement in getValidRefreshToken() optional --- .../Utilities/SessionConfigurationTools.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift index 0eb3c699f8..7431a90f34 100644 --- a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift +++ b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift @@ -155,14 +155,17 @@ public class SessionConfigurationTools { /// Validates and retrieves a valid access token from the provided argument or the session object. /// /// - Parameter accessToken: An optional access token to validate. - /// - Returns: A valid access token. - public func getValidRefreshToken(from refreshToken: String?) -> String { + /// - Returns: A valid access token, or `nil`. + public func getValidRefreshToken(from refreshToken: String?) -> String? { if let token = refreshToken { return token } - let token = self.sessionConfiguration.refreshToken - return token + if let token = self.sessionConfiguration.refreshToken { + return token + } + + return nil } /// Handles re-authentication and session refresh when the token is expired. From c6d38824acf17a5263b66eb068f4edaf911e0dda Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 07:21:21 -0500 Subject: [PATCH 13/34] Remove UserSession from SessionConfiguration --- .../APIReference/SessionManager/SessionConfiguration.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index e19fa137a2..6b09ad2b6e 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -61,11 +61,6 @@ public protocol SessionConfiguration: Sendable { /// after the successful initalizing. var logger: Logger? { get set } - /// The object attached to the configuration class that holds the session. Optional. - /// - /// This also includes things such as retry limits and logging. - var session: UserSession? { get set } - /// The number of times a request can be attempted before it's considered a failure. /// /// By default, ATProtoKit will retry a request attempt for 1 second. From bed59b5756074852b055fe7912c3d99a63bb5a13 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 07:21:35 -0500 Subject: [PATCH 14/34] Make refreshToken optional --- .../APIReference/SessionManager/SessionConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index 6b09ad2b6e..7ea4763e4f 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -34,7 +34,7 @@ public protocol SessionConfiguration: Sendable { var accessToken: String? { get set } /// The refresh token used to generate a new access token. - var refreshToken: String { get set } + var refreshToken: String? { get set } /// The DID document associated with the user, which contains AT Protocol-specific /// information. Optional. From bd14d6579dc8eb31771627c034594b30d0e55940 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sun, 2 Feb 2025 07:22:49 -0500 Subject: [PATCH 15/34] Add SessionConfiguration extension Adding default implementations. --- .../SessionManager/SessionConfiguration.swift | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index 7ea4763e4f..157bfba4bf 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -206,4 +206,268 @@ public protocol SessionConfiguration: Sendable { func deleteSession(with refreshToken: String?) async throws } +extension SessionConfiguration { + + public mutating func authenticate(authenticationFactorToken: String? = nil) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + + do { + let response = try await ATProtoKit(canUseBlueskyRecords: false).createSession( + with: self.handle, + and: self.password, + authenticationFactorToken: authenticationFactorToken, + pdsURL: self.pdsURL + ) + + guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { + throw DIDDocument.DIDDocumentError.emptyArray + } + + let atService = try didDocument.checkServiceForATProto() + let serviceEndpoint = atService.serviceEndpoint + + var status: UserAccountStatus? = nil + + switch response.status { + case .suspended: + status = .suspended + case .takedown: + status = .takedown + case .deactivated: + status = .deactivated + default: + status = nil + } + + self.handle = response.handle + self.sessionDID = response.did + self.email = response.email + self.isEmailConfirmed = response.isEmailConfirmed + self.isEmailAuthenticationFactorEnabled = response.isEmailAuthenticatedFactor + self.accessToken = response.accessToken + self.refreshToken = response.refreshToken + self.didDocument = didDocument + self.isActive = response.isActive + self.status = status + self.serviceEndpoint = serviceEndpoint + self.logger = await ATProtocolConfiguration.getLogger() + } catch { + throw error + } + } + + public mutating func createAccount( + email: String?, + handle: String, + existingDID: String?, + inviteCode: String?, + verificationCode: String?, + verificationPhone: String?, + password: String?, + recoveryKey: String?, + plcOperation: UnknownType? + ) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + + let response = try await ATProtoKit(canUseBlueskyRecords: false).createAccount( + email: email, + handle: handle, + existingDID: existingDID, + inviteCode: inviteCode, + verificationCode: verificationCode, + verificationPhone: verificationPhone, + password: password, + recoveryKey: recoveryKey, + plcOperation: plcOperation, + pdsURL: self.pdsURL + ) + + guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { + throw DIDDocument.DIDDocumentError.emptyArray + } + + let atService = try didDocument.checkServiceForATProto() + let serviceEndpoint = atService.serviceEndpoint + + self.handle = response.handle + self.sessionDID = response.did + self.email = email + self.accessToken = response.accessToken + self.refreshToken = response.refreshToken + self.didDocument = didDocument + self.serviceEndpoint = serviceEndpoint + self.logger = await ATProtocolConfiguration.getLogger() + } + + public mutating func getSession(by accessToken: String? = nil, authenticationFactorToken: String? = nil) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + + var sessionToken: String = "" + do { + sessionToken = try sessionConfiguration.getValidAccessToken(from: accessToken) + } catch { + throw error + } + + do { + let response = try await ATProtoKit(canUseBlueskyRecords: false).getSession( + by: sessionToken, + pdsURL: self.pdsURL + ) + + guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { + throw DIDDocument.DIDDocumentError.emptyArray + } + + let atService = try didDocument.checkServiceForATProto() + let serviceEndpoint = atService.serviceEndpoint + + var status: UserAccountStatus? = nil + + switch response.status { + case .suspended: + status = .suspended + case .takedown: + status = .takedown + case .deactivated: + status = .deactivated + default: + status = nil + } + + self.handle = response.handle + self.sessionDID = response.did + self.email = response.email + self.isEmailConfirmed = response.isEmailConfirmed + self.isEmailAuthenticationFactorEnabled = response.isEmailAuthenticationFactor + self.accessToken = sessionToken + self.didDocument = didDocument + self.isActive = response.isActive + self.status = status + self.serviceEndpoint = serviceEndpoint + self.logger = await ATProtocolConfiguration.getLogger() + } catch let apiError as ATAPIError { + guard case .badRequest(let errorDetails) = apiError, + errorDetails.error == "ExpiredToken" else { + throw apiError + } + + _ = try await sessionConfiguration.handleExpiredTokenFromGetSession(authenticationFactorToken: authenticationFactorToken) + } + } + + public mutating func refreshSession( + by refreshToken: String? = nil, + authenticationFactorToken: String? = nil + ) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + var sessionToken: String = "" + + let token = sessionConfiguration.getValidRefreshToken(from: self.refreshToken) + + if let token = sessionConfiguration.getValidRefreshToken(from: self.refreshToken) { + sessionToken = token + } else { + do { + try await self.authenticate(authenticationFactorToken: authenticationFactorToken) + } catch { + throw error + } + } + + do { + let response = try await ATProtoKit(canUseBlueskyRecords: false).refreshSession( + refreshToken: sessionToken, + pdsURL: self.pdsURL + ) + + guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { + throw DIDDocument.DIDDocumentError.emptyArray + } + + let atService = try didDocument.checkServiceForATProto() + let serviceEndpoint = atService.serviceEndpoint + + var status: UserAccountStatus? = nil + + switch response.status { + case .suspended: + status = .suspended + case .takedown: + status = .takedown + case .deactivated: + status = .deactivated + default: + status = nil + } + + self.handle = response.handle + self.sessionDID = response.did + self.accessToken = response.accessToken + self.refreshToken = response.refreshToken + self.didDocument = didDocument + self.isActive = response.isActive + self.status = status + self.serviceEndpoint = serviceEndpoint + self.logger = await ATProtocolConfiguration.getLogger() + } catch let apiError as ATAPIError { + // If the token expires, re-authenticate and try refreshing the token again. + guard case .badRequest(let errorDetails) = apiError, + errorDetails.error == "ExpiredToken" else { + throw apiError + } + + _ = try await sessionConfiguration.handleExpiredTokenFromRefreshSession(authenticationFactorToken: authenticationFactorToken) + } + } + + public mutating func resumeSession( + accessToken: String? = nil, + refreshToken: String, + pdsURL: String = "https://bsky.social" + ) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + + if let sessionToken = accessToken ?? self.accessToken { + let expiryDate = try SessionToken(sessionToken: sessionToken).payload.expiresAt + let currentDate = Date() + + if currentDate > expiryDate { + do { + try await sessionConfiguration.checkRefreshToken(refreshToken: refreshToken, pdsURL: pdsURL) + } catch { + throw error + } + } + + _ = try await self.getSession(by: accessToken) + } else { + do { + try await sessionConfiguration.checkRefreshToken(refreshToken: refreshToken) + } catch { + throw error + } + } + } + + public mutating func deleteSession(with refreshToken: String? = nil) async throws { + do { + var token: String + + if let refreshToken = refreshToken { + token = refreshToken + } else if let sessionToken = self.refreshToken { + token = sessionToken + } else { + return + } + + _ = try await ATProtoKit(canUseBlueskyRecords: false).deleteSession( + refreshToken: token, + pdsURL: self.pdsURL + ) + } catch { + throw error + } + } } From 1fd69f67dfeacb6595116a0d59925ef5b689936f Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Tue, 25 Feb 2025 13:39:50 -0500 Subject: [PATCH 16/34] Update method signatures --- .../SessionManager/SessionConfiguration.swift | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index 157bfba4bf..81f0c9cb6b 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -212,11 +212,10 @@ extension SessionConfiguration { let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) do { - let response = try await ATProtoKit(canUseBlueskyRecords: false).createSession( + let response = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).createSession( with: self.handle, and: self.password, - authenticationFactorToken: authenticationFactorToken, - pdsURL: self.pdsURL + authenticationFactorToken: authenticationFactorToken ) guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { @@ -269,7 +268,7 @@ extension SessionConfiguration { ) async throws { let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) - let response = try await ATProtoKit(canUseBlueskyRecords: false).createAccount( + let response = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).createAccount( email: email, handle: handle, existingDID: existingDID, @@ -278,8 +277,7 @@ extension SessionConfiguration { verificationPhone: verificationPhone, password: password, recoveryKey: recoveryKey, - plcOperation: plcOperation, - pdsURL: self.pdsURL + plcOperation: plcOperation ) guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { @@ -310,9 +308,8 @@ extension SessionConfiguration { } do { - let response = try await ATProtoKit(canUseBlueskyRecords: false).getSession( - by: sessionToken, - pdsURL: self.pdsURL + let response = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).getSession( + by: sessionToken ) guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { @@ -376,9 +373,8 @@ extension SessionConfiguration { } do { - let response = try await ATProtoKit(canUseBlueskyRecords: false).refreshSession( - refreshToken: sessionToken, - pdsURL: self.pdsURL + let response = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).refreshSession( + refreshToken: sessionToken ) guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { @@ -462,9 +458,8 @@ extension SessionConfiguration { return } - _ = try await ATProtoKit(canUseBlueskyRecords: false).deleteSession( - refreshToken: token, - pdsURL: self.pdsURL + _ = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).deleteSession( + refreshToken: token ) } catch { throw error From 39d830445ebc6a43e3b0974a1dce75571f170c80 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Tue, 25 Feb 2025 14:54:08 -0500 Subject: [PATCH 17/34] Mark properties as get-only --- .../SessionManager/SessionConfiguration.swift | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index 81f0c9cb6b..f8d7117ddf 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -53,13 +53,13 @@ public protocol SessionConfiguration: Sendable { /// /// This URL is used to make network requests to the PDS for various operations, such as /// session creation, refresh, and deletion. - var pdsURL: String { get set } + var pdsURL: String { get } /// Specifies the logger that will be used for emitting log messages. Optional. /// /// - Note: This is not included when initalizing `UserSession`. Instead, it's added /// after the successful initalizing. - var logger: Logger? { get set } + var logger: Logger? { get } /// The number of times a request can be attempted before it's considered a failure. /// @@ -67,7 +67,7 @@ public protocol SessionConfiguration: Sendable { /// /// - Note: This is not included when initalizing `UserSession`. Instead, it's added /// after the successful initalizing. - var maxRetryCount: Int? { get set } + var maxRetryCount: Int? { get } /// The length of time to wait before attempting to retry a request. /// @@ -77,7 +77,7 @@ public protocol SessionConfiguration: Sendable { /// /// - Note: This is not included when initalizing `UserSession`. Instead, it's added /// after the successful initalizing. - var retryTimeDelay: TimeInterval? { get set } + var retryTimeDelay: TimeInterval? { get } /// Attempts to authenticate with the PDS using the `handle` and `appPassword`. @@ -249,7 +249,6 @@ extension SessionConfiguration { self.isActive = response.isActive self.status = status self.serviceEndpoint = serviceEndpoint - self.logger = await ATProtocolConfiguration.getLogger() } catch { throw error } @@ -294,7 +293,6 @@ extension SessionConfiguration { self.refreshToken = response.refreshToken self.didDocument = didDocument self.serviceEndpoint = serviceEndpoint - self.logger = await ATProtocolConfiguration.getLogger() } public mutating func getSession(by accessToken: String? = nil, authenticationFactorToken: String? = nil) async throws { @@ -342,7 +340,6 @@ extension SessionConfiguration { self.isActive = response.isActive self.status = status self.serviceEndpoint = serviceEndpoint - self.logger = await ATProtocolConfiguration.getLogger() } catch let apiError as ATAPIError { guard case .badRequest(let errorDetails) = apiError, errorDetails.error == "ExpiredToken" else { @@ -405,7 +402,6 @@ extension SessionConfiguration { self.isActive = response.isActive self.status = status self.serviceEndpoint = serviceEndpoint - self.logger = await ATProtocolConfiguration.getLogger() } catch let apiError as ATAPIError { // If the token expires, re-authenticate and try refreshing the token again. guard case .badRequest(let errorDetails) = apiError, From 8a981f111c0bac2f11763e257bf66a28fe5163b1 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Tue, 25 Feb 2025 14:54:58 -0500 Subject: [PATCH 18/34] Remove Sendable --- .../APIReference/SessionManager/SessionConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index f8d7117ddf..f1249e8949 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -9,7 +9,7 @@ import Foundation import Logging /// Defines the requirements for session configurations within ATProtoKit. -public protocol SessionConfiguration: Sendable { +public protocol SessionConfiguration { /// The user's handle within the AT Protocol. var handle: String { get set } From 1a1ae60fd4ddf061a3091b97f2303d2a1ff797b4 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Tue, 25 Feb 2025 14:56:06 -0500 Subject: [PATCH 19/34] Attach SessionConfiguration to ATProtocolConfiguration --- .../APIReference/SessionManager/ATProtocolConfiguration.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ATProtoKit/APIReference/SessionManager/ATProtocolConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/ATProtocolConfiguration.swift index 3bf8873815..7a9e3766b2 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/ATProtocolConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/ATProtocolConfiguration.swift @@ -9,7 +9,7 @@ import Foundation import Logging /// Manages authentication and session operations for the a user account in the ATProtocol. -public class ATProtocolConfiguration { +public class ATProtocolConfiguration: SessionConfiguration { /// The user's handle identifier in their account. public var handle: String From 444fd918f5c98a88e0c2e41499c3a8ee46861ba7 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 19:40:20 -0500 Subject: [PATCH 20/34] Fix lexicon model Made "labels" property optional. --- .../Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift index a3c8ecaad8..7f8355f887 100644 --- a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift +++ b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift @@ -51,13 +51,13 @@ extension AppBskyLexicon.Graph { public let avatarImageBlob: ComAtprotoLexicon.Repository.UploadBlobOutput? /// The user-defined labels for the list. Optional. - public let labels: ATUnion.ListLabelsUnion + public let labels: ATUnion.ListLabelsUnion? /// The date and time the list was created. public let createdAt: Date public init(purpose: ListPurpose, name: String, description: String?, descriptionFacets: [AppBskyLexicon.RichText.Facet]?, - avatarImageBlob: ComAtprotoLexicon.Repository.UploadBlobOutput?, labels: ATUnion.ListLabelsUnion, createdAt: Date) { + avatarImageBlob: ComAtprotoLexicon.Repository.UploadBlobOutput?, labels: ATUnion.ListLabelsUnion?, createdAt: Date) { self.purpose = purpose self.name = name self.description = description @@ -75,7 +75,7 @@ extension AppBskyLexicon.Graph { self.description = try container.decodeIfPresent(String.self, forKey: .description) self.descriptionFacets = try container.decodeIfPresent([AppBskyLexicon.RichText.Facet].self, forKey: .descriptionFacets) self.avatarImageBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .avatarImageBlob) - self.labels = try container.decode(ATUnion.ListLabelsUnion.self, forKey: .labels) + self.labels = try container.decodeIfPresent(ATUnion.ListLabelsUnion.self, forKey: .labels) self.createdAt = try container.decodeDate(forKey: .createdAt) } @@ -88,7 +88,7 @@ extension AppBskyLexicon.Graph { try container.truncatedEncodeIfPresent(self.description, forKey: .description, upToCharacterLength: 30) try container.encodeIfPresent(self.descriptionFacets, forKey: .descriptionFacets) try container.encodeIfPresent(self.avatarImageBlob, forKey: .avatarImageBlob) - try container.encode(self.labels, forKey: .labels) + try container.encodeIfPresent(self.labels, forKey: .labels) try container.encodeDate(self.createdAt, forKey: .createdAt) } From 55c6965d672638c5d72a5b867ccb79c4a734f8f7 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 19:40:42 -0500 Subject: [PATCH 21/34] Delete UpdateListRecord --- .../ListRecord/UpdateListRecord.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift new file mode 100644 index 0000000000..d31c53a0af --- /dev/null +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift @@ -0,0 +1,13 @@ +// +// UpdateListRecord.swift +// +// +// Created by Christopher Jr Riley on 2025-02-28. +// + +import Foundation + +extension ATProtoBluesky { + + +} From a05aa3b7721b110fa1b8e31a633e10e1449ee720 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 19:41:06 -0500 Subject: [PATCH 22/34] Tweak documentation --- .../Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift index 27dfbb6780..65efbc810e 100644 --- a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift +++ b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift @@ -429,12 +429,12 @@ extension AppBskyLexicon.Graph { /// [github]: https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/graph/defs.json public enum ListPurpose: String, Sendable, Codable { - /// An array of actors to apply an aggregate moderation action (mute/block) on. + /// A list of actors to apply an aggregate moderation action (mute/block) on. /// /// - Note: The documentation is taken directly from the lexicon itself. case modlist = "app.bsky.graph.defs#modlist" - /// An array of actors used for curation purposes such as list feeds or interaction gating. + /// A list of actors used for curation purposes such as list feeds or interaction gating. /// /// - Note: The documentation is taken directly from the lexicon itself. case curatelist = "app.bsky.graph.defs#curatelist" From 3266c112ec1f845683e1b31a7b6d546e433c639c Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 19:45:26 -0500 Subject: [PATCH 23/34] Fix lexicon model Fixed the truncation of "description" to 300 characters. --- .../Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift index 7f8355f887..be3691b491 100644 --- a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift +++ b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift @@ -85,7 +85,7 @@ extension AppBskyLexicon.Graph { try container.encode(self.purpose, forKey: .purpose) try container.encode(self.name, forKey: .name) try container.truncatedEncode(self.name, forKey: .name, upToCharacterLength: 64) - try container.truncatedEncodeIfPresent(self.description, forKey: .description, upToCharacterLength: 30) + try container.truncatedEncodeIfPresent(self.description, forKey: .description, upToCharacterLength: 300) try container.encodeIfPresent(self.descriptionFacets, forKey: .descriptionFacets) try container.encodeIfPresent(self.avatarImageBlob, forKey: .avatarImageBlob) try container.encodeIfPresent(self.labels, forKey: .labels) From ef4b4e34699d2072013d0b26c6b094bd2744c15e Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 21:05:38 -0500 Subject: [PATCH 24/34] Tweak documentation --- .../ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift index 0033d38cea..5265599a34 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift @@ -14,7 +14,7 @@ extension ATProtoBluesky { /// This can be used instead of creating your own method if you wish not to do so. /// /// ## Creating a Post - /// After you authenticate into Bluesky, you can create a post by using the `text` field: + /// After you authenticate into Bluesky, you can create a post by using the `text` argument: /// ```swift /// do { /// let postResult = try await atProtoBluesky.createPostRecord( From 9c2fba9fe3719436275975aef7ff8831faec9885 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 21:36:05 -0500 Subject: [PATCH 25/34] Add support for adding list records --- .../ListRecord/CreateListRecord.swift | 155 ++++++++++++++++++ .../ListRecord/DeleteListRecord.swift | 10 ++ 2 files changed, 165 insertions(+) diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift index 2c13170002..cdac9a5308 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift @@ -9,4 +9,159 @@ import Foundation extension ATProtoBluesky { + /// A convenience method to create a list record to the user account in Bluesky. + /// + /// This can be used instead of creating your own method if you wish not to do so. + /// + /// # Creating a List + /// + /// After you authenticate into Bluesky, you can create a post by using the `name` and + /// `listType` arguments: + /// + /// ```swift + /// do { + /// let listResult = try await atProtoBluesky.createListRecord( + /// named: "Book Authors", + /// ofType: .reference + /// ) + /// + /// print(listResult) + /// } catch { + /// throw error + /// } + /// ``` + /// + /// You can optionally add a description and avatar image for the list. + /// + /// - Note: Names can be up to 64 characters long. \ + /// \ + /// Descriptions can be up to 300 characters long.\ + /// \ + /// List avatar images can be either .jpg or .png and can be up to 1 MB large. + /// + /// # Types of Lists + /// + /// There are three types of lists that can be created: + /// - Moderation lists: These are lists where the user accounts listed will be muted + /// or blocked. + /// - Curated lists: These are lists where the user accounts listed are used for curation: + /// things like allowlists for interaction or regular feeds. + /// - Reference feeds: These are lists where the user accounts listed will be used as a + /// reference, such as with a starter pack. + /// + /// - Parameters: + /// - name: The name of the list. + /// - listType: The list's type. + /// - description: The list's description. Optional. Defaults to `nil`. + /// - listAvatarImage: The avatar image of the list. Optional. Defaults to `nil`. + /// - labels: An array of labels made by the user. Optional. Defaults to `nil`. + /// - creationDate: The date of the post record. Defaults to `Date.now`. + /// - recordKey: The record key of the collection. Optional. Defaults to `nil`. + /// - shouldValidate: Indicates whether the record should be validated. Optional. + /// Defaults to `true`. + /// - swapCommit: Swaps out an operation based on the CID. Optional. Defaults to `nil`. + /// - Returns: A strong reference, which contains the newly-created record's URI and CID hash. + public func createListRecord( + named name: String, + ofType listType: ListType, + description: String? = nil, + listAvatarImage: ATProtoTools.ImageQuery? = nil, + labels: ATUnion.ListLabelsUnion? = nil, + creationDate: Date = Date(), + recordKey: String? = nil, + shouldValidate: Bool? = true, + swapCommit: String? = nil + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let session else { + throw ATRequestPrepareError.missingActiveSession + } + + guard let sessionURL = session.pdsURL else { + throw ATRequestPrepareError.invalidPDS + } + + // listPurpose + let listPurpose: AppBskyLexicon.Graph.ListPurpose + switch listType { + case .moderation: + listPurpose = .modlist + case .curation: + listPurpose = .curatelist + case .reference: + listPurpose = .referencelist + } + + // name + // Truncate the number of characters to 64. + let nameText = name.truncated(toLength: 64) + + // description and descriptionFacets + var descriptionText: String? = nil + var descriptionFacets: [AppBskyLexicon.RichText.Facet]? = nil + + if let description = description { + // Truncate the number of characters to 300. + let truncatedDescriptionText = description.truncated(toLength: 300) + descriptionText = truncatedDescriptionText + + let facets = await ATFacetParser.parseFacets(from: truncatedDescriptionText, pdsURL: session.pdsURL ?? "https://bsky.social") + descriptionFacets = facets + } + + // listAvatarImage + var postEmbed: ATUnion.PostEmbedUnion? = nil + var avatarImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil + if let listAvatarImage = listAvatarImage { + postEmbed = try await uploadImages( + [listAvatarImage], + pdsURL: sessionURL, + accessToken: session.accessToken + ) + + switch postEmbed { + case .images(let imagesDefinition): + let avatarImageContainer = imagesDefinition + avatarImage = avatarImageContainer.images[0].imageBlob + default: + break + } + } + + let listRecord = AppBskyLexicon.Graph.ListRecord( + purpose: listPurpose, + name: name, + description: descriptionText, + descriptionFacets: descriptionFacets, + avatarImageBlob: avatarImage, + labels: labels, + createdAt: creationDate + ) + + do { + return try await atProtoKitInstance.createRecord( + repositoryDID: session.sessionDID, + collection: "app.bsky.graph.list", + recordKey: recordKey ?? nil, + shouldValidate: shouldValidate, + record: UnknownType.record(listRecord), + swapCommit: swapCommit ?? nil + ) + } catch { + throw error + } + } + + /// The list's type. + public enum ListType { + + /// Indicates the list is used for muting or blocking the list of user accounts. + case moderation + + /// Indicates the list is used for curation purposes, such as list feeds or + /// interaction gating. + case curation + + /// Indicates the list is used for reference purposes (such as within a starter pack). + case reference + } } diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/DeleteListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/DeleteListRecord.swift index d703032428..710542206d 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/DeleteListRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/DeleteListRecord.swift @@ -9,4 +9,14 @@ import Foundation extension ATProtoBluesky { + /// Deletes a list record. + /// + /// This can also be used to validate if a list record has been deleted. + /// + /// - Note: This can be either the URI of the list record, or the full record object itself. + /// + /// - Parameter record: The list record that needs to be deleted. + public func deleteListRecord(_ record: RecordIdentifier) async throws { + return try await deleteLikeRecord(record) + } } From 1c1f07105e5bf68df3ee1825665f47967f2e77ad Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 21:44:47 -0500 Subject: [PATCH 26/34] Tweak documentation --- .../PostRecord/CreatePostRecord.swift | 14 +++++++------- .../PostgateRecord/CreatePostgateRecord.swift | 2 +- .../ThreadgateRecord/CreateThreadgateRecord.swift | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift index 5265599a34..85eb0afc2f 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift @@ -13,7 +13,7 @@ extension ATProtoBluesky { /// /// This can be used instead of creating your own method if you wish not to do so. /// - /// ## Creating a Post + /// # Creating a Post /// After you authenticate into Bluesky, you can create a post by using the `text` argument: /// ```swift /// do { @@ -68,11 +68,11 @@ extension ATProtoBluesky { /// - Note: `startPostion` and `endPostion` must be UTF-8 values. /// /// - /// ## Adding Embedded Content + /// # Adding Embedded Content /// You can embed various kinds of content in your post, from media to external links, /// to other records. /// - /// ### Images + /// ## Images /// Use ``ATProtoTools/ImageQuery`` to add details to the image, such as /// alt text, then attach it to the post record. /// @@ -99,7 +99,7 @@ extension ATProtoBluesky { /// /// Up to four images can be attached to a post. All images need to be a .jpg format. /// - /// ### Videos + /// ## Videos /// Similar to images, you can add videos to a post. /// /// ```swift @@ -121,7 +121,7 @@ extension ATProtoBluesky { /// You can upload up to 25 videos per day and the 25 videos can't exceed a total of 500 MB /// for the day. /// - /// ### External Links + /// ## External Links /// You can attach a website card to the post. /// /// ```swift @@ -159,7 +159,7 @@ extension ATProtoBluesky { /// If there are any links in the post's text, the method will not convert it into a /// website card; you will need to manually achieve this. /// - /// ## Creating a Quote Post + /// # Creating a Quote Post /// Quote posts are also embeds: you simply need to embed the record's strong reference to it. /// /// ```swift @@ -180,7 +180,7 @@ extension ATProtoBluesky { /// Only one record can be embedded. This isn't limited to post records, though: you can embed /// any Bluesky-related record that you wish. /// - /// ## Creating a Reply + /// # Creating a Reply /// To create a reply, pass the reply reference of the post's reply reference to the post record. /// /// ```swift diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostgateRecord/CreatePostgateRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostgateRecord/CreatePostgateRecord.swift index f0f0d31dcd..2a82397f0f 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostgateRecord/CreatePostgateRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostgateRecord/CreatePostgateRecord.swift @@ -37,7 +37,7 @@ extension ATProtoBluesky { /// } /// ``` /// - /// ## Managing Embedding Post Options + /// # Managing Embedding Post Options /// /// You can detact the post from quote posts by using the `detachedEmbeddingURIs` argument. /// When doing so, Bluesky will display a "Removed by author" warning and the quote post will diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ThreadgateRecord/CreateThreadgateRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ThreadgateRecord/CreateThreadgateRecord.swift index ea0119c0e1..16700e2e5d 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ThreadgateRecord/CreateThreadgateRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ThreadgateRecord/CreateThreadgateRecord.swift @@ -21,7 +21,7 @@ extension ATProtoBluesky { /// After that, you can use the ``ComAtprotoLexicon/Repository/StrongReference/recordURI`` /// property as the value for the `postURI` argument. /// - /// ## Managing Allowlist Options + /// # Managing Allowlist Options /// /// With the `replyControls` argument, you can specifiy the specific retrictions you want /// for the post. From 11e62a06c0d2ccbe9b231b5ab2b3b5b49ab4353f Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 22:09:23 -0500 Subject: [PATCH 27/34] Add support for updating list records --- .../ListRecord/UpdateListRecord.swift | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift index d31c53a0af..bb3714e931 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift @@ -9,5 +9,121 @@ import Foundation extension ATProtoBluesky { + /// A convenience method to update a list record to the user account in Bluesky. + /// + /// This can be used instead of creating your own method if you wish not to do so. + /// + /// - Parameters: + /// - listURI: The URI of the post. + /// - name: The name of the list. + /// - listType: The list's type. + /// - description: The list's description. Optional. Defaults to `nil`. + /// - listAvatarImage: The avatar image of the list. Optional. Defaults to `nil`. + /// - labels: An array of labels made by the user. Optional. Defaults to `nil`. + /// - Returns: A strong reference, which contains the newly-created record's URI and CID hash. + public func updateListRecord( + listURI: String, + name: String, + listType: ListType, + description: String? = nil, + listAvatarImage: ATProtoTools.ImageQuery? = nil, + labels: ATUnion.ListLabelsUnion? = nil + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let session else { + throw ATRequestPrepareError.missingActiveSession + } + guard let sessionURL = session.pdsURL else { + throw ATRequestPrepareError.invalidPDS + } + + // Check to see if the list exists. + let uri = try ATProtoTools().parseURI(listURI) + + guard let record = try await atProtoKitInstance.getRepositoryRecord( + from: uri.repository, + collection: uri.collection, + recordKey: uri.recordKey + ).value?.getRecord(ofType: AppBskyLexicon.Graph.ListRecord.self) else { + throw ATProtoBlueskyError.postNotFound(message: "List record (\(listURI)) not found.") + } + + // listPurpose + let listPurpose: AppBskyLexicon.Graph.ListPurpose + switch listType { + case .moderation: + listPurpose = .modlist + case .curation: + listPurpose = .curatelist + case .reference: + listPurpose = .referencelist + } + + // name + // Truncate the number of characters to 64. + let nameText = name.truncated(toLength: 64) + + // description and descriptionFacets + var descriptionText: String? = nil + var descriptionFacets: [AppBskyLexicon.RichText.Facet]? = nil + + if let description = description { + // Truncate the number of characters to 300. + let truncatedDescriptionText = description.truncated(toLength: 300) + descriptionText = truncatedDescriptionText + + let facets = await ATFacetParser.parseFacets(from: truncatedDescriptionText, pdsURL: session.pdsURL ?? "https://bsky.social") + descriptionFacets = facets + } + + // listAvatarImage + var postEmbed: ATUnion.PostEmbedUnion? = nil + var avatarImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil + if let listAvatarImage = listAvatarImage { + postEmbed = try await uploadImages( + [listAvatarImage], + pdsURL: sessionURL, + accessToken: session.accessToken + ) + + switch postEmbed { + case .images(let imagesDefinition): + let avatarImageContainer = imagesDefinition + avatarImage = avatarImageContainer.images[0].imageBlob + default: + break + } + } + + let listRecord = AppBskyLexicon.Graph.ListRecord( + purpose: listPurpose, + name: nameText, + description: descriptionText, + descriptionFacets: descriptionFacets, + avatarImageBlob: avatarImage, + labels: labels, + createdAt: Date() + ) + + do { + let uri = try ATProtoTools().parseURI(listURI) + + guard try await atProtoKitInstance.getRepositoryRecord( + from: uri.repository, + collection: uri.collection, + recordKey: uri.recordKey + ).value != nil else { + throw ATProtoBlueskyError.postNotFound(message: "List record (\(listURI)) not found.") + } + + return try await atProtoKitInstance.putRecord( + repository: session.sessionDID, + collection: "app.bsky.graph.list", + recordKey: uri.recordKey, + record: UnknownType.record(listRecord) + ) + } catch { + throw error + } + } } From df81ebd296e74ff6f60a668e6578e13e299be348 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 22:29:16 -0500 Subject: [PATCH 28/34] Add initializer for ProfileRecord --- .../app.bsky/Actor/AppBskyActorProfile.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift index ae1c4f13bb..89c38fb507 100644 --- a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift +++ b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift @@ -72,6 +72,20 @@ extension AppBskyLexicon.Actor { /// The date and time the profile was created. Optional. public let createdAt: Date? + public init(displayName: String?, description: String?, avatarBlob: ComAtprotoLexicon.Repository.BlobContainer?, + bannerBlob: ComAtprotoLexicon.Repository.BlobContainer?, labels: [ComAtprotoLexicon.Label.SelfLabelsDefinition]?, + joinedViaStarterPack: ComAtprotoLexicon.Repository.StrongReference?, pinnedPost: ComAtprotoLexicon.Repository.StrongReference?, + createdAt: Date?) { + self.displayName = displayName + self.description = description + self.avatarBlob = avatarBlob + self.bannerBlob = bannerBlob + self.labels = labels + self.joinedViaStarterPack = joinedViaStarterPack + self.pinnedPost = pinnedPost + self.createdAt = createdAt + } + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) From b391bad06516024c8e3973cbcb2ef5f7c6c596eb Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 23:20:47 -0500 Subject: [PATCH 29/34] Fix documentation --- .../ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift index 85eb0afc2f..1bc6e72d6b 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift @@ -81,7 +81,7 @@ extension ATProtoBluesky { /// let image = ATProtoTools.ImageQuery( /// imageData: Data(contentsOf: "/path/to/file/cat.jpg"), /// fileName: "cat.jpg", - /// altText: "A cat looking annoyed, waring a hat." + /// altText: "A cat looking annoyed, wearing a hat." /// ) /// /// let postResult = try await atProtoBluesky.createPostRecord( From 320af2c67d69245c4ee3b2fe0fd559bfe50b6860 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 23:22:15 -0500 Subject: [PATCH 30/34] Fix lexicon model - Fixed documentation. - Changed "avatarBlob" and "bannerBlob" type to "ComAtprotoLexicon.Repository.UploadBlobOutput?" --- .../app.bsky/Actor/AppBskyActorProfile.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift index 89c38fb507..c2ba40508d 100644 --- a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift +++ b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift @@ -37,7 +37,7 @@ extension AppBskyLexicon.Actor { /// description text." public let description: String? - /// The avatar image URL of the profile. Optional. + /// The avatar image blob of the profile. Optional. /// /// - Note: Only JPEGs and PNGs are accepted. /// @@ -45,9 +45,9 @@ extension AppBskyLexicon.Actor { /// /// - Note: According to the AT Protocol specifications: "Small image to be displayed next /// to posts from account. AKA, 'profile picture'" - public let avatarBlob: ComAtprotoLexicon.Repository.BlobContainer? + public let avatarBlob: ComAtprotoLexicon.Repository.UploadBlobOutput? - /// The banner image URL of the profile. Optional. + /// The banner image blob of the profile. Optional. /// /// - Note: Only JPEGs and PNGs are accepted. /// @@ -55,7 +55,7 @@ extension AppBskyLexicon.Actor { /// /// - Note: According to the AT Protocol specifications: "Larger horizontal image to /// display behind profile view." - public let bannerBlob: ComAtprotoLexicon.Repository.BlobContainer? + public let bannerBlob: ComAtprotoLexicon.Repository.UploadBlobOutput? /// An array of user-defined labels. Optional. /// @@ -72,8 +72,8 @@ extension AppBskyLexicon.Actor { /// The date and time the profile was created. Optional. public let createdAt: Date? - public init(displayName: String?, description: String?, avatarBlob: ComAtprotoLexicon.Repository.BlobContainer?, - bannerBlob: ComAtprotoLexicon.Repository.BlobContainer?, labels: [ComAtprotoLexicon.Label.SelfLabelsDefinition]?, + public init(displayName: String?, description: String?, avatarBlob: ComAtprotoLexicon.Repository.UploadBlobOutput?, + bannerBlob: ComAtprotoLexicon.Repository.UploadBlobOutput?, labels: [ComAtprotoLexicon.Label.SelfLabelsDefinition]?, joinedViaStarterPack: ComAtprotoLexicon.Repository.StrongReference?, pinnedPost: ComAtprotoLexicon.Repository.StrongReference?, createdAt: Date?) { self.displayName = displayName @@ -91,8 +91,8 @@ extension AppBskyLexicon.Actor { self.displayName = try container.decodeIfPresent(String.self, forKey: .displayName) self.description = try container.decodeIfPresent(String.self, forKey: .description) - self.avatarBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .avatarBlob) - self.bannerBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .bannerBlob) + self.avatarBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .avatarBlob) + self.bannerBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .bannerBlob) self.labels = try container.decodeIfPresent([ComAtprotoLexicon.Label.SelfLabelsDefinition].self, forKey: .labels) self.joinedViaStarterPack = try container.decodeIfPresent(ComAtprotoLexicon.Repository.StrongReference.self, forKey: .joinedViaStarterPack) self.pinnedPost = try container.decodeIfPresent(ComAtprotoLexicon.Repository.StrongReference.self, forKey: .pinnedPost) From 2372ed39f6d6da1591764e6859b7ad2ae6ce481c Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 23:24:52 -0500 Subject: [PATCH 31/34] Tweak createListRecord - Fixed documentation. - Removed unnecessary variable. --- .../ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift index cdac9a5308..b0fa6932a2 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift @@ -15,7 +15,7 @@ extension ATProtoBluesky { /// /// # Creating a List /// - /// After you authenticate into Bluesky, you can create a post by using the `name` and + /// After you authenticate into Bluesky, you can create a list by using the `name` and /// `listType` arguments: /// /// ```swift @@ -109,10 +109,9 @@ extension ATProtoBluesky { } // listAvatarImage - var postEmbed: ATUnion.PostEmbedUnion? = nil var avatarImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil if let listAvatarImage = listAvatarImage { - postEmbed = try await uploadImages( + let postEmbed = try await uploadImages( [listAvatarImage], pdsURL: sessionURL, accessToken: session.accessToken From 37b49826646416721d2247bfbdffc98621911399 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Fri, 28 Feb 2025 23:47:23 -0500 Subject: [PATCH 32/34] Add helper method for creating strong references --- .../ATProtoKit/Utilities/ATProtoTools.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Sources/ATProtoKit/Utilities/ATProtoTools.swift b/Sources/ATProtoKit/Utilities/ATProtoTools.swift index 75e8689cb9..0dc8f7dc0e 100644 --- a/Sources/ATProtoKit/Utilities/ATProtoTools.swift +++ b/Sources/ATProtoKit/Utilities/ATProtoTools.swift @@ -357,6 +357,32 @@ public class ATProtoTools { } } + /// Creates a ``ComAtprotoLexicon/Repository/StrongReference`` from a URI. + /// + /// - Parameters: + /// - recordURI: The URI of the record. + /// - pdsURL: The URL of the Personal Data Server (PDS). Optional. + /// Defaults to `https://api.bsky.app`. + /// - Returns: A strong reference of the record. + public static func createStrongReference( + from recordURI: String, + pdsURL: String = "https://api.bsky.app" + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + let query = try ATProtoTools().parseURI(recordURI) + + do { + let record = try await ATProtoKit(pdsURL: pdsURL, canUseBlueskyRecords: false).getRepositoryRecord( + from: query.repository, + collection: query.collection, + recordKey: query.recordKey + ) + + return ComAtprotoLexicon.Repository.StrongReference(recordURI: record.uri, cidHash: record.cid) + } catch { + throw error + } + } + /// Generates a random alphanumeric string with a specified length /// /// A maximum of 25 characters can be created for the string. This is useful for generating From 9b1e6dd670574821d399320512fcffc07486ffe4 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sat, 1 Mar 2025 00:03:04 -0500 Subject: [PATCH 33/34] Tweak createListRecord, updateListRecord - Removed extra list record check. - Fixed the value assignment of the record's arguments. --- .../ListRecord/CreateListRecord.swift | 2 +- .../ListRecord/UpdateListRecord.swift | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift index b0fa6932a2..a3375e16c5 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift @@ -128,7 +128,7 @@ extension ATProtoBluesky { let listRecord = AppBskyLexicon.Graph.ListRecord( purpose: listPurpose, - name: name, + name: nameText, description: descriptionText, descriptionFacets: descriptionFacets, avatarImageBlob: avatarImage, diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift index bb3714e931..d672b0f7fb 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift @@ -37,17 +37,6 @@ extension ATProtoBluesky { throw ATRequestPrepareError.invalidPDS } - // Check to see if the list exists. - let uri = try ATProtoTools().parseURI(listURI) - - guard let record = try await atProtoKitInstance.getRepositoryRecord( - from: uri.repository, - collection: uri.collection, - recordKey: uri.recordKey - ).value?.getRecord(ofType: AppBskyLexicon.Graph.ListRecord.self) else { - throw ATProtoBlueskyError.postNotFound(message: "List record (\(listURI)) not found.") - } - // listPurpose let listPurpose: AppBskyLexicon.Graph.ListPurpose switch listType { From d75284e9b16141515ff1db0721a2869c81e8ab56 Mon Sep 17 00:00:00 2001 From: Christopher Jr Riley Date: Sat, 1 Mar 2025 00:12:46 -0500 Subject: [PATCH 34/34] Add profile record support in ATProtoBluesky --- .../ProfileRecord/CreateProfileRecord.swift | 219 ++++++++++++++++++ .../ProfileRecord/DeleteProfileRecord.swift | 22 ++ .../ProfileRecord/UpdateProfileRecord.swift | 12 + 3 files changed, 253 insertions(+) create mode 100644 Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/CreateProfileRecord.swift create mode 100644 Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/DeleteProfileRecord.swift create mode 100644 Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/UpdateProfileRecord.swift diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/CreateProfileRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/CreateProfileRecord.swift new file mode 100644 index 0000000000..9d5f961cb0 --- /dev/null +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/CreateProfileRecord.swift @@ -0,0 +1,219 @@ +// +// CreateProfileRecord.swift +// +// +// Created by Christopher Jr Riley on 2025-02-28. +// + +import Foundation + +extension ATProtoBluesky { + + /// A convenience method to create a profile record to the user account in Bluesky. + /// + /// This can be used instead of creating your own method if you wish not to do so. + /// + /// # Creating a Profile + /// + /// After you authenticate into Bluesky, you can create a profile. You don't have to enter + /// anything: Bluesky will still accept it: + /// ```swift + /// do { + /// let profileResult = try await atProtoBluesky.createProfileRecord() + /// + /// print(profileResult) + /// } catch { + /// throw error + /// } + /// ``` + /// + /// # Adding Details + /// + /// In most cases, however, you will want to add different details. You can enter in the + /// display name and description using the `displayName` and `description` + /// arguments respectively: + /// + /// ```swift + /// do { + /// let profileResult = try await atProtoBluesky.createProfileRecord( + /// displayName: "Alexy Carter", + /// description: "Big fan of music, movies, and weekend road trips. Just trying to enjoy life one day at a time." + /// ) + /// + /// print(profileResult) + /// } catch { + /// throw error + /// } + /// ``` + /// - Note: You can have up to 64 characters for the display name and up to 300 characters for + /// the description. + /// + /// You can also add the images for profile pictures and banners: + /// ```swift + /// do { + /// let profileResult = try await atProtoBluesky.createProfileRecord( + /// displayName: "Alexy Carter", + /// description: "Big fan of music, movies, and weekend road trips. Just trying to enjoy life one day at a time.", + /// avatarImage: ATProtoTools.ImageQuery( + /// imageData: Data(contentsOf: "/path/to/file/alexycarter_avatar.jpg"), + /// fileName: "alexycarter_profile.jpg", + /// altText: "Image of me with glasses, with my dog." + /// ), + /// bannerImage: ATProtoTools.ImageQuery( + /// imageData: Data(contentsOf: "/path/to/file/alexycarter_banner.jpg"), + /// fileName: alexycarter_banner.jpg, + /// altText: "A baseball stadium." + /// ) + /// ) + /// + /// print(profileResult) + /// } catch { + /// throw error + /// } + /// ``` + /// + /// - Note: Both the avatar and banner images can be up to 1 MB in size. You can upload either + /// .jpg or .png. + /// + /// # Pinning and Unpinning Posts + /// + /// If you want to pin a post, create a strong reference and attach it to the + /// `pinnedPost` argument. The easiest way to do this is to get the URI of the post, then use + /// ``ATProtoTools/createStrongReference(from:pdsURL:)``: + /// + /// ```swift + /// do { + /// let pinnedPostStrongReference = ATProtoTools.createStrongReference(from: uri) + /// let profileResult = try await atProtoBluesky.createProfileRecord( + /// displayName: "Alexy Carter", + /// description: "Big fan of music, movies, and weekend road trips. Just trying to enjoy life one day at a time.", + /// avatarImage: ATProtoTools.ImageQuery( + /// imageData: Data(contentsOf: "/path/to/file/alexycarter_avatar.jpg"), + /// fileName: "alexycarter_profile.jpg", + /// altText: "Image of me with glasses, with my dog." + /// ), + /// bannerImage: ATProtoTools.ImageQuery( + /// imageData: Data(contentsOf: "/path/to/file/alexycarter_banner.jpg"), + /// fileName: alexycarter_banner.jpg, + /// altText: "A baseball stadium." + /// ), + /// pinnedPost: pinnedPostStrongReference + /// ) + /// + /// print(profileResult) + /// } catch { + /// throw error + /// } + /// ``` + /// + /// If you want to unpin the post, simply update the profile record and set + /// `pinnedPost` to `nil`. + /// + /// - Parameters: + /// - displayName: A display name for the profile. Optional. Defaults to `nil`. + /// - description: A description for the profile. Optional. Defaults to `nil`. + /// - avatarImage: An image used for the profile picture. Optional. Defaults to `nil`. + /// - bannerImage: An image used for the banner image. Optional,Defaults to `nil`. + /// - labels: An array of user-defined labels. Optional. Defaults to `nil`. + /// - joinedViaStarterPack: A strong reference to the starter pack the user used to + /// join Bluesky. + /// - pinnedPost: A strong reference to a post the user account has. Optional. + /// Defaults to `nil`. + /// - recordKey: The record key of the collection. Optional. Defaults to `nil`. + /// - shouldValidate: Indicates whether the record should be validated. Optional. + /// Defaults to `true`. + /// - swapCommit: Swaps out an operation based on the CID. Optional. Defaults to `nil`. + /// - Returns: A strong reference, which contains the newly-created record's URI and CID hash. + public func createProfileRecord( + with displayName: String? = nil, + description: String? = nil, + avatarImage: ATProtoTools.ImageQuery? = nil, + bannerImage: ATProtoTools.ImageQuery? = nil, + labels: [ComAtprotoLexicon.Label.SelfLabelsDefinition]? = nil, + joinedViaStarterPack: ComAtprotoLexicon.Repository.StrongReference? = nil, + pinnedPost: ComAtprotoLexicon.Repository.StrongReference? = nil, + recordKey: String? = nil, + shouldValidate: Bool? = true, + swapCommit: String? = nil + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let session else { + throw ATRequestPrepareError.missingActiveSession + } + + guard let sessionURL = session.pdsURL else { + throw ATRequestPrepareError.invalidPDS + } + + // displayText + var displayNameText: String? = nil + if let displayName = displayName { + displayNameText = displayName.truncated(toLength: 64) + } + + // description + var descriptionText: String? = nil + if let description = description { + descriptionText = description.truncated(toLength: 256) + } + + // avatarImage + var profileAvatarImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil + if let avatarImage = avatarImage { + let postEmbed = try await uploadImages( + [avatarImage], + pdsURL: sessionURL, + accessToken: session.accessToken + ) + + switch postEmbed { + case .images(let imagesDefinition): + let avatarImageContainer = imagesDefinition + profileAvatarImage = avatarImageContainer.images[0].imageBlob + default: + break + } + } + + // bannerImage + var profileBannerImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil + if let bannerImage = bannerImage { + let postEmbed = try await uploadImages( + [bannerImage], + pdsURL: sessionURL, + accessToken: session.accessToken + ) + + switch postEmbed { + case .images(let imagesDefinition): + let bannerImageContainer = imagesDefinition + profileBannerImage = bannerImageContainer.images[0].imageBlob + default: + break + } + } + + let profileRecord = AppBskyLexicon.Actor.ProfileRecord( + displayName: displayNameText, + description: descriptionText, + avatarBlob: profileAvatarImage, + bannerBlob: profileBannerImage, + labels: labels, + joinedViaStarterPack: joinedViaStarterPack, + pinnedPost: pinnedPost, + createdAt: Date() + ) + + do { + return try await atProtoKitInstance.createRecord( + repositoryDID: session.sessionDID, + collection: "app.bsky.actor.profile", + recordKey: recordKey ?? nil, + shouldValidate: shouldValidate, + record: UnknownType.record(profileRecord), + swapCommit: swapCommit ?? nil + ) + } catch { + throw error + } + } +} diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/DeleteProfileRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/DeleteProfileRecord.swift new file mode 100644 index 0000000000..78ce99527f --- /dev/null +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/DeleteProfileRecord.swift @@ -0,0 +1,22 @@ +// +// DeleteProfileRecord.swift +// +// +// Created by Christopher Jr Riley on 2025-02-28. +// + +import Foundation + +extension ATProtoBluesky { + + /// Deletes a profile record. + /// + /// This can also be used to validate if a profile record has been deleted. + /// + /// - Note: This can be either the URI of the profile record, or the full record object itself. + /// + /// - Parameter record: The profile record that needs to be deleted. + public func deleteProfileRecord(_ record: RecordIdentifier) async throws { + return try await deleteActionRecord(record) + } +} diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/UpdateProfileRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/UpdateProfileRecord.swift new file mode 100644 index 0000000000..72237d1506 --- /dev/null +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/UpdateProfileRecord.swift @@ -0,0 +1,12 @@ +// +// UpdateProfileRecord.swift +// +// +// Created by Christopher Jr Riley on 2025-02-28. +// + +import Foundation + +extension ATProtoBluesky { + +}