package nostr import ( "context" "encoding/hex" "fmt" "time" "github.com/nbd-wtf/go-nostr" ) // IssueService handles publishing issue events type IssueService struct { client *Client issueKind int // Issue event kind (from config) repoAnnouncementKind int // Repo announcement kind (from config) } // NewIssueService creates a new issue service func NewIssueService(client *Client, issueKind int, repoAnnouncementKind int) *IssueService { return &IssueService{ client: client, issueKind: issueKind, repoAnnouncementKind: repoAnnouncementKind, } } // RepoAnnouncement represents a parsed kind 30617 repository announcement type RepoAnnouncement struct { Event *nostr.Event Pubkey string DTag string Relays []string Maintainers []string } // ParseRepoAnnouncement parses a repository announcement event func ParseRepoAnnouncement(event *nostr.Event, expectedKind int) (*RepoAnnouncement, error) { if event.Kind != expectedKind { return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind) } repo := &RepoAnnouncement{ Event: event, Pubkey: event.PubKey, } // Extract d tag for _, tag := range event.Tags { if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 { repo.DTag = tag[1] break } } // Extract relays tag for _, tag := range event.Tags { if len(tag) > 0 && tag[0] == "relays" && len(tag) > 1 { repo.Relays = append(repo.Relays, tag[1:]...) } } // Extract maintainers tag for _, tag := range event.Tags { if len(tag) > 0 && tag[0] == "maintainers" && len(tag) > 1 { repo.Maintainers = append(repo.Maintainers, tag[1:]...) } } return repo, nil } // FetchRepoAnnouncement fetches a repository announcement by naddr func (s *IssueService) FetchRepoAnnouncement(ctx context.Context, repoNaddr string) (*RepoAnnouncement, error) { // Parse the naddr naddr, err := ParseNaddr(repoNaddr) if err != nil { return nil, fmt.Errorf("failed to parse repo naddr: %w", err) } if naddr.Kind != s.repoAnnouncementKind { return nil, fmt.Errorf("expected kind %d, got %d", s.repoAnnouncementKind, naddr.Kind) } // Fetch the event filter := naddr.ToFilter() logFilter(filter, fmt.Sprintf("repo announcement (kind %d)", s.repoAnnouncementKind)) event, err := s.client.FetchEvent(ctx, filter) if err != nil { return nil, fmt.Errorf("failed to fetch repo announcement: %w", err) } return ParseRepoAnnouncement(event, s.repoAnnouncementKind) } // IssueRequest represents a request to create an issue type IssueRequest struct { Subject string Content string Labels []string } // PublishIssue publishes a kind 1621 issue event to the repository // If privateKey is empty, a random key will be generated (anonymous submission) func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoAnnouncement, req *IssueRequest, privateKey string) (string, error) { // Generate or use provided private key var privKeyHex string if privateKey == "" { // Generate a random key for anonymous submission privKeyHex = nostr.GeneratePrivateKey() } else { // Validate the provided key keyBytes, err := hex.DecodeString(privateKey) if err != nil { return "", fmt.Errorf("invalid private key: %w", err) } if len(keyBytes) != 32 { return "", fmt.Errorf("private key must be 32 bytes (64 hex characters)") } privKeyHex = privateKey } // Get public key from private key pubkey, err := nostr.GetPublicKey(privKeyHex) if err != nil { return "", fmt.Errorf("failed to get public key: %w", err) } // Create the issue event event := &nostr.Event{ PubKey: pubkey, Kind: s.issueKind, Content: req.Content, CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: nostr.Tags{}, } // Add 'a' tag pointing to the repository announcement // Format: ["a", "::"] event.Tags = append(event.Tags, nostr.Tag{"a", fmt.Sprintf("%d:%s:%s", s.repoAnnouncementKind, repoAnnouncement.Pubkey, repoAnnouncement.DTag)}) // Add 'p' tag for repository owner event.Tags = append(event.Tags, nostr.Tag{"p", repoAnnouncement.Pubkey}) // Add maintainers as 'p' tags for _, maintainer := range repoAnnouncement.Maintainers { event.Tags = append(event.Tags, nostr.Tag{"p", maintainer}) } // Add subject tag if provided if req.Subject != "" { event.Tags = append(event.Tags, nostr.Tag{"subject", req.Subject}) } // Add label tags for _, label := range req.Labels { if label != "" { event.Tags = append(event.Tags, nostr.Tag{"t", label}) } } // Sign the event if err := event.Sign(privKeyHex); err != nil { return "", fmt.Errorf("failed to sign event: %w", err) } // Determine which relays to publish to relays := repoAnnouncement.Relays if len(relays) == 0 { // Fallback to default relays if none specified clientRelays := s.client.GetRelays() if len(clientRelays) > 0 { relays = clientRelays } } // Publish to relays var lastErr error successCount := 0 for _, relayURL := range relays { relay, err := s.client.ConnectToRelay(ctx, relayURL) if err != nil { lastErr = err continue } err = relay.Publish(ctx, *event) relay.Close() if err != nil { lastErr = err continue } // Publish succeeded successCount++ } if successCount == 0 && lastErr != nil { return "", fmt.Errorf("failed to publish to any relay: %w", lastErr) } return event.ID, nil } // PublishSignedIssue publishes a pre-signed issue event (signed by browser) func (s *IssueService) PublishSignedIssue(ctx context.Context, signedEvent *nostr.Event) (string, error) { // Validate the event if signedEvent.Kind != s.issueKind { return "", fmt.Errorf("expected kind %d, got %d", s.issueKind, signedEvent.Kind) } // Verify the event signature valid, err := signedEvent.CheckSignature() if err != nil { return "", fmt.Errorf("failed to check signature: %w", err) } if !valid { return "", fmt.Errorf("invalid event signature") } // Determine which relays to publish to // Try to extract relays from the event tags or use defaults relays := s.client.GetRelays() // Look for relay hints in tags for _, tag := range signedEvent.Tags { if len(tag) > 0 && tag[0] == "relays" { if len(tag) > 1 { relays = tag[1:] break } } } // Publish to relays var lastErr error successCount := 0 for _, relayURL := range relays { relay, err := s.client.ConnectToRelay(ctx, relayURL) if err != nil { lastErr = err continue } err = relay.Publish(ctx, *signedEvent) relay.Close() if err != nil { lastErr = err continue } // Publish succeeded successCount++ } if successCount == 0 && lastErr != nil { return "", fmt.Errorf("failed to publish to any relay: %w", lastErr) } return signedEvent.ID, nil }