From bf81d0b7ac66328804ade998ba0c8c37aab0b812 Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Sun, 3 Nov 2024 01:33:44 +0100 Subject: [PATCH] Add support for NIP-32 Labeling --- README.md | 2 +- Sources/NostrSDK/EventKind.swift | 10 +- Sources/NostrSDK/Events/LabelEvent.swift | 230 ++++++++++++++++++ Sources/NostrSDK/Events/NostrEvent.swift | 4 +- Sources/NostrSDK/Events/Tags/PubkeyTag.swift | 85 +++++++ .../Events/LabelEventTests.swift | 63 +++++ .../Events/NostrEventTests.swift | 30 ++- 7 files changed, 419 insertions(+), 5 deletions(-) create mode 100644 Sources/NostrSDK/Events/LabelEvent.swift create mode 100644 Sources/NostrSDK/Events/Tags/PubkeyTag.swift create mode 100644 Tests/NostrSDKTests/Events/LabelEventTests.swift diff --git a/README.md b/README.md index c2abc44..3d35f21 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The following [NIPs](https://github.com/nostr-protocol/nips) are implemented: - [ ] [NIP-29: Relay-based Groups](https://github.com/nostr-protocol/nips/blob/master/29.md) - [x] [NIP-30: Custom Emoji](https://github.com/nostr-protocol/nips/blob/master/30.md) - [x] [NIP-31: Dealing with Unknown Events](https://github.com/nostr-protocol/nips/blob/master/31.md) -- [ ] [NIP-32: Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md) +- [x] [NIP-32: Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md) - [ ] [NIP-34: `git` stuff](https://github.com/nostr-protocol/nips/blob/master/34.md) - [ ] [NIP-35: Torrents](https://github.com/nostr-protocol/nips/blob/master/35.md) - [ ] [NIP-36: Sensitive Content](https://github.com/nostr-protocol/nips/blob/master/36.md) diff --git a/Sources/NostrSDK/EventKind.swift b/Sources/NostrSDK/EventKind.swift index 0c49f95..adf1737 100644 --- a/Sources/NostrSDK/EventKind.swift +++ b/Sources/NostrSDK/EventKind.swift @@ -86,7 +86,12 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha /// /// See [NIP-56](https://github.com/nostr-protocol/nips/blob/b4cdc1a73d415c79c35655fa02f5e55cd1f2a60c/56.md#nip-56). case report - + + /// This kind of event attaches labels to label targets. This allow sof rlabeling of events, people, relays, or topics. + /// + /// See [NIP-32 Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md). + case label + /// This kind of event contains a list of things the user does not want to see, such as pubkeys, hashtags, words, and event ids (threads). /// /// See [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists) @@ -148,6 +153,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha .genericRepost, .giftWrap, .report, + .label, .muteList, .relayListMetadata, .bookmarksList, @@ -182,6 +188,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha case .genericRepost: return 16 case .giftWrap: return 1059 case .report: return 1984 + case .label: return 1985 case .muteList: return 10000 case .relayListMetadata: return 10002 case .bookmarksList: return 10003 @@ -210,6 +217,7 @@ public enum EventKind: RawRepresentable, CaseIterable, Codable, Equatable, Hasha case .genericRepost: return GenericRepostEvent.self case .giftWrap: return GiftWrapEvent.self case .report: return ReportEvent.self + case .label: return LabelEvent.self case .muteList: return MuteListEvent.self case .relayListMetadata: return RelayListMetadataEvent.self case .bookmarksList: return BookmarksListEvent.self diff --git a/Sources/NostrSDK/Events/LabelEvent.swift b/Sources/NostrSDK/Events/LabelEvent.swift new file mode 100644 index 0000000..eb3123f --- /dev/null +++ b/Sources/NostrSDK/Events/LabelEvent.swift @@ -0,0 +1,230 @@ +// +// LabelEvent.swift +// NostrSDK +// +// Created by Terry Yiu on 10/31/24. +// + +import Foundation + +/// This event attaches labels to label targets. This allows for labeling of events, people, relays, or topics. +/// This supports several use cases, including distributed moderation, collection management, license assignment, and content classification. +/// +/// See [NIP-32 Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md). +public final class LabelEvent: NostrEvent { + + public required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + @available(*, unavailable, message: "This initializer is unavailable for this class.") + override init(id: String, pubkey: String, createdAt: Int64, kind: EventKind, tags: [Tag], content: String, signature: String?) { + super.init(id: id, pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content, signature: signature) + } + + @available(*, unavailable, message: "This initializer is unavailable for this class.") + required init(kind: EventKind, content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), pubkey: String) { + super.init(kind: kind, content: content, tags: tags, createdAt: createdAt, pubkey: pubkey) + } + + @available(*, unavailable, message: "This initializer is unavailable for this class.") + required init(kind: EventKind, content: String, tags: [Tag] = [], createdAt: Int64 = Int64(Date.now.timeIntervalSince1970), signedBy keypair: Keypair) throws { + try super.init(kind: kind, content: content, tags: tags, createdAt: createdAt, signedBy: keypair) + } + + /// The targeted events from this label event. + public var targetedEvents: [EventTag] { + allTags(withTagName: .event).compactMap { EventTag(tag: $0) } + } + + /// The targeted pubkeys from this label event. + public var targetedPubkeys: [PubkeyTag] { + allTags(withTagName: .pubkey).compactMap { PubkeyTag(tag: $0) } + } + + /// The targeted event coordinates from this label event. + public var targetedEventCoordinates: [EventCoordinates] { + referencedEventCoordinates + } + + /// The targeted relay URLs from this label event. + public var targetedRelayURLs: [URL] { + tags.filter { $0.name == "r" }.compactMap { URL(string: $0.value) } + } + + /// The targeted topics from this label event. + public var targetedTopics: [String] { + allValues(forTagName: .hashtag) + } +} + +/// Interprets label tags on an event. +/// +/// If this is a ``LabelEvent`` kind (1985), the label is attached to the label target. +/// Otherwise, the label is attached to this event itself as the target. +/// +/// See [NIP-32 Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md). +public protocol LabelTagInterpreting: NostrEvent {} +public extension LabelTagInterpreting { + /// The label namespaces. + var labelNamespaces: [String] { + allValues(forTagName: .labelNamespace) + } + + /// Dictionary of label namespaces or marks mapped to list of labels. + /// If a label does not include mark, `ugc` (user generated content) is implied and keyed from `ugc` in the dictionary. + var labels: [String: [String]] { + let filteredTags = allTags(withTagName: .label) + return Dictionary( + grouping: filteredTags, + by: { $0.otherParameters.first ?? "ugc" } + ).mapValues { tags in + tags.map { tag in tag.value } + } + } + + /// The labels that include the specified mark. + /// If no mark is provided to this function or on a label tag, `ugc` (user generated content) is implied. + func labels(for mark: String?) -> [String] { + let resolvedMark = mark ?? "ugc" + return allTags(withTagName: .label) + .filter { labelTag in + let labelMark = labelTag.otherParameters.first ?? "ugc" + return labelMark == resolvedMark + }.map { $0.value } + } +} + +public extension LabelEvent { + /// Builder of a ``LabelEvent``. + final class Builder: NostrEvent.Builder, RelayURLValidating { + public init() { + super.init(kind: .label) + } + + /// Adds an event as a label target. + @discardableResult + public final func target(eventId: String, relayURL: URL? = nil) throws -> Builder { + appendTags(try EventTag(eventId: eventId, relayURL: relayURL).tag) + } + + /// Adds a pubkey as a label target. + @discardableResult + public final func target(pubkey: String, relayURL: URL? = nil) throws -> Builder { + if let relayURL { + let validatedRelayURL = try validateRelayURL(relayURL) + appendTags(.pubkey(pubkey, otherParameters: [validatedRelayURL.absoluteString])) + } else { + appendTags(.pubkey(pubkey)) + } + return self + } + + /// Adds event coordinates as a label target. + @discardableResult + public final func target(eventCoordinates: EventCoordinates) throws -> Builder { + appendTags(eventCoordinates.tag) + } + + /// Adds a relay URL as a label target. + @discardableResult + public final func target(relayURL: URL) throws -> Builder { + let validatedRelayURL = try validateRelayURL(relayURL) + return appendTags(Tag(name: "r", value: validatedRelayURL.absoluteString)) + } + + /// Adds a hashtag topic as a label target. + @discardableResult + public final func target(topic: String) throws -> Builder { + appendTags(.hashtag(topic)) + } + } +} + +/// Builder that labels a target. +/// +/// If this is a ``LabelEvent`` kind (1985), the label is attached to the label target. +/// Otherwise, the label is attached to this event itself as the target. +/// +/// See [NIP-32 Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md). +public protocol LabelBuilding: NostrEventBuilding {} +public extension LabelBuilding { + /// Labels an event in a given namespace. + /// + /// Namespaces can be any string but SHOULD be unambiguous by using a well-defined namespace (such as an ISO standard) or reverse domain name notation. + /// + /// Namespaces are RECOMMENDED in order to support searching by namespace rather than by a specific tag. + /// The special `ugc` ("user generated content") namespace MAY be used when the label content is provided by an end user. + /// + /// Namespaces starting with # indicate that the label target should be associated with the label's value. + /// This is a way of attaching standard nostr tags to events, pubkeys, relays, urls, etc. + /// + /// If this is a ``LabelEvent`` kind (1985), the label is attached to the label target. + /// Otherwise, the label is attached to this event itself as the target. + /// + /// See [NIP-32 Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md). + @discardableResult + func appendLabels(_ labels: String..., namespace: String) -> Self { + self.appendLabels(contentsOf: labels, namespace: namespace) + } + + /// Labels an event in a given namespace. + /// + /// Namespaces can be any string but SHOULD be unambiguous by using a well-defined namespace (such as an ISO standard) or reverse domain name notation. + /// + /// Namespaces are RECOMMENDED in order to support searching by namespace rather than by a specific tag. + /// The special `ugc` ("user generated content") namespace MAY be used when the label content is provided by an end user. + /// + /// Namespaces starting with # indicate that the label target should be associated with the label's value. + /// This is a way of attaching standard nostr tags to events, pubkeys, relays, urls, etc. + /// + /// If this is a ``LabelEvent`` kind (1985), the label is attached to the label target. + /// Otherwise, the label is attached to this event itself as the target. + /// + /// See [NIP-32 Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md). + @discardableResult + func appendLabels(contentsOf labels: [String], namespace: String) -> Self { + guard !labels.isEmpty else { + return self + } + + appendTags(Tag(name: .labelNamespace, value: namespace)) + for label in labels { + appendTags(Tag(name: .label, value: label, otherParameters: [namespace])) + } + return self + } + + /// Labels the event with a given mark. + /// A mark SHOULD be included. If it is not included, `ugc` (user generated content) is implied. + /// + /// If this is a ``LabelEvent`` kind (1985), the label is attached to the label target. + /// Otherwise, the label is attached to this event itself as the target. + /// + /// See [NIP-32 Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md). + @discardableResult + func appendLabels(_ labels: String..., mark: String? = nil) -> Self { + self.appendLabels(contentsOf: labels, mark: mark) + } + + /// Labels the event with a given mark. + /// A mark SHOULD be included. If it is not included, `ugc` (user generated content) is implied. + /// + /// If this is a ``LabelEvent`` kind (1985), the label is attached to the label target. + /// Otherwise, the label is attached to this event itself as the target. + /// + /// See [NIP-32 Labeling](https://github.com/nostr-protocol/nips/blob/master/32.md). + @discardableResult + func appendLabels(contentsOf labels: [String], mark: String? = nil) -> Self { + let otherParameters: [String] + if let mark { + otherParameters = [mark] + } else { + otherParameters = [] + } + for label in labels { + appendTags(Tag(name: .label, value: label, otherParameters: otherParameters)) + } + return self + } +} diff --git a/Sources/NostrSDK/Events/NostrEvent.swift b/Sources/NostrSDK/Events/NostrEvent.swift index 6c6341a..4ea3a99 100644 --- a/Sources/NostrSDK/Events/NostrEvent.swift +++ b/Sources/NostrSDK/Events/NostrEvent.swift @@ -10,7 +10,7 @@ import Foundation /// A structure that describes a Nostr event. /// /// > Note: [NIP-01 Specification](https://github.com/nostr-protocol/nips/blob/master/01.md#events-and-signatures) -public class NostrEvent: Codable, Equatable, Hashable { +public class NostrEvent: Codable, Equatable, Hashable, LabelTagInterpreting { public static func == (lhs: NostrEvent, rhs: NostrEvent) -> Bool { lhs.id == rhs.id && lhs.pubkey == rhs.pubkey && @@ -318,7 +318,7 @@ public protocol NostrEventBuilding { public extension NostrEvent { /// Builder of a ``NostrEvent`` of type `T`. - class Builder: NostrEventBuilding { + class Builder: NostrEventBuilding, LabelBuilding { public typealias EventType = T /// The event kind. diff --git a/Sources/NostrSDK/Events/Tags/PubkeyTag.swift b/Sources/NostrSDK/Events/Tags/PubkeyTag.swift new file mode 100644 index 0000000..d247b71 --- /dev/null +++ b/Sources/NostrSDK/Events/Tags/PubkeyTag.swift @@ -0,0 +1,85 @@ +// +// PubkeyTag.swift +// NostrSDK +// +// Created by Terry Yiu on 10/31/24. +// + +import Foundation + +public struct PubkeyTag: RelayProviding, RelayURLValidating, Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.tag == rhs.tag + } + + /// The ``Tag`` that represents this pubkey tag. + public let tag: Tag + + /// The pubkey being referenced. + public var pubkey: String { + tag.value + } + + /// The URL of a recommended relay associated with the reference. + public var relayURL: URL? { + guard let relayString = tag.otherParameters.first, !relayString.isEmpty else { + return nil + } + + return try? validateRelayURLString(relayString) + } + + /// The petname of the pubkey. + public var petname: String? { + guard tag.otherParameters.count >= 2 else { + return nil + } + + return tag.otherParameters[1] + } + + /// Initializes an event tag from a ``Tag``. + /// `nil` is returned if the tag is not an pubkey tag. + public init?(tag: Tag) { + guard tag.name == TagName.pubkey.rawValue else { + return nil + } + + self.tag = tag + } + + /// Initializes a pubkey tag. + /// - Parameters: + /// - publicKey: The ``PublicKey`` being referenced. + /// - relayURL: The URL of a recommended relay associated with the reference. + /// - petname: The petname of the pubkey. + public init(publicKey: PublicKey, relayURL: URL? = nil, petname: String? = nil) throws { + let validatedRelayURL: URL? + if let relayURL { + validatedRelayURL = try RelayURLValidator.shared.validateRelayURL(relayURL) + } else { + validatedRelayURL = nil + } + + var tagOtherParameters = [validatedRelayURL?.absoluteString ?? ""] + + if let petname { + tagOtherParameters.append(petname) + } + + tag = .pubkey(publicKey.hex, otherParameters: tagOtherParameters) + } + + /// Initializes a pubkey tag. + /// - Parameters: + /// - pubkey: The hex pubkey being referenced. + /// - relayURL: The URL of a recommended relay associated with the reference. + /// - petname: The petname of the pubkey. + public init(pubkey: String, relayURL: URL? = nil, petname: String? = nil) throws { + guard let publicKey = PublicKey(hex: pubkey) else { + throw EventCreatingError.invalidInput + } + + try self.init(publicKey: publicKey, relayURL: relayURL, petname: petname) + } +} diff --git a/Tests/NostrSDKTests/Events/LabelEventTests.swift b/Tests/NostrSDKTests/Events/LabelEventTests.swift new file mode 100644 index 0000000..1bebeb8 --- /dev/null +++ b/Tests/NostrSDKTests/Events/LabelEventTests.swift @@ -0,0 +1,63 @@ +// +// LabelEventTests.swift +// NostrSDK +// +// Created by Terry Yiu on 11/3/24. +// + +@testable import NostrSDK +import XCTest + +final class LabelEventTests: XCTestCase, EventVerifying { + + func testCreateLabelEvent() throws { + let publicKey1 = try XCTUnwrap(Keypair()).publicKey + let publicKey2 = try XCTUnwrap(Keypair()).publicKey + let relayURL1 = try XCTUnwrap(URL(string: "wss://relay.nostrsdk.com")) + let relayURL2 = try XCTUnwrap(URL(string: "wss://relay.damus.io")) + let relayURL3 = try XCTUnwrap(URL(string: "wss://relay.primal.net")) + let targetedEventCoordinates1 = try XCTUnwrap(EventCoordinates(kind: .bookmarksList, pubkey: publicKey1)) + let targetedEventCoordinates2 = try XCTUnwrap(EventCoordinates(kind: .bookmarksList, pubkey: publicKey2)) + + let labelEvent = try LabelEvent.Builder() + .appendLabels("approve", namespace: "nip28.moderation") + .target(eventId: "event-id-1", relayURL: relayURL1) + .target(eventId: "event-id-2", relayURL: relayURL2) + .target(pubkey: publicKey1.hex, relayURL: relayURL2) + .target(pubkey: publicKey2.hex, relayURL: relayURL3) + .target(eventCoordinates: targetedEventCoordinates1) + .target(eventCoordinates: targetedEventCoordinates2) + .target(relayURL: relayURL3) + .target(relayURL: relayURL1) + .target(topic: "topic1") + .target(topic: "topic2") + .build(signedBy: .test) + + XCTAssertEqual(labelEvent.labels, ["nip28.moderation": ["approve"]]) + XCTAssertEqual(labelEvent.labels(for: "nip28.moderation"), ["approve"]) + XCTAssertEqual(labelEvent.labelNamespaces, ["nip28.moderation"]) + + XCTAssertEqual(labelEvent.targetedEvents.count, 2) + let targetedEventTag1 = try XCTUnwrap(labelEvent.targetedEvents[0]) + XCTAssertEqual(targetedEventTag1.eventId, "event-id-1") + XCTAssertEqual(targetedEventTag1.relayURL?.absoluteString, relayURL1.absoluteString) + let targetedEventTag2 = try XCTUnwrap(labelEvent.targetedEvents[1]) + XCTAssertEqual(targetedEventTag2.eventId, "event-id-2") + XCTAssertEqual(targetedEventTag2.relayURL?.absoluteString, relayURL2.absoluteString) + + XCTAssertEqual(labelEvent.targetedPubkeys.count, 2) + let targetedPubkey1 = try XCTUnwrap(labelEvent.targetedPubkeys[0]) + XCTAssertEqual(targetedPubkey1.pubkey, publicKey1.hex) + XCTAssertEqual(targetedPubkey1.relayURL?.absoluteString, relayURL2.absoluteString) + let targetedPubkey2 = try XCTUnwrap(labelEvent.targetedPubkeys[1]) + XCTAssertEqual(targetedPubkey2.pubkey, publicKey2.hex) + XCTAssertEqual(targetedPubkey2.relayURL?.absoluteString, relayURL3.absoluteString) + + XCTAssertEqual(labelEvent.targetedEventCoordinates, [targetedEventCoordinates1, targetedEventCoordinates2]) + XCTAssertEqual(labelEvent.targetedRelayURLs.map { $0.absoluteString }, [relayURL3.absoluteString, relayURL1.absoluteString]) + XCTAssertEqual(labelEvent.targetedTopics, ["topic1", "topic2"]) + + try verifyEvent(labelEvent) + } + +} diff --git a/Tests/NostrSDKTests/Events/NostrEventTests.swift b/Tests/NostrSDKTests/Events/NostrEventTests.swift index c6f1509..c271f86 100644 --- a/Tests/NostrSDKTests/Events/NostrEventTests.swift +++ b/Tests/NostrSDKTests/Events/NostrEventTests.swift @@ -8,7 +8,7 @@ @testable import NostrSDK import XCTest -final class NostrEventTests: XCTestCase, FixtureLoading, MetadataCoding { +final class NostrEventTests: XCTestCase, EventVerifying, FixtureLoading, MetadataCoding { func testEquatable() throws { let textNoteEvent: TextNoteEvent = try decodeFixture(filename: "text_note") @@ -79,6 +79,34 @@ final class NostrEventTests: XCTestCase, FixtureLoading, MetadataCoding { XCTAssertEqual(decodedCustomEventWithAltTag.alternativeSummary, alternativeSummary) } + func testLabels() throws { + let event = try TextNoteEvent.Builder() + .appendLabels("IT-MI", "US-CA", namespace: "ISO-3166-2") + .appendLabels("en", namespace: "ISO-639-1") + .appendLabels("Milan", "San Francisco", mark: "cities") + .appendLabels("Italy", "United States of America") + .content("It's beautiful here in Milan and wonderful there in San Francisco!") + .build(signedBy: .test) + + XCTAssertEqual(event.labels(for: "ISO-3166-2"), ["IT-MI", "US-CA"]) + XCTAssertEqual(event.labels(for: "ISO-639-1"), ["en"]) + XCTAssertEqual(event.labels(for: "cities"), ["Milan", "San Francisco"]) + XCTAssertEqual(event.labels(for: nil), ["Italy", "United States of America"]) + XCTAssertEqual(event.labels(for: "ugc"), ["Italy", "United States of America"]) + XCTAssertEqual(event.labels(for: "doesnotexist"), []) + + XCTAssertEqual(event.labelNamespaces, ["ISO-3166-2", "ISO-639-1"]) + + let labels = event.labels + XCTAssertEqual(labels["ISO-3166-2"], ["IT-MI", "US-CA"]) + XCTAssertEqual(labels["ISO-639-1"], ["en"]) + XCTAssertEqual(labels["cities"], ["Milan", "San Francisco"]) + XCTAssertEqual(labels["ugc"], ["Italy", "United States of America"]) + XCTAssertEqual(labels["doesnotexist"], nil) + + try verifyEvent(event) + } + func testExpiration() throws { let futureExpiration = Int64(Date.now.timeIntervalSince1970 + 10000) let futureExpirationEvent = try NostrEvent.Builder(kind: .textNote)