first commit

This commit is contained in:
2026-04-29 02:35:00 +00:00
commit 2cb17df4c5
90 changed files with 7321 additions and 0 deletions

72
internal/nostr/keys.go Normal file
View File

@@ -0,0 +1,72 @@
package nostr
import (
"encoding/hex"
"errors"
"strings"
gn "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
)
var (
ErrInvalidPubkey = errors.New("invalid pubkey")
ErrInvalidNsec = errors.New("invalid nsec")
)
// NormalizePubkey accepts npub bech32 or 64-char hex and returns lowercase hex.
func NormalizePubkey(in string) (string, error) {
in = strings.TrimSpace(in)
if in == "" {
return "", ErrInvalidPubkey
}
if strings.HasPrefix(in, "npub1") {
prefix, data, err := nip19.Decode(in)
if err != nil {
return "", ErrInvalidPubkey
}
if prefix != "npub" {
return "", ErrInvalidPubkey
}
s, ok := data.(string)
if !ok {
return "", ErrInvalidPubkey
}
return strings.ToLower(s), nil
}
if len(in) != 64 {
return "", ErrInvalidPubkey
}
if _, err := hex.DecodeString(in); err != nil {
return "", ErrInvalidPubkey
}
if !gn.IsValidPublicKey(in) {
return "", ErrInvalidPubkey
}
return strings.ToLower(in), nil
}
// HexToNpub converts hex pubkey to npub bech32. Empty string on error.
func HexToNpub(hexpk string) string {
npub, err := nip19.EncodePublicKey(hexpk)
if err != nil {
return ""
}
return npub
}
// NsecToHex decodes nsec1... to hex private key.
func NsecToHex(nsec string) (string, error) {
prefix, data, err := nip19.Decode(strings.TrimSpace(nsec))
if err != nil {
return "", ErrInvalidNsec
}
if prefix != "nsec" {
return "", ErrInvalidNsec
}
s, ok := data.(string)
if !ok {
return "", ErrInvalidNsec
}
return strings.ToLower(s), nil
}

View File

@@ -0,0 +1,41 @@
package nostr
import (
"strings"
"testing"
)
func TestNormalizePubkey_Hex(t *testing.T) {
hex := "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
got, err := NormalizePubkey(hex)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != strings.ToLower(hex) {
t.Fatalf("got %q want %q", got, hex)
}
}
func TestNormalizePubkey_BadInput(t *testing.T) {
cases := []string{"", "abc", "not-an-npub", "npub1invalid", strings.Repeat("z", 64)}
for _, c := range cases {
if _, err := NormalizePubkey(c); err == nil {
t.Errorf("expected error for %q", c)
}
}
}
func TestNormalizePubkey_NpubRoundtrip(t *testing.T) {
hex := "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
npub := HexToNpub(hex)
if npub == "" || !strings.HasPrefix(npub, "npub1") {
t.Fatalf("HexToNpub failed: %q", npub)
}
got, err := NormalizePubkey(npub)
if err != nil {
t.Fatalf("decode npub: %v", err)
}
if got != hex {
t.Fatalf("roundtrip got %q want %q", got, hex)
}
}

81
internal/nostr/profile.go Normal file
View File

@@ -0,0 +1,81 @@
package nostr
import (
"context"
"encoding/json"
"time"
gn "github.com/nbd-wtf/go-nostr"
)
// Metadata mirrors the kind:0 profile JSON. We accept both snake_case
// (`display_name`, NIP-24) and the deprecated camelCase `displayName` since
// some clients still publish only the latter. `Username` is also deprecated
// but appears in older profiles; per NIP-24 it should be ignored in favor of
// `Name`, but we surface it as a last-resort fallback.
type Metadata struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
DisplayNameAlt string `json:"displayName"`
Username string `json:"username"`
NIP05 string `json:"nip05"`
About string `json:"about"`
Picture string `json:"picture"`
}
// ParseMetadata decodes a kind:0 content payload.
func ParseMetadata(content string) (*Metadata, error) {
var md Metadata
if err := json.Unmarshal([]byte(content), &md); err != nil {
return nil, err
}
return &md, nil
}
// FetchMetadata pulls the most recent kind:0 event for a hex pubkey across the pool.
// Each relay may return multiple replacement events; we keep the one with the
// highest CreatedAt across every relay reached before timeout.
func FetchMetadata(ctx context.Context, p *Pool, hexpk string) (*Metadata, error) {
filter := gn.Filter{
Kinds: []int{0},
Authors: []string{hexpk},
Limit: 100,
}
var newest *gn.Event
for _, url := range p.URLs() {
r, err := p.Connect(ctx, url)
if err != nil {
continue
}
subCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
sub, err := r.Subscribe(subCtx, gn.Filters{filter})
if err != nil {
cancel()
continue
}
loop:
for {
select {
case ev, ok := <-sub.Events:
if !ok {
break loop
}
if newest == nil || ev.CreatedAt > newest.CreatedAt {
newest = ev
}
case <-sub.EndOfStoredEvents:
break loop
case <-subCtx.Done():
break loop
}
}
sub.Unsub()
cancel()
}
if newest == nil {
return nil, nil
}
return ParseMetadata(newest.Content)
}

