package nostr import ( "context" "encoding/hex" "fmt" "log" "strconv" "time" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" ) const ( // NIP-35: Torrent announcements KindTorrent = 2003 ) type Publisher struct { privateKey string publicKey string relays []string } type TorrentEventData struct { Title string InfoHash string FileName string FileSize int64 MagnetLink string WebSeedURL string BlossomHash string Description string } // NewPublisher creates a new Nostr publisher func NewPublisher(privateKeyHex string, relays []string) (*Publisher, error) { if privateKeyHex == "" { // Generate a new key if none provided sk := nostr.GeneratePrivateKey() privateKeyHex = sk } // Validate private key privateKeyBytes, err := hex.DecodeString(privateKeyHex) if err != nil { return nil, fmt.Errorf("invalid private key hex: %w", err) } if len(privateKeyBytes) != 32 { return nil, fmt.Errorf("private key must be 32 bytes") } publicKey, err := nostr.GetPublicKey(privateKeyHex) if err != nil { return nil, fmt.Errorf("error deriving public key: %w", err) } if len(relays) == 0 { relays = []string{ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band", } } return &Publisher{ privateKey: privateKeyHex, publicKey: publicKey, relays: relays, }, nil } // CreateTorrentEvent creates a NIP-35 compliant torrent announcement event func (p *Publisher) CreateTorrentEvent(data TorrentEventData) (*nostr.Event, error) { event := &nostr.Event{ Kind: KindTorrent, CreatedAt: nostr.Now(), Content: data.Description, Tags: nostr.Tags{}, } // Add required tags according to NIP-35 if data.Title != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"title", data.Title}) } if data.InfoHash != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"x", data.InfoHash}) } if data.FileName != "" && data.FileSize > 0 { event.Tags = event.Tags.AppendUnique(nostr.Tag{"file", data.FileName, strconv.FormatInt(data.FileSize, 10)}) } if data.WebSeedURL != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"webseed", data.WebSeedURL}) } if data.BlossomHash != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"blossom", data.BlossomHash}) } if data.MagnetLink != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"magnet", data.MagnetLink}) } // Add some additional useful tags event.Tags = event.Tags.AppendUnique(nostr.Tag{"t", "torrent"}) event.Tags = event.Tags.AppendUnique(nostr.Tag{"t", "blossom"}) // Sign the event err := event.Sign(p.privateKey) if err != nil { return nil, fmt.Errorf("error signing event: %w", err) } return event, nil } // PublishEvent publishes an event to configured relays func (p *Publisher) PublishEvent(ctx context.Context, event *nostr.Event) error { if len(p.relays) == 0 { return fmt.Errorf("no relays configured") } successCount := 0 errorCount := 0 for _, relayURL := range p.relays { err := p.publishToRelay(ctx, relayURL, event) if err != nil { log.Printf("Failed to publish to relay %s: %v", relayURL, err) errorCount++ } else { log.Printf("Successfully published to relay %s", relayURL) successCount++ } } if successCount == 0 { return fmt.Errorf("failed to publish to any relay (%d errors)", errorCount) } log.Printf("Published to %d/%d relays successfully", successCount, len(p.relays)) return nil } // publishToRelay publishes an event to a single relay func (p *Publisher) publishToRelay(ctx context.Context, relayURL string, event *nostr.Event) error { relay, err := nostr.RelayConnect(ctx, relayURL) if err != nil { return fmt.Errorf("error connecting to relay: %w", err) } defer relay.Close() // Set a reasonable timeout publishCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() err = relay.Publish(publishCtx, *event) if err != nil { return fmt.Errorf("error publishing event: %w", err) } return nil } // PublishTorrentAnnouncement creates and publishes a NIP-35 torrent announcement func (p *Publisher) PublishTorrentAnnouncement(ctx context.Context, data TorrentEventData) (*nostr.Event, error) { event, err := p.CreateTorrentEvent(data) if err != nil { return nil, fmt.Errorf("error creating torrent event: %w", err) } err = p.PublishEvent(ctx, event) if err != nil { return nil, fmt.Errorf("error publishing torrent event: %w", err) } return event, nil } // GetPublicKeyBech32 returns the public key in bech32 format (npub) func (p *Publisher) GetPublicKeyBech32() (string, error) { return nip19.EncodePublicKey(p.publicKey) } // GetPrivateKeyBech32 returns the private key in bech32 format (nsec) func (p *Publisher) GetPrivateKeyBech32() (string, error) { return nip19.EncodePrivateKey(p.privateKey) } // GetEventID returns the event ID in hex format func GetEventID(event *nostr.Event) string { return event.ID } // GetEventIDBech32 returns the event ID in bech32 format (note) func GetEventIDBech32(event *nostr.Event) (string, error) { return nip19.EncodeNote(event.ID) } // CreateMockPublisher creates a publisher that logs instead of publishing (for testing) func CreateMockPublisher() *MockPublisher { sk := nostr.GeneratePrivateKey() pk, _ := nostr.GetPublicKey(sk) return &MockPublisher{ privateKey: sk, publicKey: pk, events: make([]*nostr.Event, 0), } } // MockPublisher is a test implementation that doesn't actually publish type MockPublisher struct { privateKey string publicKey string events []*nostr.Event } func (m *MockPublisher) CreateTorrentEvent(data TorrentEventData) (*nostr.Event, error) { event := &nostr.Event{ Kind: KindTorrent, CreatedAt: nostr.Now(), Content: data.Description, Tags: nostr.Tags{}, } // Add required tags if data.Title != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"title", data.Title}) } if data.InfoHash != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"x", data.InfoHash}) } if data.FileName != "" && data.FileSize > 0 { event.Tags = event.Tags.AppendUnique(nostr.Tag{"file", data.FileName, strconv.FormatInt(data.FileSize, 10)}) } if data.WebSeedURL != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"webseed", data.WebSeedURL}) } if data.BlossomHash != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"blossom", data.BlossomHash}) } if data.MagnetLink != "" { event.Tags = event.Tags.AppendUnique(nostr.Tag{"magnet", data.MagnetLink}) } event.Tags = event.Tags.AppendUnique(nostr.Tag{"t", "torrent"}) event.Tags = event.Tags.AppendUnique(nostr.Tag{"t", "blossom"}) // Sign the event err := event.Sign(m.privateKey) if err != nil { return nil, fmt.Errorf("error signing event: %w", err) } return event, nil } func (m *MockPublisher) PublishTorrentAnnouncement(ctx context.Context, data TorrentEventData) (*nostr.Event, error) { event, err := m.CreateTorrentEvent(data) if err != nil { return nil, err } // Store event instead of publishing m.events = append(m.events, event) log.Printf("Mock: Would publish NIP-35 event (ID: %s) to relays", event.ID) log.Printf("Mock: Event content: %s", event.Content) log.Printf("Mock: Event tags: %v", event.Tags) return event, nil } func (m *MockPublisher) GetEvents() []*nostr.Event { return m.events }