package worker import ( "context" "database/sql" "fmt" "regexp" "strconv" "strings" "sync" "time" "github.com/fiatjaf/narr/src/parser" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip05" "github.com/nbd-wtf/go-nostr/nip19" "github.com/nbd-wtf/go-nostr/nip23" "github.com/nbd-wtf/go-nostr/sdk" "github.com/nbd-wtf/go-nostr/sdk/hints/sqlh" "github.com/puzpuzpuz/xsync/v3" ) var nostrSdk *sdk.System func InitializeNostr(db *sql.DB) error { hdb, err := sqlh.NewSQLHints(db, "sqlite3") if err != nil { return err } nostrSdk = sdk.NewSystem( sdk.WithHintsDB(hdb), ) return nil } func isItNostr(url string) (bool, *sdk.ProfileMetadata) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() // check for nostr url prefixes if strings.HasPrefix(url, "nostr://") { url = url[8:] } else if strings.HasPrefix(url, "nostr:") { url = url[6:] } else { // only accept nostr: or nostr:// urls for now return false, nil } // check for npub or nprofile if prefix, _, err := nip19.Decode(url); err == nil { if prefix == "nprofile" || prefix == "npub" { profile, err := nostrSdk.FetchProfileFromInput(ctx, url) if err != nil { return false, nil } return true, &profile } } if nip05.IsValidIdentifier(url) { profile, err := nostrSdk.FetchProfileFromInput(ctx, url) if err != nil { return false, nil } return true, &profile } return false, nil } func discoverNostr(candidateUrl string) (bool, *DiscoverResult) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() yes, profile := isItNostr(candidateUrl) if yes { nprofile := profile.Nprofile(ctx, nostrSdk, 3) // get some feed items items, err := nostrListItems(profile) if err != nil { items = []parser.Item{} } return true, &DiscoverResult{ FeedLink: fmt.Sprintf("nostr:%s", nprofile), Feed: &parser.Feed{ Title: profile.Name, SiteURL: fmt.Sprintf("https://njump.me/%s", nprofile), Items: items, }, Sources: []FeedSource{}, } } return false, nil } func nostrListItems(profile *sdk.ProfileMetadata) ([]parser.Item, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() relays := nostrSdk.FetchOutboxRelays(ctx, profile.PubKey, 3) evchan := nostrSdk.Pool.SubManyEose(ctx, relays, nostr.Filters{ { Authors: []string{profile.PubKey}, Kinds: []int{nostr.KindArticle}, Limit: 300, }, }) feedItems := []parser.Item{} for event := range evchan { eventTime := event.CreatedAt.Time() publishedAt := eventTime var lastUpdated *time.Time if paTag := event.Tags.GetFirst([]string{"published_at", ""}); paTag != nil && len(*paTag) >= 2 { i, err := strconv.ParseInt((*paTag)[1], 10, 64) if err == nil { publishedAt = time.Unix(i, 0) if publishedAt.Compare(eventTime) != 0 { lastUpdated = &eventTime } } } naddr, err := nip19.EncodeEntity(event.PubKey, event.Kind, event.Tags.GetD(), relays) if err != nil { continue } title := "" titleTag := event.Tags.GetFirst([]string{"title", ""}) if titleTag != nil && len(*titleTag) >= 2 { title = (*titleTag)[1] } else { continue } image := "" imageTag := event.Tags.GetFirst([]string{"image", ""}) if imageTag != nil && len(*imageTag) >= 2 { image = (*imageTag)[1] } htmlContent := replaceNostrURLsWithHTMLTags(nip23.MarkdownToHTML(event.Content)) feedItems = append(feedItems, parser.Item{ GUID: fmt.Sprintf("nostr:%s:%s", event.PubKey, event.Tags.GetD()), Date: publishedAt, LastUpdated: lastUpdated, URL: fmt.Sprintf("https://njump.me/%s", naddr), Content: htmlContent, Title: title, ImageURL: image, }) } return feedItems, nil } var nostrEveryMatcher = regexp.MustCompile(`nostr:((npub|note|nevent|nprofile|naddr)1[a-z0-9]+)\b`) func replaceNostrURLsWithHTMLTags(input string) string { // match and replace npup1, nprofile1, note1, nevent1, etc names := xsync.NewMapOf[string, string]() wg := sync.WaitGroup{} // first we run it without waiting for the results of getting the name as they will be async for _, match := range nostrEveryMatcher.FindAllString(input, len(input)+1) { nip19 := match[len("nostr:"):] if strings.HasPrefix(nip19, "npub1") || strings.HasPrefix(nip19, "nprofile1") { ctx, cancel := context.WithTimeout(context.Background(), time.Second*4) defer cancel() wg.Add(1) go func() { metadata, _ := nostrSdk.FetchProfileFromInput(ctx, nip19) if metadata.Name != "" { names.Store(nip19, metadata.Name) } wg.Done() }() } } // in the second time now that we got all the names we actually perform replacement wg.Wait() return nostrEveryMatcher.ReplaceAllStringFunc(input, func(match string) string { nip19 := match[len("nostr:"):] firstChars := nip19[:8] lastChars := nip19[len(nip19)-4:] if strings.HasPrefix(nip19, "npub1") || strings.HasPrefix(nip19, "nprofile1") { name, _ := names.Load(nip19) if name != "" { return fmt.Sprintf(`%s (%s…%s)`, nip19, name, firstChars, lastChars) } else { return fmt.Sprintf(`%s…%s`, nip19, firstChars, lastChars) } } else { return fmt.Sprintf(`%s…%s`, nip19, firstChars, lastChars) } }) }