enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

282 lines
7.2 KiB
Go

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
}