// internal/nostr/poster/poster.go package poster import ( "context" "fmt" "path/filepath" "strings" "time" "github.com/nbd-wtf/go-nostr" "git.sovbit.dev/Enki/nostr-poster/internal/media/prepare" "git.sovbit.dev/Enki/nostr-poster/internal/models" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/events" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" "go.uber.org/zap" ) // Poster handles posting content to Nostr type Poster struct { eventMgr *events.EventManager relayMgr *relay.Manager mediaPrep *prepare.Manager logger *zap.Logger } // NewPoster creates a new content poster func NewPoster( eventMgr *events.EventManager, relayMgr *relay.Manager, mediaPrep *prepare.Manager, logger *zap.Logger, ) *Poster { if logger == nil { // Create a default logger if none is provided var err error logger, err = zap.NewProduction() if err != nil { // If we can't create a logger, use a no-op logger logger = zap.NewNop() } } return &Poster{ eventMgr: eventMgr, relayMgr: relayMgr, mediaPrep: mediaPrep, logger: logger, } } // PostContent posts content to Nostr func (p *Poster) PostContent( pubkey string, contentPath string, contentType string, mediaURL string, mediaHash string, caption string, hashtags []string, ) error { // Get post mode from the database or default to kind20 postMode := "kind20" // Logger post mode for easier debugging p.logger.Info("Using post mode in PostContent", zap.String("post_mode", postMode)) // Determine the type of content isImage := strings.HasPrefix(contentType, "image/") isVideo := strings.HasPrefix(contentType, "video/") // Create alt text if not provided (initialize it here) altText := caption if altText == "" { // Use the filename without extension as a fallback altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath)) } // Extract media dimensions if it's an image if isImage { dims, err := p.mediaPrep.GetMediaDimensions(contentPath) if err != nil { p.logger.Warn("Failed to get image dimensions, continuing anyway", zap.String("file", contentPath), zap.Error(err)) } else { // Log the dimensions p.logger.Debug("Image dimensions", zap.Int("width", dims.Width), zap.Int("height", dims.Height)) // Add dimensions to alt text if available if altText != "" { altText = fmt.Sprintf("%s [%dx%d]", altText, dims.Width, dims.Height) } } } // Create a context with timeout ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var event *nostr.Event var err error // Create hashtag string for post content var hashtagStr string if len(hashtags) > 0 { hashtagArr := make([]string, len(hashtags)) for i, tag := range hashtags { hashtagArr[i] = "#" + tag } hashtagStr = "\n\n" + strings.Join(hashtagArr, " ") } // Don't use filename as content, just use hashtags content := hashtagStr // If postMode is empty, default to kind20 if postMode == "" { postMode = "kind20" } // Determine the appropriate event kind and create the event if isImage { // Log the media URL for debugging p.logger.Info("Creating image post with media URL", zap.String("mediaURL", mediaURL), zap.String("mediaHash", mediaHash), zap.String("contentType", contentType), zap.String("postMode", postMode)) // Handle different post modes if postMode == "hybrid" { // In hybrid mode, we post both a kind1 and kind20 event // Create a kind1 text post with media attachment and both imeta and URL tags for maximum compatibility var tags []nostr.Tag // Add hashtags to tags for _, tag := range hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Create imeta tag for metadata imeta := []string{"imeta", "url " + mediaURL, "m " + contentType} if mediaHash != "" { imeta = append(imeta, "x "+mediaHash) } if altText != "" { imeta = append(imeta, "alt "+altText) } tags = append(tags, imeta) // Add multiple tag types for maximum client compatibility tags = append(tags, nostr.Tag{"url", mediaURL}) tags = append(tags, nostr.Tag{"image", mediaURL}) tags = append(tags, nostr.Tag{"media", mediaURL}) tags = append(tags, nostr.Tag{"picture", mediaURL}) // Log the tags we're using p.logger.Debug("Creating kind1 event with media tags", zap.Any("tags", tags), zap.String("mediaURL", mediaURL)) // Create content with URL directly embedded for better client compatibility content := "" if caption != "" { content = caption + "\n\n" } // Add the media URL directly in the content content += mediaURL + "\n\n" // Add hashtags at the end content += hashtagStr // Use CreateAndSignTextNoteEvent instead of Media event for more control over tags textEvent, textErr := p.eventMgr.CreateAndSignTextNoteEvent( pubkey, content, tags, ) if textErr == nil { // Publish the kind1 event ctx1, cancel1 := context.WithTimeout(context.Background(), 30*time.Second) defer cancel1() p.relayMgr.PublishEvent(ctx1, textEvent) p.logger.Info("Published kind1 text event in hybrid mode", zap.String("event_id", textEvent.ID)) } else { p.logger.Error("Failed to create kind1 event in hybrid mode", zap.Error(textErr)) } // Then continue with the kind20 event event, err = p.eventMgr.CreateAndSignPictureEvent( pubkey, "", // Empty title instead of using caption content, // Description mediaURL, contentType, mediaHash, altText, hashtags, ) } else if postMode == "kind1" { // Use kind 1 (text note) with media attachment - enhanced for better compatibility // Create tags for better compatibility var tags []nostr.Tag // Add hashtags to tags for _, tag := range hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Create imeta tag for metadata imeta := []string{"imeta", "url " + mediaURL, "m " + contentType} if mediaHash != "" { imeta = append(imeta, "x "+mediaHash) } if altText != "" { imeta = append(imeta, "alt "+altText) } tags = append(tags, imeta) // Add multiple tag types for maximum client compatibility tags = append(tags, nostr.Tag{"url", mediaURL}) tags = append(tags, nostr.Tag{"image", mediaURL}) tags = append(tags, nostr.Tag{"media", mediaURL}) tags = append(tags, nostr.Tag{"picture", mediaURL}) // Create content with URL directly embedded for better client compatibility content := "" if caption != "" { content = caption + "\n\n" } // Add the media URL directly in the content content += mediaURL + "\n\n" // Add hashtags at the end content += hashtagStr // Use CreateAndSignTextNoteEvent instead of MediaEvent for more control over tags event, err = p.eventMgr.CreateAndSignTextNoteEvent( pubkey, content, tags, ) } else { // Default: Use kind 20 (picture post) for better media compatibility event, err = p.eventMgr.CreateAndSignPictureEvent( pubkey, "", // Empty title instead of using caption content, // Description mediaURL, contentType, mediaHash, altText, hashtags, ) } } else if isVideo { // For videos, determine if it's a short video isShortVideo := false // Just a placeholder, would need logic to determine // Create the video event event, err = p.eventMgr.CreateAndSignVideoEvent( pubkey, caption, // Title caption, // Description mediaURL, contentType, mediaHash, "", // Preview image URL 0, // Duration altText, hashtags, isShortVideo, ) } else { // For other types, use a regular text note with attachment event, err = p.eventMgr.CreateAndSignMediaEvent( pubkey, caption, mediaURL, contentType, mediaHash, altText, hashtags, ) } if err != nil { return fmt.Errorf("failed to create event: %w", err) } // Publish the event relays, err := p.relayMgr.PublishEvent(ctx, event) if err != nil { return fmt.Errorf("failed to publish event: %w", err) } p.logger.Info("Published content to relays", zap.String("event_id", event.ID), zap.Strings("relays", relays)) return nil } // CreatePostContentFunc creates a function for posting content // This returns the OLD function signature for backward compatibility func CreatePostContentFunc( eventMgr *events.EventManager, relayMgr *relay.Manager, mediaPrep *prepare.Manager, logger *zap.Logger, ) func(string, string, string, string, string, string, []string) error { poster := NewPoster(eventMgr, relayMgr, mediaPrep, logger) return func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) error { return poster.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags) } } // CreatePostContentEncodedFunc creates a function for posting content with NIP-19 encoding // This returns the OLD function signature for backward compatibility func CreatePostContentEncodedFunc( eventMgr *events.EventManager, relayMgr *relay.Manager, mediaPrep *prepare.Manager, logger *zap.Logger, ) func(string, string, string, string, string, string, []string) (*models.EventResponse, error) { poster := NewPoster(eventMgr, relayMgr, mediaPrep, logger) return func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) (*models.EventResponse, error) { return poster.PostContentWithEncoding(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags) } } // PostContentWithEncoding posts content to Nostr and returns NIP-19 encoded event references func (p *Poster) PostContentWithEncoding( pubkey string, contentPath string, contentType string, mediaURL string, mediaHash string, caption string, hashtags []string, ) (*models.EventResponse, error) { // Get post mode from the database or default to kind20 postMode := "kind20" // Log post mode for easier debugging p.logger.Info("Using post mode in PostContentWithEncoding", zap.String("post_mode", postMode)) // Post content as usual err := p.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags) if err != nil { return nil, err } // For simplicity, we'll just return a basic response // In a real implementation, you would capture the event ID from PostContent return &models.EventResponse{ ID: "event_id", Note: "note1...", Nevent: "nevent1...", }, nil }