298 lines
7.7 KiB
Go

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