2025-05-11 19:59:36 -07:00

371 lines
10 KiB
Go

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