451 lines
12 KiB
Go
451 lines
12 KiB
Go
// internal/scheduler/scheduler.go
|
|
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"encoding/json"
|
|
|
|
|
|
"github.com/robfig/cron/v3"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/db"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/models"
|
|
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// MediaUploader defines the interface for uploading media
|
|
type MediaUploader interface {
|
|
UploadFile(filePath string, caption string, altText string) (string, string, error)
|
|
DeleteFile(fileHash string) error
|
|
WithCustomURL(customURL string) MediaUploader
|
|
}
|
|
|
|
// PostPublisher defines the interface for publishing posts
|
|
type PostPublisher interface {
|
|
PublishEvent(ctx context.Context, event interface{}) ([]string, error)
|
|
}
|
|
|
|
// ContentPoster defines the function to post content
|
|
type ContentPoster func(
|
|
pubkey string,
|
|
contentPath string,
|
|
contentType string,
|
|
mediaURL string,
|
|
mediaHash string,
|
|
caption string,
|
|
hashtags []string,
|
|
) error
|
|
|
|
// ContentPosterWithEncoding defines a function to post content and return encoded event
|
|
type ContentPosterWithEncoding func(
|
|
pubkey string,
|
|
contentPath string,
|
|
contentType string,
|
|
mediaURL string,
|
|
mediaHash string,
|
|
caption string,
|
|
hashtags []string,
|
|
) (*models.EventResponse, error)
|
|
|
|
// Scheduler manages scheduled content posting
|
|
type Scheduler struct {
|
|
db *db.DB
|
|
cron *cron.Cron
|
|
logger *zap.Logger
|
|
contentDir string
|
|
archiveDir string
|
|
nip94Uploader MediaUploader
|
|
blossomUploader MediaUploader
|
|
postContent ContentPoster
|
|
postContentEncoded ContentPosterWithEncoding // New field for NIP-19 support
|
|
botJobs map[int64]cron.EntryID
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// NewScheduler creates a new content scheduler
|
|
func NewScheduler(
|
|
db *db.DB,
|
|
logger *zap.Logger,
|
|
contentDir string,
|
|
archiveDir string,
|
|
nip94Uploader MediaUploader,
|
|
blossomUploader MediaUploader,
|
|
postContent ContentPoster,
|
|
postContentEncoded ContentPosterWithEncoding, // New parameter for NIP-19 support
|
|
) *Scheduler {
|
|
if logger == nil {
|
|
// Create a default logger
|
|
var err error
|
|
logger, err = zap.NewProduction()
|
|
if err != nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
}
|
|
|
|
// Create a new cron scheduler with seconds precision
|
|
cronScheduler := cron.New(cron.WithSeconds())
|
|
|
|
return &Scheduler{
|
|
db: db,
|
|
cron: cronScheduler,
|
|
logger: logger,
|
|
contentDir: contentDir,
|
|
archiveDir: archiveDir,
|
|
nip94Uploader: nip94Uploader,
|
|
blossomUploader: blossomUploader,
|
|
postContent: postContent,
|
|
postContentEncoded: postContentEncoded, // Initialize the new field
|
|
botJobs: make(map[int64]cron.EntryID),
|
|
}
|
|
}
|
|
|
|
// Start starts the scheduler
|
|
func (s *Scheduler) Start() error {
|
|
// Load all bots with enabled post configs
|
|
query := `
|
|
SELECT b.*, pc.*, mc.*
|
|
FROM bots b
|
|
JOIN post_config pc ON b.id = pc.bot_id
|
|
JOIN media_config mc ON b.id = mc.bot_id
|
|
WHERE pc.enabled = 1
|
|
`
|
|
|
|
rows, err := s.db.Queryx(query)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load bots: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Process each bot
|
|
for rows.Next() {
|
|
var bot models.Bot
|
|
var postConfig models.PostConfig
|
|
var mediaConfig models.MediaConfig
|
|
|
|
// Map the results to our structs
|
|
err := rows.Scan(
|
|
&bot.ID, &bot.Pubkey, &bot.EncryptedPrivkey, &bot.Name, &bot.DisplayName,
|
|
&bot.Bio, &bot.Nip05, &bot.ZapAddress, &bot.ProfilePicture, &bot.Banner,
|
|
&bot.CreatedAt, &bot.OwnerPubkey,
|
|
&postConfig.ID, &postConfig.BotID, &postConfig.Hashtags, &postConfig.IntervalMinutes,
|
|
&postConfig.PostTemplate, &postConfig.Enabled,
|
|
&mediaConfig.ID, &mediaConfig.BotID, &mediaConfig.PrimaryService,
|
|
&mediaConfig.FallbackService, &mediaConfig.Nip94ServerURL, &mediaConfig.BlossomServerURL,
|
|
)
|
|
if err != nil {
|
|
s.logger.Error("Failed to scan bot row", zap.Error(err))
|
|
continue
|
|
}
|
|
|
|
// Set the associated config
|
|
bot.PostConfig = &postConfig
|
|
bot.MediaConfig = &mediaConfig
|
|
|
|
// Schedule the bot
|
|
if err := s.ScheduleBot(&bot); err != nil {
|
|
s.logger.Error("Failed to schedule bot",
|
|
zap.String("name", bot.Name),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// Start the cron scheduler
|
|
s.cron.Start()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops the scheduler
|
|
func (s *Scheduler) Stop() {
|
|
if s.cron != nil {
|
|
s.cron.Stop()
|
|
}
|
|
}
|
|
|
|
// ScheduleBot schedules posting for a specific bot
|
|
func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Unschedule if already scheduled
|
|
if entryID, exists := s.botJobs[bot.ID]; exists {
|
|
s.cron.Remove(entryID)
|
|
delete(s.botJobs, bot.ID)
|
|
}
|
|
|
|
// Create bot-specific content directory if it doesn't exist
|
|
botContentDir := filepath.Join(s.contentDir, fmt.Sprintf("bot_%d", bot.ID))
|
|
if err := utils.EnsureDir(botContentDir); err != nil {
|
|
return fmt.Errorf("failed to create bot content directory: %w", err)
|
|
}
|
|
|
|
// Create bot-specific archive directory if it doesn't exist
|
|
botArchiveDir := filepath.Join(s.archiveDir, fmt.Sprintf("bot_%d", bot.ID))
|
|
if err := utils.EnsureDir(botArchiveDir); err != nil {
|
|
return fmt.Errorf("failed to create bot archive directory: %w", err)
|
|
}
|
|
|
|
// Parse hashtags
|
|
var hashtags []string
|
|
if bot.PostConfig.Hashtags != "" {
|
|
if err := json.Unmarshal([]byte(bot.PostConfig.Hashtags), &hashtags); err != nil {
|
|
s.logger.Warn("Failed to parse hashtags",
|
|
zap.String("hashtags", bot.PostConfig.Hashtags),
|
|
zap.Error(err))
|
|
hashtags = []string{}
|
|
}
|
|
}
|
|
|
|
// Create the schedule expression
|
|
// Format: "0 */X * * * *" where X is the interval in minutes
|
|
// This runs at 0 seconds, every X minutes
|
|
schedule := fmt.Sprintf("0 */%d * * * *", bot.PostConfig.IntervalMinutes)
|
|
|
|
// Create a job function that captures the bot's information
|
|
jobFunc := func() {
|
|
s.logger.Info("Running scheduled post",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("name", bot.Name))
|
|
|
|
// Get a random media file
|
|
contentPath, err := utils.GetRandomFile(botContentDir, utils.GetAllSupportedMediaExtensions())
|
|
if err != nil {
|
|
s.logger.Error("Failed to get random file",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
|
|
// Get content type
|
|
contentType, err := utils.GetFileContentType(contentPath)
|
|
if err != nil {
|
|
s.logger.Error("Failed to determine content type",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("file", contentPath),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
|
|
// Select the appropriate uploader
|
|
var uploader MediaUploader
|
|
if bot.MediaConfig.PrimaryService == "blossom" {
|
|
uploader = s.blossomUploader
|
|
} else {
|
|
uploader = s.nip94Uploader
|
|
}
|
|
|
|
// Upload the file
|
|
mediaURL, mediaHash, err := uploader.UploadFile(contentPath, "", "")
|
|
if err != nil {
|
|
// Try fallback if available
|
|
if bot.MediaConfig.FallbackService != "" && bot.MediaConfig.FallbackService != bot.MediaConfig.PrimaryService {
|
|
s.logger.Warn("Primary upload failed, trying fallback",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("primary", bot.MediaConfig.PrimaryService),
|
|
zap.String("fallback", bot.MediaConfig.FallbackService),
|
|
zap.Error(err))
|
|
|
|
if bot.MediaConfig.FallbackService == "blossom" {
|
|
uploader = s.blossomUploader
|
|
} else {
|
|
uploader = s.nip94Uploader
|
|
}
|
|
|
|
mediaURL, mediaHash, err = uploader.UploadFile(contentPath, "", "")
|
|
if err != nil {
|
|
s.logger.Error("Fallback upload failed",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("file", contentPath),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
} else {
|
|
s.logger.Error("File upload failed",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("file", contentPath),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get the base filename without extension
|
|
filename := filepath.Base(contentPath)
|
|
caption := strings.TrimSuffix(filename, filepath.Ext(filename))
|
|
|
|
// Post the content - use encoded version if available, otherwise use the original
|
|
var postErr error
|
|
|
|
if s.postContentEncoded != nil {
|
|
// Use the NIP-19 encoded version
|
|
encodedEvent, err := s.postContentEncoded(
|
|
bot.Pubkey,
|
|
contentPath,
|
|
contentType,
|
|
mediaURL,
|
|
mediaHash,
|
|
caption,
|
|
hashtags,
|
|
)
|
|
|
|
if err != nil {
|
|
s.logger.Error("Failed to post content with encoding",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("file", contentPath),
|
|
zap.Error(err))
|
|
postErr = err
|
|
} else {
|
|
// Success with encoded version
|
|
s.logger.Info("Successfully posted content with NIP-19 encoding",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("file", contentPath),
|
|
zap.String("media_url", mediaURL),
|
|
zap.String("event_id", encodedEvent.ID),
|
|
zap.String("note", encodedEvent.Note),
|
|
zap.String("nevent", encodedEvent.Nevent))
|
|
postErr = nil
|
|
}
|
|
} else {
|
|
// Fall back to original function
|
|
postErr = s.postContent(
|
|
bot.Pubkey,
|
|
contentPath,
|
|
contentType,
|
|
mediaURL,
|
|
mediaHash,
|
|
caption,
|
|
hashtags,
|
|
)
|
|
|
|
if postErr != nil {
|
|
s.logger.Error("Failed to post content",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("file", contentPath),
|
|
zap.Error(postErr))
|
|
} else {
|
|
s.logger.Info("Successfully posted content",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("file", contentPath),
|
|
zap.String("media_url", mediaURL))
|
|
}
|
|
}
|
|
|
|
// If posting failed, return without archiving the file
|
|
if postErr != nil {
|
|
return
|
|
}
|
|
|
|
// Move the file to the archive directory
|
|
archivePath := filepath.Join(botArchiveDir, filepath.Base(contentPath))
|
|
if err := utils.MoveFile(contentPath, archivePath); err != nil {
|
|
s.logger.Error("Failed to archive file",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("file", contentPath),
|
|
zap.String("archive", archivePath),
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Schedule the job
|
|
entryID, err := s.cron.AddFunc(schedule, jobFunc)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to schedule bot: %w", err)
|
|
}
|
|
|
|
// Store the entry ID
|
|
s.botJobs[bot.ID] = entryID
|
|
|
|
s.logger.Info("Bot scheduled successfully",
|
|
zap.Int64("bot_id", bot.ID),
|
|
zap.String("name", bot.Name),
|
|
zap.String("schedule", schedule))
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnscheduleBot removes a bot from the scheduler
|
|
func (s *Scheduler) UnscheduleBot(botID int64) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if entryID, exists := s.botJobs[botID]; exists {
|
|
s.cron.Remove(entryID)
|
|
delete(s.botJobs, botID)
|
|
s.logger.Info("Bot unscheduled", zap.Int64("bot_id", botID))
|
|
}
|
|
}
|
|
|
|
// UpdateBotSchedule updates a bot's schedule
|
|
func (s *Scheduler) UpdateBotSchedule(bot *models.Bot) error {
|
|
// Unschedule first
|
|
s.UnscheduleBot(bot.ID)
|
|
|
|
// Reschedule with new configuration
|
|
return s.ScheduleBot(bot)
|
|
}
|
|
|
|
// GetNIP94Uploader returns the NIP-94 uploader
|
|
func (s *Scheduler) GetNIP94Uploader() MediaUploader {
|
|
return s.nip94Uploader
|
|
}
|
|
|
|
// GetBlossomUploader returns the Blossom uploader
|
|
func (s *Scheduler) GetBlossomUploader() MediaUploader {
|
|
return s.blossomUploader
|
|
}
|
|
|
|
func (s *Scheduler) GetContentDir() string {
|
|
return s.contentDir
|
|
}
|
|
|
|
// RunNow triggers an immediate post for a bot
|
|
func (s *Scheduler) RunNow(botID int64) error {
|
|
// Load the bot with its configurations
|
|
var bot models.Bot
|
|
err := s.db.Get(&bot, "SELECT * FROM bots WHERE id = ?", botID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load bot: %w", err)
|
|
}
|
|
|
|
// Load post config
|
|
var postConfig models.PostConfig
|
|
err = s.db.Get(&postConfig, "SELECT * FROM post_config WHERE bot_id = ?", botID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load post config: %w", err)
|
|
}
|
|
bot.PostConfig = &postConfig
|
|
|
|
// Load media config
|
|
var mediaConfig models.MediaConfig
|
|
err = s.db.Get(&mediaConfig, "SELECT * FROM media_config WHERE bot_id = ?", botID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load media config: %w", err)
|
|
}
|
|
bot.MediaConfig = &mediaConfig
|
|
|
|
// Create job and run it
|
|
s.ScheduleBot(&bot)
|
|
|
|
s.mu.RLock()
|
|
entryID, exists := s.botJobs[botID]
|
|
s.mu.RUnlock()
|
|
|
|
if !exists {
|
|
return fmt.Errorf("bot not scheduled")
|
|
}
|
|
|
|
// Get the job
|
|
entry := s.cron.Entry(entryID)
|
|
if entry.Job == nil {
|
|
return fmt.Errorf("job not found")
|
|
}
|
|
|
|
// Run the job
|
|
entry.Job.Run()
|
|
|
|
return nil
|
|
} |