| | 1 | | namespace NostrSure.Domain.Entities; |
| | 2 | |
|
| | 3 | | using NostrSure.Domain.ValueObjects; |
| | 4 | |
|
| | 5 | | /// <summary> |
| | 6 | | /// Represents a NIP-02 Contact List Event (kind 3). |
| | 7 | | /// Immutable record following SOLID principles with proper encapsulation. |
| | 8 | | /// </summary> |
| | 9 | | public sealed record ContactListEvent( |
| | 10 | | string Id, |
| | 11 | | Pubkey Pubkey, |
| | 12 | | DateTimeOffset CreatedAt, |
| | 13 | | IReadOnlyList<NostrTag> Tags, |
| | 14 | | string Content, |
| | 15 | | string Sig, |
| 41 | 16 | | IReadOnlyList<ContactEntry> Contacts |
| 15 | 17 | | ) : NostrEvent(Id, Pubkey, CreatedAt, EventKind.ContactList, Tags, Content, Sig) |
| | 18 | | { |
| | 19 | | /// <summary> |
| | 20 | | /// Creates a ContactListEvent from a base NostrEvent and extracts contacts from p tags. |
| | 21 | | /// </summary> |
| | 22 | | public static ContactListEvent FromNostrEvent(NostrEvent nostrEvent) |
| 7 | 23 | | { |
| 7 | 24 | | if (nostrEvent.Kind != EventKind.ContactList) |
| 1 | 25 | | throw new ArgumentException("NostrEvent must be of kind ContactList", nameof(nostrEvent)); |
| | 26 | |
|
| 6 | 27 | | var contacts = ExtractContactsFromTags(nostrEvent.Tags); |
| | 28 | |
|
| 6 | 29 | | return new ContactListEvent( |
| 6 | 30 | | nostrEvent.Id, |
| 6 | 31 | | nostrEvent.Pubkey, |
| 6 | 32 | | nostrEvent.CreatedAt, |
| 6 | 33 | | nostrEvent.Tags, |
| 6 | 34 | | nostrEvent.Content, |
| 6 | 35 | | nostrEvent.Sig, |
| 6 | 36 | | contacts |
| 6 | 37 | | ); |
| 6 | 38 | | } |
| | 39 | |
|
| | 40 | | /// <summary> |
| | 41 | | /// Creates a new ContactListEvent with the given contacts, automatically generating p tags. |
| | 42 | | /// </summary> |
| | 43 | | public static ContactListEvent Create( |
| | 44 | | string id, |
| | 45 | | Pubkey pubkey, |
| | 46 | | DateTimeOffset createdAt, |
| | 47 | | string content, |
| | 48 | | string sig, |
| | 49 | | IReadOnlyList<ContactEntry> contacts, |
| | 50 | | IReadOnlyList<NostrTag>? additionalTags = null) |
| 7 | 51 | | { |
| 7 | 52 | | var allTags = new List<NostrTag>(); |
| | 53 | |
|
| | 54 | | // Add non-p tags first |
| 7 | 55 | | if (additionalTags != null) |
| 4 | 56 | | allTags.AddRange(additionalTags.Where(t => t.Name != "p")); |
| | 57 | |
|
| | 58 | | // Add p tags from contacts |
| 20 | 59 | | allTags.AddRange(contacts.Select(c => c.ToPTag())); |
| | 60 | |
|
| 7 | 61 | | return new ContactListEvent( |
| 7 | 62 | | id, |
| 7 | 63 | | pubkey, |
| 7 | 64 | | createdAt, |
| 7 | 65 | | allTags, |
| 7 | 66 | | content, |
| 7 | 67 | | sig, |
| 7 | 68 | | contacts |
| 7 | 69 | | ); |
| 7 | 70 | | } |
| | 71 | |
|
| | 72 | | /// <summary> |
| | 73 | | /// Extracts contact entries from p tags in the tags collection. |
| | 74 | | /// </summary> |
| | 75 | | private static IReadOnlyList<ContactEntry> ExtractContactsFromTags(IReadOnlyList<NostrTag> tags) |
| 6 | 76 | | { |
| 6 | 77 | | return tags |
| 15 | 78 | | .Where(tag => tag.Name == "p" && tag.Values.Count > 0) |
| 6 | 79 | | .Select(ContactEntry.FromPTag) |
| 6 | 80 | | .ToList(); |
| 6 | 81 | | } |
| | 82 | |
|
| | 83 | | /// <summary> |
| | 84 | | /// Validates the contact list event according to NIP-02 specifications. |
| | 85 | | /// </summary> |
| | 86 | | public bool IsValidContactList() |
| 4 | 87 | | { |
| | 88 | | // All contacts must be valid |
| 11 | 89 | | if (!Contacts.All(c => c.IsValid)) |
| 1 | 90 | | return false; |
| | 91 | |
|
| | 92 | | // Check that p tags match contacts |
| 9 | 93 | | var pTags = Tags.Where(t => t.Name == "p").ToList(); |
| 3 | 94 | | if (pTags.Count != Contacts.Count) |
| 1 | 95 | | return false; |
| | 96 | |
|
| | 97 | | // Use a HashSet for efficient lookup |
| 2 | 98 | | var pTagSet = new HashSet<string>(pTags.Select(TagKey)); |
| 14 | 99 | | foreach (var contact in Contacts) |
| 4 | 100 | | { |
| 4 | 101 | | var expectedTag = contact.ToPTag(); |
| 4 | 102 | | if (!pTagSet.Contains(TagKey(expectedTag))) |
| 0 | 103 | | return false; |
| 4 | 104 | | } |
| | 105 | |
|
| | 106 | | // Local function to generate a unique key for a tag |
| | 107 | | static string TagKey(NostrTag tag) |
| 8 | 108 | | { |
| 8 | 109 | | return $"{tag.Name}:{string.Join(":", tag.Values)}"; |
| 8 | 110 | | } |
| | 111 | |
|
| 2 | 112 | | return true; |
| 4 | 113 | | } |
| | 114 | |
|
| | 115 | | /// <summary> |
| | 116 | | /// Override validation to include NIP-02 specific validation. |
| | 117 | | /// </summary> |
| | 118 | | public override bool Validate(Interfaces.INostrEventValidator validator, out List<string> errors) |
| 0 | 119 | | { |
| | 120 | | // First run base validation |
| 0 | 121 | | var isValid = base.Validate(validator, out errors); |
| | 122 | |
|
| | 123 | | // Then add NIP-02 specific validation |
| 0 | 124 | | if (!IsValidContactList()) |
| 0 | 125 | | { |
| 0 | 126 | | errors.Add("Contact list validation failed: contacts and p tags do not match"); |
| 0 | 127 | | isValid = false; |
| 0 | 128 | | } |
| | 129 | |
|
| | 130 | | // Validate individual contacts |
| 0 | 131 | | for (int i = 0; i < Contacts.Count; i++) |
| 0 | 132 | | { |
| 0 | 133 | | if (!Contacts[i].IsValid) |
| 0 | 134 | | { |
| 0 | 135 | | errors.Add($"Contact {i} is invalid: {Contacts[i].ContactPubkey.Value}"); |
| 0 | 136 | | isValid = false; |
| 0 | 137 | | } |
| 0 | 138 | | } |
| | 139 | |
|
| 0 | 140 | | return isValid; |
| 0 | 141 | | } |
| | 142 | |
|
| | 143 | | private static bool TagsMatch(NostrTag tag1, NostrTag tag2) |
| 0 | 144 | | { |
| 0 | 145 | | if (tag1.Name != tag2.Name || tag1.Values.Count != tag2.Values.Count) |
| 0 | 146 | | return false; |
| | 147 | |
|
| 0 | 148 | | for (int i = 0; i < tag1.Values.Count; i++) |
| 0 | 149 | | { |
| 0 | 150 | | if (tag1.Values[i] != tag2.Values[i]) |
| 0 | 151 | | return false; |
| 0 | 152 | | } |
| | 153 | |
|
| 0 | 154 | | return true; |
| 0 | 155 | | } |
| | 156 | | } |