You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
260 lines
7.0 KiB
260 lines
7.0 KiB
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) |
|
} |
|
|
|
// Validate that PubKey is set |
|
if event.PubKey == "" { |
|
return nil, fmt.Errorf("repository announcement event missing pubkey") |
|
} |
|
|
|
repo := &RepoAnnouncement{ |
|
Event: event, |
|
Pubkey: event.PubKey, |
|
} |
|
|
|
// Extract d tag |
|
repo.DTag = getDTag(event.Tags) |
|
|
|
// Validate that DTag is set |
|
if repo.DTag == "" { |
|
return nil, fmt.Errorf("repository announcement event missing d tag") |
|
} |
|
|
|
// Extract relays and maintainers tags |
|
repo.Relays = getAllTagValues(event.Tags, "relays") |
|
repo.Maintainers = getAllTagValues(event.Tags, "maintainers") |
|
|
|
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", "<kind>:<pubkey>:<d-tag>"] |
|
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) |
|
// Note: SimplePool manages connections, but we close here for explicit cleanup |
|
// Closing after publish attempt ensures cleanup regardless of success/failure |
|
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) |
|
// Note: SimplePool manages connections, but we close here for explicit cleanup |
|
// Closing after publish attempt ensures cleanup regardless of success/failure |
|
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 |
|
}
|
|
|