543 lines
16 KiB
Go
543 lines
16 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 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))
|
|
}
|
|
} |