// 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 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) { // Replace with a valid bot's public key. botPubkey := "your_valid_bot_pubkey_here" privkey, err := keyStore.GetPrivateKey(botPubkey) if err != nil { return "", err } return nip94.CreateNIP98AuthHeader(url, method, payload, privkey) }, ) // Blossom uploader blossomUploader := blossom.NewUploader( cfg.Media.Blossom.ServerURL, logger, func(url, method string) (string, error) { // Replace with the appropriate bot's public key botPubkey := "your_valid_bot_pubkey_here" privkey, err := keyStore.GetPrivateKey(botPubkey) if err != nil { return "", 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, logger, ) // Create standard post content function postContentFunc := poster.CreatePostContentFunc( eventManager, relayManager, mediaPrep, logger, ) // Create the NIP-19 encoded post content function postContentEncodedFunc := func( pubkey string, contentPath string, contentType string, mediaURL string, mediaHash string, caption string, hashtags []string, ) (*models.EventResponse, error) { // 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 err 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, " ") } content := caption + hashtagStr event, err = eventManager.CreateAndSignMediaEvent( pubkey, content, 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 = 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, err = eventManager.CreateAndSignMediaEvent( pubkey, caption, mediaURL, contentType, mediaHash, altText, hashtags, ) } if err != nil { return nil, fmt.Errorf("failed to create event: %w", err) } // 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, // New encoded function ) // Initialize API apiServer := api.NewAPI( logger, botService, authService, posterScheduler, ) // 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)) } }