View File

@@ -0,0 +1,59 @@
package nostr
import "testing"
func TestParseMetadata(t *testing.T) {
cases := []struct {
desc, in string
name, dn, dnAlt string
username, nip05 string
}{
{
desc: "snake_case display_name",
in: `{"name":"alice","display_name":"Alice","nip05":"alice@azzamo.net"}`,
name: "alice", dn: "Alice", nip05: "alice@azzamo.net",
},
{
desc: "camelCase displayName preserved separately",
in: `{"displayName":"Alice"}`,
dnAlt: "Alice",
},
{
desc: "deprecated username field exposed",
in: `{"username":"legacy"}`,
username: "legacy",
},
{
desc: "all fields together",
in: `{"name":"a","display_name":"A","displayName":"AA","username":"u","nip05":"a@x"}`,
name: "a", dn: "A", dnAlt: "AA", username: "u", nip05: "a@x",
},
}
for _, tc := range cases {
md, err := ParseMetadata(tc.in)
if err != nil {
t.Fatalf("%s: parse error: %v", tc.desc, err)
}
if md.Name != tc.name {
t.Errorf("%s: Name=%q want %q", tc.desc, md.Name, tc.name)
}
if md.DisplayName != tc.dn {
t.Errorf("%s: DisplayName=%q want %q", tc.desc, md.DisplayName, tc.dn)
}
if md.DisplayNameAlt != tc.dnAlt {
t.Errorf("%s: DisplayNameAlt=%q want %q", tc.desc, md.DisplayNameAlt, tc.dnAlt)
}
if md.Username != tc.username {
t.Errorf("%s: Username=%q want %q", tc.desc, md.Username, tc.username)
}
if md.NIP05 != tc.nip05 {
t.Errorf("%s: NIP05=%q want %q", tc.desc, md.NIP05, tc.nip05)
}
}
}
func TestParseMetadataInvalidJSON(t *testing.T) {
if _, err := ParseMetadata("not json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}

36
internal/nostr/publish.go Normal file
View File

@@ -0,0 +1,36 @@
package nostr
import (
"context"
"errors"
gn "github.com/nbd-wtf/go-nostr"
)
var ErrNoRelayAccepted = errors.New("no relay accepted event")
// Publish sends an already-signed event to all relays in the pool.
// Success if at least one relay accepts.
func Publish(ctx context.Context, p *Pool, ev *gn.Event) error {
var lastErr error
accepted := 0
for _, url := range p.URLs() {
r, err := p.Connect(ctx, url)
if err != nil {
lastErr = err
continue
}
if err := r.Publish(ctx, *ev); err != nil {
lastErr = err
continue
}
accepted++
}
if accepted == 0 {
if lastErr != nil {
return lastErr
}
return ErrNoRelayAccepted
}
return nil
}

51
internal/nostr/relay.go Normal file
View File

@@ -0,0 +1,51 @@
package nostr
import (
"context"
"sync"
"time"
gn "github.com/nbd-wtf/go-nostr"
)
// Pool is a small relay-connection pool.
type Pool struct {
mu sync.Mutex
relays []string
active map[string]*gn.Relay
}
func NewPool(urls []string) *Pool {
return &Pool{relays: urls, active: map[string]*gn.Relay{}}
}
func (p *Pool) URLs() []string { return p.relays }
func (p *Pool) Connect(ctx context.Context, url string) (*gn.Relay, error) {
p.mu.Lock()
if r, ok := p.active[url]; ok && r.IsConnected() {
p.mu.Unlock()
return r, nil
}
p.mu.Unlock()
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
r, err := gn.RelayConnect(dialCtx, url)
if err != nil {
return nil, err
}
p.mu.Lock()
p.active[url] = r
p.mu.Unlock()
return r, nil
}
func (p *Pool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
for _, r := range p.active {
_ = r.Close()
}
p.active = map[string]*gn.Relay{}
}