diff --git a/Sources/NostrSDK/Events/MetadataEvent.swift b/Sources/NostrSDK/Events/MetadataEvent.swift index f29910e..e711cd7 100644 --- a/Sources/NostrSDK/Events/MetadataEvent.swift +++ b/Sources/NostrSDK/Events/MetadataEvent.swift @@ -8,41 +8,59 @@ import Foundation /// An object that describes a user. +/// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/master/01.md#kinds) public struct UserMetadata: Codable { /// The user's name. public let name: String? /// The user's display name. - /// > Warning: This property is not part of the Nostr specifications. + /// > Note: [NIP-24 Extra metadata fields and tags](https://github.com/nostr-protocol/nips/blob/master/24.md#kind-0) public let displayName: String? /// The user's description of themself. public let about: String? /// The user's website address. + /// > Note: [NIP-24 Extra metadata fields and tags](https://github.com/nostr-protocol/nips/blob/master/24.md#kind-0) public let website: URL? /// The user's Nostr address. /// - /// > Note: [NIP-05 Specification](https://github.com/nostr-protocol/nips/blob/master/05.md#nip-05). + /// > Note: [NIP-05 Specification](https://github.com/nostr-protocol/nips/blob/master/05.md#finding-users-from-their-nip-05-identifier). public let nostrAddress: String? /// A URL to retrieve the user's picture. public let pictureURL: URL? /// A URL to retrieve the user's banner image. + /// > Note: [NIP-24 Extra metadata fields and tags](https://github.com/nostr-protocol/nips/blob/master/24.md#kind-0) public let bannerPictureURL: URL? - + + /// A boolean to clarify that the content is entirely or partially the result of automation, such as with chatbots or newsfeeds. + /// > Note: [NIP-24 Extra metadata fields and tags](https://github.com/nostr-protocol/nips/blob/master/24.md#kind-0) + public let isBot: Bool? + + /// The user's LUD-06 Lightning URL (LNURL). + /// > Note: [NIP-57 Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md#protocol-flow) + public let lightningURLString: String? + + /// The user's LUD-16 Lightning address. + /// > Note: [NIP-57 Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md#protocol-flow) + public let lightningAddress: String? + enum CodingKeys: String, CodingKey { case name, about, website case nostrAddress = "nip05" case pictureURL = "picture" case bannerPictureURL = "banner" case displayName = "display_name" + case isBot = "bot" + case lightningURLString = "lud06" + case lightningAddress = "lud16" } - public init(name: String?, displayName: String?, about: String?, website: URL?, nostrAddress: String?, pictureURL: URL?, bannerPictureURL: URL?) { + public init(name: String? = nil, displayName: String? = nil, about: String? = nil, website: URL? = nil, nostrAddress: String? = nil, pictureURL: URL? = nil, bannerPictureURL: URL? = nil, isBot: Bool? = nil, lightningURLString: String? = nil, lightningAddress: String? = nil) { self.name = name self.displayName = displayName self.about = about @@ -50,6 +68,9 @@ public struct UserMetadata: Codable { self.nostrAddress = nostrAddress self.pictureURL = pictureURL self.bannerPictureURL = bannerPictureURL + self.isBot = isBot + self.lightningURLString = lightningURLString + self.lightningAddress = lightningAddress } public init(from decoder: Decoder) throws { @@ -61,12 +82,15 @@ public struct UserMetadata: Codable { nostrAddress = try container.decodeIfPresent(String.self, forKey: .nostrAddress) pictureURL = try? container.decodeIfPresent(URL.self, forKey: .pictureURL) bannerPictureURL = try? container.decodeIfPresent(URL.self, forKey: .bannerPictureURL) + isBot = try? container.decodeIfPresent(Bool.self, forKey: .isBot) + lightningURLString = try? container.decodeIfPresent(String.self, forKey: .lightningURLString) + lightningAddress = try? container.decodeIfPresent(String.self, forKey: .lightningAddress) } } /// An event that contains a user profile. /// -/// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/b503f8a92b22be3037b8115fe3e644865a4fa155/01.md#basic-event-kinds) +/// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/master/01.md#kinds) public final class MetadataEvent: NostrEvent, CustomEmojiInterpreting, NonParameterizedReplaceableEvent { public required init(from decoder: Decoder) throws { @@ -103,17 +127,31 @@ public final class MetadataEvent: NostrEvent, CustomEmojiInterpreting, NonParame public extension EventCreating { /// Creates a ``MetadataEvent`` (kind 0) and signs it with the provided ``Keypair``. + /// /// - Parameters: - /// - userMetadata: The metadata to set. + /// - userMetadata: The ``UserMetadata`` to set. + /// - rawUserMetadata: The dictionary of raw metadata to set that can contain fields unknown to any implemented NIPs. /// - customEmojis: The custom emojis to emojify with if the matching shortcodes are found in the name or about fields. /// - keypair: The Keypair to sign with. /// - Returns: The signed ``MetadataEvent``. /// - /// See [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) - func metadataEvent(withUserMetadata userMetadata: UserMetadata, customEmojis: [CustomEmoji] = [], signedBy keypair: Keypair) throws -> MetadataEvent { - let metadataAsData = try JSONEncoder().encode(userMetadata) - let metadataAsString = String(decoding: metadataAsData, as: UTF8.self) + /// > Note: If `rawUserMetadata` has fields that conflict with `userMetadata`, `userMetadata` fields take precedence. + /// + /// > Note: [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) + func metadataEvent(withUserMetadata userMetadata: UserMetadata, rawUserMetadata: [String: Any] = [:], customEmojis: [CustomEmoji] = [], signedBy keypair: Keypair) throws -> MetadataEvent { + let userMetadataAsData = try JSONEncoder().encode(userMetadata) + + let allUserMetadataAsData: Data + if rawUserMetadata.isEmpty { + allUserMetadataAsData = userMetadataAsData + } else { + var userMetadataAsDictionary = try JSONSerialization.jsonObject(with: userMetadataAsData, options: []) as? [String: Any] ?? [:] + userMetadataAsDictionary.merge(rawUserMetadata) { (current, _) in current } + allUserMetadataAsData = try JSONSerialization.data(withJSONObject: userMetadataAsDictionary, options: .sortedKeys) + } + + let allUserMetadataAsString = String(decoding: allUserMetadataAsData, as: UTF8.self) let customEmojiTags = customEmojis.map { $0.tag } - return try MetadataEvent(content: metadataAsString, tags: customEmojiTags, signedBy: keypair) + return try MetadataEvent(content: allUserMetadataAsString, tags: customEmojiTags, signedBy: keypair) } } diff --git a/Tests/NostrSDKTests/Events/MetadataEventTests.swift b/Tests/NostrSDKTests/Events/MetadataEventTests.swift index ce391f1..613f62f 100644 --- a/Tests/NostrSDKTests/Events/MetadataEventTests.swift +++ b/Tests/NostrSDKTests/Events/MetadataEventTests.swift @@ -17,7 +17,18 @@ final class MetadataEventTests: XCTestCase, EventCreating, EventVerifying, Fixtu website: URL(string: "https://github.com/nostr-sdk/nostr-sdk-ios"), nostrAddress: "test@nostr.com", pictureURL: URL(string: "https://nostrsdk.com/picture.png"), - bannerPictureURL: URL(string: "https://nostrsdk.com/banner.png")) + bannerPictureURL: URL(string: "https://nostrsdk.com/banner.png"), + isBot: true, + lightningURLString: "LNURL1234567890", + lightningAddress: "satoshi@bitcoin.org") + + let rawUserMetadata: [String: Any] = [ + "foo": "string", + "bool": true, + "number": 123, + "name": "This field should be ignored.", + "lud16": "should@be.ignored" + ] let ostrichImageURL = try XCTUnwrap(URL(string: "https://nostrsdk.com/ostrich.png")) let appleImageURL = try XCTUnwrap(URL(string: "https://nostrsdk.com/apple.png")) @@ -31,7 +42,7 @@ final class MetadataEventTests: XCTestCase, EventCreating, EventVerifying, Fixtu Tag(name: .emoji, value: "apple", otherParameters: ["https://nostrsdk.com/apple.png"]) ] - let event = try metadataEvent(withUserMetadata: meta, customEmojis: customEmojis, signedBy: Keypair.test) + let event = try metadataEvent(withUserMetadata: meta, rawUserMetadata: rawUserMetadata, customEmojis: customEmojis, signedBy: Keypair.test) let expectedReplaceableEventCoordinates = try XCTUnwrap(EventCoordinates(kind: .metadata, pubkey: Keypair.test.publicKey)) XCTAssertEqual(event.userMetadata?.name, "Nostr SDK Test :ostrich:") @@ -41,6 +52,14 @@ final class MetadataEventTests: XCTestCase, EventCreating, EventVerifying, Fixtu XCTAssertEqual(event.userMetadata?.nostrAddress, "test@nostr.com") XCTAssertEqual(event.userMetadata?.pictureURL, URL(string: "https://nostrsdk.com/picture.png")) XCTAssertEqual(event.userMetadata?.bannerPictureURL, URL(string: "https://nostrsdk.com/banner.png")) + XCTAssertEqual(event.userMetadata?.isBot, true) + XCTAssertEqual(event.userMetadata?.lightningURLString, "LNURL1234567890") + XCTAssertEqual(event.userMetadata?.lightningAddress, "satoshi@bitcoin.org") + XCTAssertEqual(event.rawUserMetadata["foo"] as? String, "string") + XCTAssertEqual(event.rawUserMetadata["bool"] as? Bool, true) + XCTAssertEqual(event.rawUserMetadata["number"] as? Int, 123) + XCTAssertEqual(event.rawUserMetadata["name"] as? String, "Nostr SDK Test :ostrich:") + XCTAssertEqual(event.rawUserMetadata["lud16"] as? String, "satoshi@bitcoin.org") XCTAssertEqual(event.customEmojis, customEmojis) XCTAssertEqual(event.replaceableEventCoordinates(relayURL: nil), expectedReplaceableEventCoordinates) XCTAssertEqual(event.tags, customEmojiTags) @@ -68,6 +87,7 @@ final class MetadataEventTests: XCTestCase, EventCreating, EventVerifying, Fixtu XCTAssertEqual(event.rawUserMetadata["nip05"] as? String, "cameri@elder.nostr.land") XCTAssertEqual(event.rawUserMetadata["picture"] as? String, "https://nostr.build/i/9396d5cd901304726883aea7363543f121e1d53964dd3149cadecd802608aebe.jpg") XCTAssertEqual(event.rawUserMetadata["banner"] as? String, "https://nostr.build/i/nostr.build_90a51a2e50c9f42288260d01b3a2a4a1c7a9df085423abad7809e76429da7cdc.gif") + XCTAssertEqual(event.rawUserMetadata["lud16"] as? String, "cameri@getalby.com") // access metadata properties from decoded object let userMetadata = try XCTUnwrap(event.userMetadata) @@ -77,6 +97,7 @@ final class MetadataEventTests: XCTestCase, EventCreating, EventVerifying, Fixtu XCTAssertEqual(userMetadata.nostrAddress, "cameri@elder.nostr.land") XCTAssertEqual(userMetadata.pictureURL, URL(string: "https://nostr.build/i/9396d5cd901304726883aea7363543f121e1d53964dd3149cadecd802608aebe.jpg")) XCTAssertEqual(userMetadata.bannerPictureURL, URL(string: "https://nostr.build/i/nostr.build_90a51a2e50c9f42288260d01b3a2a4a1c7a9df085423abad7809e76429da7cdc.gif")) + XCTAssertEqual(userMetadata.lightningAddress, "cameri@getalby.com") } func testDecodeMetadataWithEmptyWebsite() throws {