package nostr import ( "context" "encoding/hex" "fmt" "time" "github.com/nbd-wtf/go-nostr" ) // IssueService handles publishing kind 1621 issue events type IssueService struct { client *Client } // NewIssueService creates a new issue service func NewIssueService(client *Client) *IssueService { return &IssueService{ client: client, } } // 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 kind 30617 repository announcement event func ParseRepoAnnouncement(event *nostr.Event) (*RepoAnnouncement, error) { if event.Kind != 30617 { return nil, fmt.Errorf("expected kind 30617, got %d", 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 != 30617 { return nil, fmt.Errorf("expected kind 30617, got %d", naddr.Kind) } // Fetch the event filter := naddr.ToFilter() event, err := s.client.FetchEvent(ctx, filter) if err != nil { return nil, fmt.Errorf("failed to fetch repo announcement: %w", err) } return ParseRepoAnnouncement(event) } // 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: 1621, Content: req.Content, CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: nostr.Tags{}, } // Add 'a' tag pointing to the repository announcement // Format: ["a", "30617::"] event.Tags = append(event.Tags, nostr.Tag{"a", fmt.Sprintf("30617:%s:%s", 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 relays = []string{s.client.primaryRelay, s.client.fallbackRelay} } // 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 }