2025-03-01 22:53:36 -08:00

329 lines
7.7 KiB
Go

// 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)
}