// cmd/server/main.go package main import ( "context" "flag" "fmt" "os" "path/filepath" "strings" "time" "github.com/nbd-wtf/go-nostr" "git.sovbit.dev/Enki/nostr-poster/internal/api" "git.sovbit.dev/Enki/nostr-poster/internal/auth" "git.sovbit.dev/Enki/nostr-poster/internal/config" "git.sovbit.dev/Enki/nostr-poster/internal/crypto" "git.sovbit.dev/Enki/nostr-poster/internal/db" "git.sovbit.dev/Enki/nostr-poster/internal/media/prepare" "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom" "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94" "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/poster" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" "git.sovbit.dev/Enki/nostr-poster/internal/utils" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) func main() { // Parse command line flags configPath := flag.String("config", "", "Path to config file") dbPath := flag.String("db", "", "Path to database file") port := flag.Int("port", 0, "Port to listen on") password := flag.String("password", "", "Password for encrypting private keys") flag.Parse() // Setup logger logConfig := zap.NewProductionConfig() logConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder logger, err := logConfig.Build() if err != nil { fmt.Fprintf(os.Stderr, "Failed to create logger: %v\n", err) os.Exit(1) } defer logger.Sync() // Load config cfg, err := config.LoadConfig(*configPath) if err != nil { logger.Fatal("Failed to load config", zap.Error(err)) } // Override config with command line flags if provided if *dbPath != "" { cfg.DB.Path = *dbPath } if *port != 0 { cfg.ServerPort = *port } // Ensure directories exist if err := utils.EnsureDir(cfg.Bot.ContentDir); err != nil { logger.Fatal("Failed to create content directory", zap.Error(err)) } if err := utils.EnsureDir(cfg.Bot.ArchiveDir); err != nil { logger.Fatal("Failed to create archive directory", zap.Error(err)) } // Initialize database database, err := db.New(cfg.DB.Path) if err != nil { logger.Fatal("Failed to connect to database", zap.Error(err)) } if err := database.Initialize(); err != nil { logger.Fatal("Failed to initialize database", zap.Error(err)) } // Initialize key store keyPassword := *password if keyPassword == "" { // Use a default password or prompt for one // In a production environment, you'd want to handle this more securely keyPassword = "nostr-poster-default-password" } keyStore, err := crypto.NewKeyStore(cfg.Bot.KeysFile, keyPassword) if err != nil { logger.Fatal("Failed to initialize key store", zap.Error(err)) } // Initialize event manager eventManager := events.NewEventManager(func(pubkey string) (string, error) { return keyStore.GetPrivateKey(pubkey) }) // Initialize relay manager relayManager := relay.NewManager(logger) // Initialize global relay service globalRelayService := api.NewGlobalRelayService(database, logger) // Initialize media preparation manager mediaPrep := prepare.NewManager(logger) // Initialize uploaders // NIP-94 uploader nip94Uploader := nip94.NewUploader( cfg.Media.NIP94.ServerURL, "", // Download URL will be discovered nil, // Supported types will be discovered logger, func(url, method string, payload []byte) (string, error) { // Get an active bot from the database var bot struct { Pubkey string } err := database.Get(&bot, "SELECT pubkey FROM bots LIMIT 1") if err != nil { return "", fmt.Errorf("no bots found for auth: %w", err) } // Get the private key for this bot privkey, err := keyStore.GetPrivateKey(bot.Pubkey) if err != nil { return "", fmt.Errorf("failed to get private key: %w", err) } return nip94.CreateNIP98AuthHeader(url, method, payload, privkey) }, ) // Blossom uploader blossomUploader := blossom.NewUploader( cfg.Media.Blossom.ServerURL, logger, func(url, method string) (string, error) { // Get an active bot from the database var bot struct { Pubkey string } err := database.Get(&bot, "SELECT pubkey FROM bots LIMIT 1") if err != nil { return "", fmt.Errorf("no bots found for auth: %w", err) } // Get the private key for this bot privkey, err := keyStore.GetPrivateKey(bot.Pubkey) if err != nil { return "", fmt.Errorf("failed to get private key: %w", err) } return blossom.CreateBlossomAuthHeader(url, method, privkey) }, ) // Initialize authentication service authService := auth.NewService( database, logger, keyPassword, // Use the same password for simplicity 24*time.Hour, // Token duration ) // Initialize bot service botService := api.NewBotService( database, keyStore, eventManager, relayManager, globalRelayService, logger, ) // Create standard post content function - updated to support post modes postContentFunc := func(pubkey, contentPath, contentType, mediaURL, mediaHash, caption string, hashtags []string) error { // Need to get the bot's post mode from the database var botID int64 var postMode string // Try to get the bot ID and post mode from the database err := database.Get(&botID, "SELECT id FROM bots WHERE pubkey = ?", pubkey) if err == nil { // Get the post mode for this bot err = database.Get(&postMode, "SELECT post_mode FROM post_config WHERE bot_id = ?", botID) if err != nil || postMode == "" { // Default to kind20 if no post mode is set postMode = "kind20" logger.Info("No post mode found, using default", zap.String("pubkey", pubkey)) } else { logger.Info("Retrieved post mode from database", zap.String("post_mode", postMode), zap.Int64("bot_id", botID)) } } else { logger.Warn("Could not find bot ID for pubkey", zap.String("pubkey", pubkey), zap.Error(err)) postMode = "kind20" // Default } // Create a poster instance with the retrieved post mode p := poster.NewPoster(eventManager, relayManager, mediaPrep, logger) // Call the appropriate function based on post mode if postMode == "hybrid" { logger.Info("Using hybrid post mode", zap.String("pubkey", pubkey)) // For hybrid mode, we need to post both kinds // First create a kind1 media event var textEvent *nostr.Event var textErr error // Create text event // 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) } 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}) // 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 if any if len(hashtags) > 0 { hashtagArr := make([]string, len(hashtags)) for i, tag := range hashtags { hashtagArr[i] = "#" + tag } content += strings.Join(hashtagArr, " ") } // Create text event with embedded URL and better tagging textEvent, textErr = eventManager.CreateAndSignTextNoteEvent( pubkey, content, tags, ) if textErr == nil { // Publish the kind1 event ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() relayManager.PublishEvent(ctx, textEvent) logger.Info("Published kind1 text event in hybrid mode", zap.String("event_id", textEvent.ID)) } else { logger.Error("Failed to create kind1 event in hybrid mode", zap.Error(textErr)) } } // Always call PostContent (will use kind20 for hybrid mode) return p.PostContent(pubkey, contentPath, contentType, mediaURL, mediaHash, caption, hashtags) } // Create the NIP-19 encoded post content function - updated to share code with the regular function postContentEncodedFunc := func( pubkey string, contentPath string, contentType string, mediaURL string, mediaHash string, caption string, hashtags []string, ) (*models.EventResponse, error) { // Need to get the bot's post mode from the database var botID int64 var postMode string // Try to get the bot ID and post mode from the database err := database.Get(&botID, "SELECT id FROM bots WHERE pubkey = ?", pubkey) if err == nil { // Get the post mode for this bot err = database.Get(&postMode, "SELECT post_mode FROM post_config WHERE bot_id = ?", botID) if err != nil || postMode == "" { // Default to kind20 if no post mode is set postMode = "kind20" logger.Info("No post mode found, using default for encoded function", zap.String("pubkey", pubkey)) } else { logger.Info("Retrieved post mode from database for encoded function", zap.String("post_mode", postMode), zap.Int64("bot_id", botID)) } } else { logger.Warn("Could not find bot ID for pubkey in encoded function", zap.String("pubkey", pubkey), zap.Error(err)) postMode = "kind20" // Default } // Create a context ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Determine the type of content isImage := strings.HasPrefix(contentType, "image/") isVideo := strings.HasPrefix(contentType, "video/") // Create alt text if not provided 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 := mediaPrep.GetMediaDimensions(contentPath) if err != nil { logger.Warn("Failed to get image dimensions, continuing anyway", zap.String("file", contentPath), zap.Error(err)) } else { // Add dimensions to alt text if available if altText != "" { altText = fmt.Sprintf("%s [%dx%d]", altText, dims.Width, dims.Height) } } } // Create an appropriate event var event *nostr.Event var eventErr error if isImage { // 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, " ") } // Only include hashtags in content, not caption content := hashtagStr // Log mediaURL and postMode for debugging logger.Debug("Creating picture event with media URL and post mode", zap.String("mediaURL", mediaURL), zap.String("postMode", postMode)) // For hybrid mode, first create and post a kind1 text note if postMode == "hybrid" { // Create media tags for kind 1 var tags []nostr.Tag // Add hashtags to tags for _, tag := range hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Create imeta tag 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) // Also add a direct URL tag for better client compatibility tags = append(tags, nostr.Tag{"url", mediaURL}) // Log tags for debugging logger.Info("Creating kind1 event with media tags", zap.Any("tags", tags), zap.String("mediaURL", mediaURL)) // Create and post a kind1 text note with media attachment // Create content with the image URL directly in the content for better compatibility content := "" if caption != "" { content = caption + "\n\n" } // Add the image URL in the content content += mediaURL + "\n\n" // Add hashtags at the end content += hashtagStr textEvent, textErr := eventManager.CreateAndSignTextNoteEvent( pubkey, content, tags, ) if textErr == nil { ctx1, cancel1 := context.WithTimeout(context.Background(), 30*time.Second) defer cancel1() // Publish the kind1 event relayManager.PublishEvent(ctx1, textEvent) logger.Info("Published kind1 text event in hybrid mode (encoded function)", zap.String("event_id", textEvent.ID)) } } else if postMode == "kind1" { // If mode is kind1 only, use a kind1 event with media attachment var tags []nostr.Tag // Add hashtags to tags for _, tag := range hashtags { tags = append(tags, nostr.Tag{"t", tag}) } // Create media tags for kind 1 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) // Create and return a kind1 text note event // Create content with the image URL directly in the content for better compatibility content := "" if caption != "" { content = caption + "\n\n" } // Add the image URL in the content content += mediaURL + "\n\n" // Add hashtags at the end content += hashtagStr event, eventErr = eventManager.CreateAndSignTextNoteEvent( pubkey, content, tags, ) // Skip kind20 creation for kind1 mode return relayManager.PublishEventWithEncoding(ctx, event) } // Always create kind20 for "kind20" mode or "hybrid" mode // For picture events, use an empty title to avoid filename issues event, eventErr = eventManager.CreateAndSignPictureEvent( pubkey, "", // Empty title instead of using caption content, // Description with hashtags only 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, eventErr = eventManager.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, eventErr = eventManager.CreateAndSignMediaEvent( pubkey, caption, mediaURL, contentType, mediaHash, altText, hashtags, ) } if eventErr != nil { return nil, fmt.Errorf("failed to create event: %w", eventErr) } // Publish the event with NIP-19 encoding return relayManager.PublishEventWithEncoding(ctx, event) } // Initialize scheduler with both posting functions posterScheduler := scheduler.NewScheduler( database, logger, cfg.Bot.ContentDir, cfg.Bot.ArchiveDir, nip94Uploader, blossomUploader, postContentFunc, postContentEncodedFunc, globalRelayService, keyStore, ) // Initialize API apiServer := api.NewAPI( logger, botService, authService, posterScheduler, globalRelayService, ) // Start the scheduler if err := posterScheduler.Start(); err != nil { logger.Error("Failed to start scheduler", zap.Error(err)) } // Start the server addr := fmt.Sprintf(":%d", cfg.ServerPort) logger.Info("Starting server", zap.String("address", addr)) if err := apiServer.Run(addr); err != nil { logger.Fatal("Failed to start server", zap.Error(err)) } }