From e18528c043bb3911f2c9d74c7c788a6cdf765568 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Fri, 21 Mar 2025 23:43:23 -0300 Subject: [PATCH] move ExternalPointer to nip73 and write nip27.Parse() that gets all the parts of the text including URLs, Nostr URIs and just raw text. --- nip22/nip22.go | 9 ++- nip27/blocks.go | 152 +++++++++++++++++++++++++++++++++++++++++++ nip27/blocks_test.go | 50 ++++++++++++++ nip27/references.go | 1 + nip73/pointer.go | 23 +++++++ pointers.go | 19 ------ 6 files changed, 232 insertions(+), 22 deletions(-) create mode 100644 nip27/blocks.go create mode 100644 nip27/blocks_test.go create mode 100644 nip73/pointer.go diff --git a/nip22/nip22.go b/nip22/nip22.go index 4d9a4f3..1a79c85 100644 --- a/nip22/nip22.go +++ b/nip22/nip22.go @@ -1,6 +1,9 @@ package nip22 -import "github.com/nbd-wtf/go-nostr" +import ( + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip73" +) func GetThreadRoot(tags nostr.Tags) nostr.Pointer { for _, tag := range tags { @@ -15,7 +18,7 @@ func GetThreadRoot(tags nostr.Tags) nostr.Pointer { ep, _ := nostr.EntityPointerFromTag(tag) return ep case "I": - ep, _ := nostr.ExternalPointerFromTag(tag) + ep, _ := nip73.ExternalPointerFromTag(tag) return ep } } @@ -35,7 +38,7 @@ func GetImmediateParent(tags nostr.Tags) nostr.Pointer { ep, _ := nostr.EntityPointerFromTag(tag) return ep case "i": - ep, _ := nostr.ExternalPointerFromTag(tag) + ep, _ := nip73.ExternalPointerFromTag(tag) return ep } } diff --git a/nip27/blocks.go b/nip27/blocks.go new file mode 100644 index 0000000..1403922 --- /dev/null +++ b/nip27/blocks.go @@ -0,0 +1,152 @@ +package nip27 + +import ( + "iter" + "net/url" + "regexp" + "strings" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip19" + "github.com/nbd-wtf/go-nostr/nip73" +) + +type Block struct { + Text string + Start int + Pointer nostr.Pointer +} + +var ( + noCharacter = regexp.MustCompile(`(?m)\W`) + noURLCharacter = regexp.MustCompile(`(?m)\W |\W$|$|,| `) +) + +func Parse(content string) iter.Seq[Block] { + return func(yield func(Block) bool) { + max := len(content) + index := 0 + prevIndex := 0 + + for index < max { + pu := strings.IndexRune(content[index:], ':') + if pu == -1 { + // reached end + break + } + u := pu + index + + switch { + case u >= 5 && content[u-5:u] == "nostr" && u+60 < max: + m := noCharacter.FindStringIndex(content[u+60:]) + end := max + if m != nil { + end = u + 60 + m[0] + } + + prefix, data, err := nip19.Decode(content[u+1 : end]) + if err != nil { + // ignore this, not a valid nostr uri + index = u + 1 + continue + } + + var pointer nostr.Pointer + switch prefix { + case "npub": + pointer = nostr.ProfilePointer{PublicKey: data.(string)} + case "nprofile", "nevent", "naddr": + pointer = data.(nostr.Pointer) + case "note", "nsec": + fallthrough // I'm so cool + default: + // ignore this, treat it as not a valid uri + index = end + 1 + continue + } + + if prevIndex != u-5 { + if !yield(Block{Text: content[prevIndex : u-5], Start: prevIndex}) { + return + } + } + + if !yield(Block{Pointer: pointer, Text: content[u-5 : end], Start: u - 5}) { + return + } + + index = end + prevIndex = index + continue + case (u >= 5 && content[u-5:u] == "https") || (u >= 4 && content[u-4:u] == "http"): + m := noURLCharacter.FindStringIndex(content[u+4:]) + end := max + if m != nil { + end = u + 4 + m[0] + } + prefixLen := 4 + if content[u-1] == 's' { + prefixLen = 5 + } + parsed, err := url.Parse(content[u-prefixLen : end]) + if err != nil || !strings.Contains(parsed.Host, ".") { + // ignore this, not a valid url + index = end + 1 + continue + } + + if prevIndex != u-prefixLen { + if !yield(Block{Text: content[prevIndex : u-prefixLen], Start: prevIndex}) { + return + } + } + + if !yield(Block{Pointer: nip73.ExternalPointer{Thing: content[u-prefixLen : end]}, Text: content[u-prefixLen : end], Start: u - prefixLen}) { + return + } + + index = end + prevIndex = index + continue + case (u >= 3 && content[u-3:u] == "wss") || (u >= 2 && content[u-2:u] == "ws"): + m := noURLCharacter.FindStringIndex(content[u+4:]) + end := max + if m != nil { + end = u + 4 + m[0] + } + prefixLen := 2 + if content[u-1] == 's' { + prefixLen = 3 + } + parsed, err := url.Parse(content[u-prefixLen : end]) + if err != nil || !strings.Contains(parsed.Host, ".") { + // ignore this, not a valid url + index = end + 1 + continue + } + + if prevIndex != u-prefixLen { + if !yield(Block{Text: content[prevIndex : u-prefixLen], Start: prevIndex}) { + return + } + } + + if !yield(Block{Pointer: nip73.ExternalPointer{Thing: content[u-prefixLen : end]}, Text: content[u-prefixLen : end], Start: u - prefixLen}) { + return + } + + index = end + prevIndex = index + continue + default: + // ignore this, it is nothing + index = u + 1 + continue + } + } + + if prevIndex != max { + yield(Block{Text: content[prevIndex:], Start: prevIndex}) + } + } +} diff --git a/nip27/blocks_test.go b/nip27/blocks_test.go new file mode 100644 index 0000000..ce25585 --- /dev/null +++ b/nip27/blocks_test.go @@ -0,0 +1,50 @@ +package nip27 + +import ( + "fmt" + "slices" + "testing" + + "github.com/nbd-wtf/go-nostr" + "github.com/nbd-wtf/go-nostr/nip73" + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + for i, tc := range []struct { + content string + expected []Block + }{ + { + "hello, nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg wrote nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4!", + []Block{ + {Text: "hello, ", Start: 0}, + {Text: "nostr:nprofile1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8yc5usxdg", Start: 7, Pointer: nostr.ProfilePointer{PublicKey: "cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393"}}, + {Text: " wrote ", Start: 83}, + {Text: "nostr:nevent1qqsvc6ulagpn7kwrcwdqgp797xl7usumqa6s3kgcelwq6m75x8fe8ychxp5v4", Start: 90, Pointer: nostr.EventPointer{ID: "cc6b9fea033f59c3c39a0407c5f1bfee439b077508d918cfdc0d6fd431d39393"}}, + {Text: "!", Start: 164}, + }, + }, + { + `:wss://oa.ao; this was a relay and now here's a video -> https://videos.com/video.mp4! and some music: http://music.com/song.mp3 +and a regular link: https://regular.com/page?ok=true. and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally https://ok.com!`, + []Block{ + {Text: ":", Start: 0}, + {Text: "wss://oa.ao", Start: 1, Pointer: nip73.ExternalPointer{Thing: "wss://oa.ao"}}, + {Text: "; this was a relay and now here's a video -> ", Start: 12}, + {Text: "https://videos.com/video.mp4", Start: 57, Pointer: nip73.ExternalPointer{Thing: "https://videos.com/video.mp4"}}, + {Text: "! and some music: ", Start: 85}, + {Text: "http://music.com/song.mp3", Start: 103, Pointer: nip73.ExternalPointer{Thing: "http://music.com/song.mp3"}}, + {Text: "\nand a regular link: ", Start: 128}, + {Text: "https://regular.com/page?ok=true", Start: 149, Pointer: nip73.ExternalPointer{Thing: "https://regular.com/page?ok=true"}}, + {Text: ". and now a broken link: https://kjxkxk and a broken nostr ref: nostr:nevent1qqsr0f9w78uyy09qwmjt0kv63j4l7sxahq33725lqyyp79whlfjurwspz4mhxue69uhh56nzv34hxcfwv9ehw6nyddhq0ag9xg and a fake nostr ref: nostr:llll ok but finally ", Start: 181}, + {Text: "https://ok.com", Start: 405, Pointer: nip73.ExternalPointer{Thing: "https://ok.com"}}, + {Text: "!", Start: 419}, + }, + }, + } { + t.Run(fmt.Sprintf("%d", i), func(t *testing.T) { + require.Equal(t, tc.expected, slices.Collect(Parse(tc.content))) + }) + } +} diff --git a/nip27/references.go b/nip27/references.go index cffc6f3..b4842ac 100644 --- a/nip27/references.go +++ b/nip27/references.go @@ -17,6 +17,7 @@ type Reference struct { var mentionRegex = regexp.MustCompile(`\bnostr:((note|npub|naddr|nevent|nprofile)1\w+)\b`) +// Deprecated: this is useless, use Parse() isntead (but the semantics is different) func ParseReferences(evt nostr.Event) iter.Seq[Reference] { return func(yield func(Reference) bool) { for _, ref := range mentionRegex.FindAllStringSubmatchIndex(evt.Content, -1) { diff --git a/nip73/pointer.go b/nip73/pointer.go new file mode 100644 index 0000000..2a27f0a --- /dev/null +++ b/nip73/pointer.go @@ -0,0 +1,23 @@ +package nip73 + +import "github.com/nbd-wtf/go-nostr" + +var _ nostr.Pointer = (*ExternalPointer)(nil) + +// ExternalPointer represents a pointer to a URL or something else. +type ExternalPointer struct { + Thing string +} + +// ExternalPointerFromTag creates a ExternalPointer from an "i" tag +func ExternalPointerFromTag(refTag nostr.Tag) (ExternalPointer, error) { + return ExternalPointer{refTag[1]}, nil +} + +func (ep ExternalPointer) MatchesEvent(_ nostr.Event) bool { return false } +func (ep ExternalPointer) AsTagReference() string { return ep.Thing } +func (ep ExternalPointer) AsFilter() nostr.Filter { return nostr.Filter{} } + +func (ep ExternalPointer) AsTag() nostr.Tag { + return nostr.Tag{"i", ep.Thing} +} diff --git a/pointers.go b/pointers.go index a13d010..e20aac6 100644 --- a/pointers.go +++ b/pointers.go @@ -26,7 +26,6 @@ var ( _ Pointer = (*ProfilePointer)(nil) _ Pointer = (*EventPointer)(nil) _ Pointer = (*EntityPointer)(nil) - _ Pointer = (*ExternalPointer)(nil) ) // ProfilePointer represents a pointer to a Nostr profile. @@ -174,21 +173,3 @@ func (ep EntityPointer) AsTag() Tag { } return Tag{"a", ep.AsTagReference()} } - -// ExternalPointer represents a pointer to a Nostr profile. -type ExternalPointer struct { - Thing string -} - -// ExternalPointerFromTag creates a ExternalPointer from an "i" tag -func ExternalPointerFromTag(refTag Tag) (ExternalPointer, error) { - return ExternalPointer{refTag[1]}, nil -} - -func (ep ExternalPointer) MatchesEvent(_ Event) bool { return false } -func (ep ExternalPointer) AsTagReference() string { return ep.Thing } -func (ep ExternalPointer) AsFilter() Filter { return Filter{} } - -func (ep ExternalPointer) AsTag() Tag { - return Tag{"i", ep.Thing} -}