// internal/api/routes.go package api import ( "net/http" "strconv" "github.com/gin-gonic/gin" "git.sovbit.dev/Enki/nostr-poster/internal/auth" "git.sovbit.dev/Enki/nostr-poster/internal/models" "git.sovbit.dev/Enki/nostr-poster/internal/scheduler" "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) } // 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) } // 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.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) } // createBot creates a new bot func (a *API) createBot(c *gin.Context) { pubkey := c.GetString("pubkey") var bot models.Bot if err := c.ShouldBindJSON(&bot); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid bot data"}) return } // Set the owner bot.OwnerPubkey = pubkey // Create the bot createdBot, err := a.botService.CreateBot(&bot) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create bot"}) a.logger.Error("Failed to create bot", zap.Error(err)) return } 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) { // This will be implemented when we add content management c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) } // uploadContent uploads content for a bot func (a *API) uploadContent(c *gin.Context) { // This will be implemented when we add content management c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) } // deleteContent deletes content for a bot func (a *API) deleteContent(c *gin.Context) { // This will be implemented when we add content management c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) } // 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"}) }