// internal/nostr/events/events.go package events import ( "encoding/json" "fmt" "time" "regexp" "strings" "github.com/nbd-wtf/go-nostr" "git.sovbit.dev/Enki/nostr-poster/internal/models" ) // EventManager handles creation and signing of Nostr events type EventManager struct { // The private key getter function returns a private key for the given pubkey getPrivateKey func(pubkey string) (string, error) } // NewEventManager creates a new EventManager func NewEventManager(getPrivateKey func(pubkey string) (string, error)) *EventManager { return &EventManager{ getPrivateKey: getPrivateKey, } } // CreateAndSignMetadataEvent creates and signs a kind 0 metadata event for the bot func (em *EventManager) CreateAndSignMetadataEvent(bot *models.Bot) (*nostr.Event, error) { // Create the metadata structure metadata := map[string]interface{}{ "name": bot.Name, "display_name": bot.DisplayName, "about": bot.Bio, } // Add optional fields if they exist if bot.Nip05 != "" { metadata["nip05"] = bot.Nip05 } if bot.ProfilePicture != "" { metadata["picture"] = bot.ProfilePicture } if bot.Banner != "" { metadata["banner"] = bot.Banner } // Convert metadata to JSON content, err := json.Marshal(metadata) if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } // Create the event ev := nostr.Event{ Kind: 0, CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: []nostr.Tag{}, Content: string(content), } // Sign the event if err := em.SignEvent(&ev, bot.Pubkey); err != nil { return nil, err } return &ev, nil } // CreateAndSignTextNoteEvent creates and signs a kind 1 text note event func (em *EventManager) CreateAndSignTextNoteEvent( pubkey string, content string, tags []nostr.Tag, ) (*nostr.Event, error) { // Create the event ev := nostr.Event{ Kind: 1, CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: tags, Content: content, } // Sign the event if err := em.SignEvent(&ev, pubkey); err != nil { return nil, err } return &ev, nil } // CreateAndSignMediaEvent creates and signs a kind 1 event with media attachment func (em *EventManager) CreateAndSignMediaEvent( pubkey string, content string, mediaURL string, mediaType string, mediaHash string, altText string, hashtags []string, ) (*nostr.Event, error) { // Create the imeta tag for the media imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType} // Add hash if available if mediaHash != "" { imeta = append(imeta, "x "+mediaHash) } // Add alt text if available if altText != "" { imeta = append(imeta, "alt "+altText) } // Create tags tags := []nostr.Tag{imeta} // Add hashtags for _, tag := range hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Create the event ev := nostr.Event{ Kind: 1, CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: tags, Content: content, } // Sign the event if err := em.SignEvent(&ev, pubkey); err != nil { return nil, err } return &ev, nil } // Updated CreateAndSignPictureEvent to properly format events according to NIP-68 func (em *EventManager) CreateAndSignPictureEvent( pubkey string, title string, description string, mediaURL string, mediaType string, mediaHash string, altText string, hashtags []string, ) (*nostr.Event, error) { // For picture events (kind 20), we need to follow NIP-68 format // Start with the required title tag tags := []nostr.Tag{ {"title", title}, } // Process media URLs from the description var contentWithoutURLs string // If no explicit mediaURL is provided, try to extract from description if mediaURL == "" { // Simple regex to extract URLs re := regexp.MustCompile(`https?://[^\s]+`) matches := re.FindAllString(description, -1) if len(matches) > 0 { mediaURL = matches[0] // Remove the URL from the description for cleaner content contentWithoutURLs = re.ReplaceAllString(description, "") contentWithoutURLs = strings.TrimSpace(contentWithoutURLs) } else { // If no URL in description either, we'll just use the description as is contentWithoutURLs = description } } else { // We have an explicit mediaURL, make sure it's not in the description contentWithoutURLs = strings.ReplaceAll(description, mediaURL, "") contentWithoutURLs = strings.TrimSpace(contentWithoutURLs) } // Create the imeta tag according to NIP-92 format if mediaURL != "" { // imeta tag needs to be an array of strings imetaTag := []string{"imeta"} // Add URL (must be space-delimited key/value) imetaTag = append(imetaTag, "url "+mediaURL) // Add media type (must be space-delimited key/value) if mediaType != "" { imetaTag = append(imetaTag, "m "+mediaType) } // Add hash if available (must be space-delimited key/value) if mediaHash != "" { imetaTag = append(imetaTag, "x "+mediaHash) } // Add alt text if available (must be space-delimited key/value) if altText != "" { imetaTag = append(imetaTag, "alt "+altText) } // Add to tags - nostr.Tag is just a []string so this works tags = append(tags, imetaTag) // Add the media type as a separate 'm' tag for filtering (required by NIP-68) if mediaType != "" { tags = append(tags, nostr.Tag{"m", mediaType}) } // Add the hash as a separate 'x' tag for querying (required by NIP-68) if mediaHash != "" { tags = append(tags, nostr.Tag{"x", mediaHash}) } } // Add hashtags for _, tag := range hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Create the event ev := nostr.Event{ Kind: 20, // NIP-68 Picture Event CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: tags, Content: contentWithoutURLs, // Use the cleaned content without URLs } // Sign the event if err := em.SignEvent(&ev, pubkey); err != nil { return nil, fmt.Errorf("failed to sign picture event: %w", err) } return &ev, nil } // CreateAndSignVideoEvent creates and signs a kind 21/22 video event (NIP-71) func (em *EventManager) CreateAndSignVideoEvent( pubkey string, title string, description string, mediaURL string, mediaType string, mediaHash string, previewImageURL string, duration int, altText string, hashtags []string, isShortVideo bool, ) (*nostr.Event, error) { // Create the imeta tag for the media imeta := []string{ "imeta", "url " + mediaURL, "m " + mediaType, } // Add hash if available if mediaHash != "" { imeta = append(imeta, "x "+mediaHash) } // Add preview image if available if previewImageURL != "" { imeta = append(imeta, "image "+previewImageURL) } // Create tags tags := []nostr.Tag{ {"title", title}, imeta, } // Add duration if available if duration > 0 { tags = append(tags, nostr.Tag{"duration", fmt.Sprintf("%d", duration)}) } // Add alt text if available if altText != "" { tags = append(tags, nostr.Tag{"alt", altText}) } // Add hashtags for _, tag := range hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Choose the right kind based on video type kind := 21 // Regular video if isShortVideo { kind = 22 // Short video } // Create the event ev := nostr.Event{ Kind: kind, CreatedAt: nostr.Timestamp(time.Now().Unix()), Tags: tags, Content: description, } // Sign the event if err := em.SignEvent(&ev, pubkey); err != nil { return nil, err } return &ev, nil } // SignEvent signs a Nostr event using the private key for the given pubkey func (em *EventManager) SignEvent(ev *nostr.Event, pubkey string) error { // Set the public key ev.PubKey = pubkey // Get the private key privkey, err := em.getPrivateKey(pubkey) if err != nil { return fmt.Errorf("failed to get private key: %w", err) } // Sign the event return ev.Sign(privkey) }