// internal/api/routes.go package api import ( "context" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" "regexp" "github.com/gin-gonic/gin" "github.com/nbd-wtf/go-nostr" "git.sovbit.dev/Enki/nostr-poster/internal/auth" "git.sovbit.dev/Enki/nostr-poster/internal/models" "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom" "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94" "go.uber.org/zap" ) // API represents the HTTP API for managing bots type API struct { router *gin.Engine logger *zap.Logger botService *BotService authService *auth.Service scheduler *scheduler.Scheduler } // NewAPI creates a new API instance func NewAPI( logger *zap.Logger, botService *BotService, authService *auth.Service, scheduler *scheduler.Scheduler, ) *API { router := gin.Default() api := &API{ router: router, logger: logger, botService: botService, authService: authService, scheduler: scheduler, } // Set up routes api.setupRoutes() return api } // SetupRoutes configures the API routes func (a *API) setupRoutes() { // Public routes a.router.GET("/health", a.healthCheck) // API routes apiGroup := a.router.Group("/api") // Authentication apiGroup.POST("/auth/login", a.login) apiGroup.GET("/auth/verify", a.requireAuth, a.verifyAuth) // Bot management botGroup := apiGroup.Group("/bots") botGroup.Use(a.requireAuth) { botGroup.GET("", a.listBots) botGroup.POST("", a.createBot) botGroup.GET("/:id", a.getBot) botGroup.PUT("/:id", a.updateBot) botGroup.DELETE("/:id", a.deleteBot) // Bot configuration botGroup.GET("/:id/config", a.getBotConfig) botGroup.PUT("/:id/config", a.updateBotConfig) // Relay management botGroup.GET("/:id/relays", a.getBotRelays) botGroup.PUT("/:id/relays", a.updateBotRelays) // Actions botGroup.POST("/:id/profile/publish", a.publishBotProfile) botGroup.POST("/:id/run", a.runBotNow) botGroup.POST("/:id/enable", a.enableBot) botGroup.POST("/:id/disable", a.disableBot) // NEW: Manual post creation botGroup.POST("/:id/post", a.createManualPost) } // Content management contentGroup := apiGroup.Group("/content") contentGroup.Use(a.requireAuth) { contentGroup.GET("/:botId", a.listBotContent) contentGroup.POST("/:botId/upload", a.uploadContent) contentGroup.DELETE("/:botId/:filename", a.deleteContent) // NEW: Media server upload contentGroup.POST("/:botId/uploadToMediaServer", a.uploadToMediaServer) } // Stats statsGroup := apiGroup.Group("/stats") statsGroup.Use(a.requireAuth) { statsGroup.GET("", a.getStats) statsGroup.GET("/:botId", a.getBotStats) } // Serve the web UI a.router.StaticFile("/", "./web/index.html") a.router.StaticFile("/content.html", "./web/content.html") a.router.Static("/assets", "./web/assets") // Handle 404s for SPA a.router.NoRoute(func(c *gin.Context) { c.File("./web/index.html") }) } // Run starts the API server func (a *API) Run(addr string) error { return a.router.Run(addr) } // Middleware for requiring authentication func (a *API) requireAuth(c *gin.Context) { token := c.GetHeader("Authorization") if token == "" { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "Authentication required", }) return } // Verify the auth token pubkey, err := a.authService.VerifyToken(token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "Invalid authentication token", }) return } // Store the pubkey in the context c.Set("pubkey", pubkey) c.Next() } // API Handlers // healthCheck responds with a simple health check func (a *API) healthCheck(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", }) } // login handles user login with NIP-07 signature func (a *API) login(c *gin.Context) { var req struct { Pubkey string `json:"pubkey" binding:"required"` Signature string `json:"signature" binding:"required"` Event string `json:"event" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } // Verify the signature token, err := a.authService.Login(req.Pubkey, req.Signature, req.Event) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "token": token, }) } // verifyAuth verifies the current auth token func (a *API) verifyAuth(c *gin.Context) { pubkey, exists := c.Get("pubkey") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"}) return } c.JSON(http.StatusOK, gin.H{ "pubkey": pubkey, }) } // listBots lists all bots owned by the user func (a *API) listBots(c *gin.Context) { pubkey := c.GetString("pubkey") bots, err := a.botService.ListUserBots(pubkey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list bots"}) a.logger.Error("Failed to list bots", zap.Error(err)) return } c.JSON(http.StatusOK, bots) } func (a *API) createBot(c *gin.Context) { pubkey := c.GetString("pubkey") a.logger.Info("Bot creation request received", zap.String("owner_pubkey", pubkey)) var bot models.Bot if err := c.ShouldBindJSON(&bot); err != nil { a.logger.Error("Invalid bot data", zap.Error(err)) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data: " + err.Error()}) return } // Log received data a.logger.Info("Received bot creation data", zap.String("name", bot.Name), zap.String("display_name", bot.DisplayName), zap.Bool("has_privkey", bot.EncryptedPrivkey != ""), zap.Bool("has_pubkey", bot.Pubkey != "")) // Set the owner bot.OwnerPubkey = pubkey // Create the bot createdBot, err := a.botService.CreateBot(&bot) if err != nil { a.logger.Error("Failed to create bot", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } a.logger.Info("Bot created successfully", zap.Int64("bot_id", createdBot.ID), zap.String("name", createdBot.Name)) createdBot.EncryptedPrivkey = "" c.JSON(http.StatusCreated, createdBot) } // getBot gets a specific bot by ID func (a *API) getBot(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Get the bot bot, err := a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } c.JSON(http.StatusOK, bot) } // updateBot updates a bot func (a *API) updateBot(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } var bot models.Bot if err := c.ShouldBindJSON(&bot); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"}) return } // Set the ID and owner bot.ID = botID bot.OwnerPubkey = pubkey // Update the bot updatedBot, err := a.botService.UpdateBot(&bot) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot"}) a.logger.Error("Failed to update bot", zap.Error(err)) return } c.JSON(http.StatusOK, updatedBot) } // deleteBot deletes a bot func (a *API) deleteBot(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Delete the bot err = a.botService.DeleteBot(botID, pubkey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete bot"}) a.logger.Error("Failed to delete bot", zap.Error(err)) return } c.Status(http.StatusNoContent) } // getBotConfig gets a bot's configuration func (a *API) getBotConfig(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Get the bot with config bot, err := a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } c.JSON(http.StatusOK, gin.H{ "post_config": bot.PostConfig, "media_config": bot.MediaConfig, }) } // updateBotConfig updates a bot's configuration func (a *API) updateBotConfig(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } var config struct { PostConfig *models.PostConfig `json:"post_config"` MediaConfig *models.MediaConfig `json:"media_config"` } if err := c.ShouldBindJSON(&config); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config data"}) return } // Set the bot ID if config.PostConfig != nil { config.PostConfig.BotID = botID } if config.MediaConfig != nil { config.MediaConfig.BotID = botID } // Update the configs err = a.botService.UpdateBotConfig(botID, pubkey, config.PostConfig, config.MediaConfig) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update config"}) a.logger.Error("Failed to update bot config", zap.Error(err)) return } // Get the updated bot bot, err := a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Update the scheduler if needed if config.PostConfig != nil && config.PostConfig.Enabled { err = a.scheduler.UpdateBotSchedule(bot) if err != nil { a.logger.Error("Failed to update bot schedule", zap.Int64("botID", botID), zap.Error(err)) } } else if config.PostConfig != nil && !config.PostConfig.Enabled { a.scheduler.UnscheduleBot(botID) } c.JSON(http.StatusOK, gin.H{ "post_config": bot.PostConfig, "media_config": bot.MediaConfig, }) } // getBotRelays gets a bot's relay configuration func (a *API) getBotRelays(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Get the relays relays, err := a.botService.GetBotRelays(botID, pubkey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get relays"}) a.logger.Error("Failed to get bot relays", zap.Error(err)) return } c.JSON(http.StatusOK, relays) } // updateBotRelays updates a bot's relay configuration func (a *API) updateBotRelays(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } var relays []*models.Relay if err := c.ShouldBindJSON(&relays); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"}) return } // Set the bot ID for each relay for _, relay := range relays { relay.BotID = botID } // Update the relays err = a.botService.UpdateBotRelays(botID, pubkey, relays) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update relays"}) a.logger.Error("Failed to update bot relays", zap.Error(err)) return } // Get the updated relays updatedRelays, err := a.botService.GetBotRelays(botID, pubkey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updated relays"}) a.logger.Error("Failed to get updated bot relays", zap.Error(err)) return } c.JSON(http.StatusOK, updatedRelays) } // publishBotProfile publishes a bot's profile to Nostr func (a *API) publishBotProfile(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Publish the profile err = a.botService.PublishBotProfile(botID, pubkey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish profile"}) a.logger.Error("Failed to publish bot profile", zap.Error(err)) return } c.JSON(http.StatusOK, gin.H{ "message": "Profile published successfully", }) } // runBotNow runs a bot immediately func (a *API) runBotNow(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Check if the bot belongs to the user _, err = a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Run the bot err = a.scheduler.RunNow(botID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to run bot"}) a.logger.Error("Failed to run bot now", zap.Error(err)) return } c.JSON(http.StatusOK, gin.H{ "message": "Bot is running", }) } // enableBot enables a bot func (a *API) enableBot(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Get the bot bot, err := a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Enable the bot bot.PostConfig.Enabled = true err = a.botService.UpdateBotConfig(botID, pubkey, bot.PostConfig, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to enable bot"}) a.logger.Error("Failed to enable bot", zap.Error(err)) return } // Schedule the bot err = a.scheduler.ScheduleBot(bot) if err != nil { a.logger.Error("Failed to schedule bot", zap.Int64("botID", botID), zap.Error(err)) } c.JSON(http.StatusOK, gin.H{ "message": "Bot enabled", }) } // disableBot disables a bot func (a *API) disableBot(c *gin.Context) { pubkey := c.GetString("pubkey") botID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Get the bot bot, err := a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Disable the bot bot.PostConfig.Enabled = false err = a.botService.UpdateBotConfig(botID, pubkey, bot.PostConfig, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable bot"}) a.logger.Error("Failed to disable bot", zap.Error(err)) return } // Unschedule the bot a.scheduler.UnscheduleBot(botID) c.JSON(http.StatusOK, gin.H{ "message": "Bot disabled", }) } // listBotContent lists the content files for a bot func (a *API) listBotContent(c *gin.Context) { pubkey := c.GetString("pubkey") botIDStr := c.Param("botId") botID, err := strconv.ParseInt(botIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Ensure the bot belongs to the user _, err = a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Build the path to the bot's content folder // e.g. content/bot_123 contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID)) // If the folder doesn't exist yet, return empty if _, err := os.Stat(contentDir); os.IsNotExist(err) { c.JSON(http.StatusOK, []string{}) return } // Read the directory entries, err := os.ReadDir(contentDir) if err != nil { a.logger.Error("Failed to read content folder", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read content folder"}) return } // Collect filenames var files []string for _, entry := range entries { // skip subdirs if you want, or handle them if entry.IsDir() { continue } files = append(files, entry.Name()) } c.JSON(http.StatusOK, files) } // uploadContent uploads content for a bot func (a *API) uploadContent(c *gin.Context) { pubkey := c.GetString("pubkey") botIDStr := c.Param("botId") botID, err := strconv.ParseInt(botIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Ensure the bot belongs to user _, err = a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Get the file from the form data file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No file in request"}) return } // Build the path: e.g. content/bot_123 contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID)) // Ensure the directory exists if err := os.MkdirAll(contentDir, 0755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bot folder"}) return } // Destination path destPath := filepath.Join(contentDir, file.Filename) // Save the file if err := c.SaveUploadedFile(file, destPath); err != nil { a.logger.Error("Failed to save uploaded file", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save file"}) return } c.JSON(http.StatusOK, gin.H{"message": "File uploaded successfully", "filename": file.Filename}) } // deleteContent deletes content for a bot func (a *API) deleteContent(c *gin.Context) { pubkey := c.GetString("pubkey") botIDStr := c.Param("botId") botID, err := strconv.ParseInt(botIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } filename := c.Param("filename") if filename == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "No filename"}) return } // Ensure the bot belongs to user _, err = a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Build the path contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID)) filePath := filepath.Join(contentDir, filename) // Remove the file if err := os.Remove(filePath); err != nil { if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) } else { a.logger.Error("Failed to delete file", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete file"}) } return } c.Status(http.StatusOK) } // uploadToMediaServer handles uploading a file from the server to a media server func (a *API) uploadToMediaServer(c *gin.Context) { pubkey := c.GetString("pubkey") botIDStr := c.Param("botId") botID, err := strconv.ParseInt(botIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Look up the bot to get its details (including its pubkey and media config) bot, err := a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Parse request body. var req struct { Filename string `json:"filename" binding:"required"` Service string `json:"service" binding:"required"` ServerURL string `json:"serverURL"` // Optional: if provided, will override bot's media config URL. } if err := c.BindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } // Build path to the file contentDir := filepath.Join(a.scheduler.GetContentDir(), fmt.Sprintf("bot_%d", botID)) filePath := filepath.Join(contentDir, req.Filename) // Verify the file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) return } // Create a new uploader instance that uses the bot's key. var uploader scheduler.MediaUploader if req.Service == "blossom" { // Get the base Blossom server URL serverURL := bot.MediaConfig.BlossomServerURL if req.ServerURL != "" { serverURL = req.ServerURL } // Log the URL for debugging purposes a.logger.Info("Creating Blossom uploader with server URL", zap.String("original_url", serverURL)) // According to BUD-02 specification, the upload endpoint should be /upload // Make sure the URL ends with /upload if !strings.HasSuffix(serverURL, "/upload") { serverURL = strings.TrimSuffix(serverURL, "/") + "/upload" a.logger.Info("Adding /upload endpoint to URL", zap.String("complete_url", serverURL)) } uploader = blossom.NewUploader( serverURL, a.logger, func(url, method string) (string, error) { privkey, err := a.botService.GetPrivateKey(bot.Pubkey) if err != nil { return "", err } return blossom.CreateBlossomAuthHeader(url, method, privkey) }, ) } else if req.Service == "nip94" { serverURL := bot.MediaConfig.Nip94ServerURL if req.ServerURL != "" { serverURL = req.ServerURL } uploader = nip94.NewUploader( serverURL, "", // Download URL will be discovered nil, // Supported types will be discovered a.logger, func(url, method string, payload []byte) (string, error) { privkey, err := a.botService.GetPrivateKey(bot.Pubkey) if err != nil { return "", err } return nip94.CreateNIP98AuthHeader(url, method, payload, privkey) }, ) } else { c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown service"}) return } caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename)) altText := caption mediaURL, mediaHash, err := uploader.UploadFile(filePath, caption, altText) if err != nil { a.logger.Error("Failed to upload to media server", zap.String("file", filePath), zap.String("service", req.Service), zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to upload to media server: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "url": mediaURL, "hash": mediaHash, "service": req.Service, }) } // Updated createManualPost function in routes.go func (a *API) createManualPost(c *gin.Context) { pubkey := c.GetString("pubkey") botIDStr := c.Param("id") botID, err := strconv.ParseInt(botIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot ID"}) return } // Ensure the bot belongs to the user bot, err := a.botService.GetBotByID(botID, pubkey) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Bot not found"}) return } // Parse request body var req struct { Kind int `json:"kind" binding:"required"` Content string `json:"content" binding:"required"` Title string `json:"title"` Alt string `json:"alt"` // Added to support alt text for images Hashtags []string `json:"hashtags"` } if err := c.BindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } // For kind 20 (picture post), title is required if req.Kind == 20 && req.Title == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Title is required for picture posts"}) return } // Process content to extract media URLs var mediaURL string var mediaType string var mediaHash string // Check if content contains URLs re := regexp.MustCompile(`https?://[^\s]+`) matches := re.FindAllString(req.Content, -1) if len(matches) > 0 { mediaURL = matches[0] // Use the first URL found // Try to determine media type mediaType = inferMediaTypeFromURL(mediaURL) } // Create the appropriate event var event *nostr.Event var eventErr error switch req.Kind { case 1: // Standard text note // Create tags var tags []nostr.Tag for _, tag := range req.Hashtags { tags = append(tags, nostr.Tag{"t", tag}) } event, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags) case 20: // Picture post event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent( bot.Pubkey, req.Title, req.Content, mediaURL, mediaType, mediaHash, req.Alt, // Use the alt text if provided req.Hashtags, ) default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported post kind"}) return } if eventErr != nil { a.logger.Error("Failed to create event", zap.Error(eventErr)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create event: " + eventErr.Error()}) return } // Configure relay manager for _, relay := range bot.Relays { if relay.Write { if err := a.botService.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil { a.logger.Warn("Failed to add relay", zap.String("url", relay.URL), zap.Error(err)) } } } // Publish to relays with NIP-19 encoding ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() // Use the new method that includes NIP-19 encoding encodedEvent, err := a.botService.relayMgr.PublishEventWithEncoding(ctx, event) if err != nil { a.logger.Error("Failed to publish event", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()}) return } // Return the encoded event response c.JSON(http.StatusOK, gin.H{ "message": "Post published successfully", "event": encodedEvent, }) } // Helper function to infer media type from URL func inferMediaTypeFromURL(url string) string { // Basic implementation - check file extension lowerURL := strings.ToLower(url) // Image types if strings.HasSuffix(lowerURL, ".jpg") || strings.HasSuffix(lowerURL, ".jpeg") { return "image/jpeg" } else if strings.HasSuffix(lowerURL, ".png") { return "image/png" } else if strings.HasSuffix(lowerURL, ".gif") { return "image/gif" } else if strings.HasSuffix(lowerURL, ".webp") { return "image/webp" } else if strings.HasSuffix(lowerURL, ".svg") { return "image/svg+xml" } else if strings.HasSuffix(lowerURL, ".avif") { return "image/avif" } // Default to generic image type if we can't determine specifically return "image/jpeg" } // getStats gets overall statistics func (a *API) getStats(c *gin.Context) { // This will be implemented when we add statistics c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) } // getBotStats gets statistics for a specific bot func (a *API) getBotStats(c *gin.Context) { // This will be implemented when we add statistics c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) } // Helper function to extract hashtags from tags func extractHashtags(tags []nostr.Tag) []string { var hashtags []string for _, tag := range tags { if tag[0] == "t" && len(tag) > 1 { hashtags = append(hashtags, tag[1]) } } return hashtags }