1318 lines
40 KiB
Go

// internal/api/routes.go
package api
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"regexp"
"database/sql"
"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
globalRelayService *GlobalRelayService
}
// NewAPI creates a new API instance
func NewAPI(
logger *zap.Logger,
botService *BotService,
authService *auth.Service,
scheduler *scheduler.Scheduler,
globalRelayService *GlobalRelayService, // Changed from colon to comma
) *API {
router := gin.Default()
api := &API{
router: router,
logger: logger,
botService: botService,
authService: authService,
scheduler: scheduler,
globalRelayService: globalRelayService, // Added this missing field
}
// 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)
}
// Global relay management
globalRelayGroup := apiGroup.Group("/global-relays")
globalRelayGroup.Use(a.requireAuth)
{
globalRelayGroup.GET("", a.listGlobalRelays)
globalRelayGroup.POST("", a.addGlobalRelay)
globalRelayGroup.PUT("/:id", a.updateGlobalRelay)
globalRelayGroup.DELETE("/:id", a.deleteGlobalRelay)
}
// 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 botUpdate models.Bot
if err := c.ShouldBindJSON(&botUpdate); 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 the received data for debugging
a.logger.Info("Received update data",
zap.Int64("bot_id", botID),
zap.String("name", botUpdate.Name),
zap.String("display_name", botUpdate.DisplayName),
zap.String("profile_picture", botUpdate.ProfilePicture),
zap.String("banner", botUpdate.Banner),
zap.Any("website", botUpdate.Website))
// Set the ID and owner
botUpdate.ID = botID
botUpdate.OwnerPubkey = pubkey
// Create SQL NullString for website if it's provided
if websiteStr, ok := c.GetPostForm("website"); ok && websiteStr != "" {
botUpdate.Website = sql.NullString{
String: websiteStr,
Valid: true,
}
}
// Update the bot
updatedBot, err := a.botService.UpdateBot(&botUpdate)
if err != nil {
a.logger.Error("Failed to update bot", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update bot: " + err.Error()})
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
}
// DEBUG: Log the raw request body
rawData, _ := c.GetRawData()
a.logger.Info("Raw config update request",
zap.String("raw_body", string(rawData)),
zap.Int64("botID", botID))
// Need to restore the body for binding
c.Request.Body = io.NopCloser(bytes.NewBuffer(rawData))
var config struct {
PostConfig *models.PostConfig `json:"post_config"`
MediaConfig *models.MediaConfig `json:"media_config"`
}
if err := c.ShouldBindJSON(&config); err != nil {
a.logger.Error("Failed to bind JSON", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid config data: " + err.Error()})
return
}
// Log the parsed configuration
a.logger.Info("Parsed bot config update",
zap.Int64("botID", botID),
zap.Any("post_config", config.PostConfig))
// Set the bot ID
if config.PostConfig != nil {
config.PostConfig.BotID = botID
}
if config.MediaConfig != nil {
config.MediaConfig.BotID = botID
}
// Update the configs - log before update
if config.PostConfig != nil {
a.logger.Info("Updating post config in database",
zap.Int64("botID", botID),
zap.String("post_mode", config.PostConfig.PostMode),
zap.Int("interval", config.PostConfig.IntervalMinutes),
zap.String("hashtags", config.PostConfig.Hashtags))
}
// 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
}
// Log after update - query the database to confirm update
var dbPostMode string
dbErr := a.botService.db.Get(&dbPostMode, "SELECT post_mode FROM post_config WHERE bot_id = ?", botID)
if dbErr != nil {
a.logger.Warn("Failed to verify post_mode update in database", zap.Error(dbErr))
} else {
a.logger.Info("Post mode after database update", zap.String("db_post_mode", dbPostMode))
}
// 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 with NIP-19 encoding
event, err := a.botService.PublishBotProfileWithEncoding(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",
"event": event,
})
}
// 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.
IsProfile bool `json:"isProfile"` // Optional: indicates this is a profile/banner image
}
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 using a fallback chain:
// 1. Request URL, 2. Bot's config URL, 3. Global config URL
serverURL := req.ServerURL
if serverURL == "" {
serverURL = bot.MediaConfig.BlossomServerURL
// If still empty, use the global config URL
if serverURL == "" {
// Get from global config - you'll need to access this from somewhere
// Assuming we have a config accessor, something like:
serverURL = "https://cdn.sovbit.host" // Default from config.yaml as fallback
}
}
// 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" {
// Similar fallback chain for NIP-94
serverURL := req.ServerURL
if serverURL == "" {
serverURL = bot.MediaConfig.Nip94ServerURL
// If still empty, use the global config URL
if serverURL == "" {
// Get from global config
serverURL = "https://files.sovbit.host" // Default from config.yaml as fallback
}
}
// Log the chosen server URL
a.logger.Info("Creating NIP-94 uploader with server URL",
zap.String("url", 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
}
// Create appropriate caption and alt text for profile images
caption := strings.TrimSuffix(req.Filename, filepath.Ext(req.Filename))
altText := caption
// For profile or banner images, set more appropriate caption/alt text
if req.IsProfile {
if strings.Contains(strings.ToLower(req.Filename), "profile") {
caption = fmt.Sprintf("%s's profile picture", bot.Name)
altText = caption
} else if strings.Contains(strings.ToLower(req.Filename), "banner") {
caption = fmt.Sprintf("%s's banner image", bot.Name)
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")
botID, err := strconv.ParseInt(c.Param("id"), 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"`
Hashtags []string `json:"hashtags"`
PostMode string `json:"post_mode"` // Added post_mode parameter for hybrid support
}
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
mediaType = inferMediaTypeFromURL(mediaURL)
}
// Create the appropriate event
var event *nostr.Event
var textEvent *nostr.Event // For hybrid mode
var eventErr error
var hybridMode bool
// Check if we're using hybrid mode
hybridMode = (req.PostMode == "hybrid")
switch req.Kind {
case 1:
// Standard text note
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
if hybridMode && strings.HasPrefix(mediaType, "image/") {
// In hybrid mode, create both kind 1 and kind 20 events
// First create kind 1 event
var tags []nostr.Tag
// Add hashtags to tags
for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
// Create media tags for kind 1
if mediaURL != "" {
imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType}
if mediaHash != "" {
imeta = append(imeta, "x "+mediaHash)
}
if req.Alt != "" {
imeta = append(imeta, "alt "+req.Alt)
}
tags = append(tags, imeta)
}
textEvent, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
if eventErr != nil {
a.logger.Error("Failed to create kind 1 event in hybrid mode", zap.Error(eventErr))
// Continue with kind 20 even if kind 1 fails
} else {
// Publish kind 1 event separately
ctxText, cancelText := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelText()
textEncoded, textErr := a.botService.relayMgr.PublishEventWithEncoding(ctxText, textEvent)
if textErr != nil {
a.logger.Error("Failed to publish kind 1 event in hybrid mode", zap.Error(textErr))
} else {
a.logger.Info("Published kind 1 event in hybrid mode",
zap.String("event_id", textEncoded.ID))
}
}
}
// Then create kind 20 event (for both normal and hybrid modes)
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
bot.Pubkey,
req.Title,
req.Content,
mediaURL,
mediaType,
mediaHash,
req.Alt,
req.Hashtags,
)
case 0: // Special case for hybrid mode selection
if !hybridMode {
c.JSON(http.StatusBadRequest, gin.H{"error": "Kind 0 is only valid with hybrid mode"})
return
}
// Create both a kind 1 and kind 20 event
var tags []nostr.Tag
for _, tag := range req.Hashtags {
tags = append(tags, nostr.Tag{"t", tag})
}
// Add media tags for kind 1
if mediaURL != "" {
imeta := []string{"imeta", "url " + mediaURL, "m " + mediaType}
if mediaHash != "" {
imeta = append(imeta, "x "+mediaHash)
}
if req.Alt != "" {
imeta = append(imeta, "alt "+req.Alt)
}
tags = append(tags, imeta)
}
// Create and publish kind 1 event
textEvent, eventErr = a.botService.eventMgr.CreateAndSignTextNoteEvent(bot.Pubkey, req.Content, tags)
if eventErr == nil {
ctxText, cancelText := context.WithTimeout(context.Background(), 30*time.Second)
defer cancelText()
textEncoded, textErr := a.botService.relayMgr.PublishEventWithEncoding(ctxText, textEvent)
if textErr != nil {
a.logger.Error("Failed to publish kind 1 event in hybrid mode", zap.Error(textErr))
} else {
a.logger.Info("Published kind 1 event in hybrid mode",
zap.String("event_id", textEncoded.ID))
}
}
// If it's an image, create kind 20 event too
if strings.HasPrefix(mediaType, "image/") {
event, eventErr = a.botService.eventMgr.CreateAndSignPictureEvent(
bot.Pubkey,
req.Title,
req.Content,
mediaURL,
mediaType,
mediaHash,
req.Alt,
req.Hashtags,
)
} else {
// For non-images, just use the kind 1 event we already created
c.JSON(http.StatusOK, gin.H{
"message": "Kind 1 post published successfully in hybrid mode",
})
return
}
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
}
// Get combined relays (bot + global)
combinedRelays, err := a.globalRelayService.GetAllRelaysForPosting(botID, pubkey)
if err != nil {
a.logger.Warn("Failed to get combined relays, using bot relays only",
zap.Int64("botID", botID),
zap.Error(err))
combinedRelays = bot.Relays
}
// Configure relay manager with combined relays
for _, relay := range combinedRelays {
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 with indication of hybrid mode if used
message := "Post published successfully"
if hybridMode {
message = "Post published successfully (hybrid mode - both kind:1 and kind:20)"
}
c.JSON(http.StatusOK, gin.H{
"message": message,
"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
}
// addGlobalRelay adds a global relay for the current user
func (a *API) addGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
var relay models.Relay
if err := c.ShouldBindJSON(&relay); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
return
}
// Set the owner pubkey
relay.OwnerPubkey = pubkey
if err := a.globalRelayService.AddGlobalRelay(&relay); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add global relay"})
a.logger.Error("Failed to add global relay", zap.Error(err))
return
}
// Get the updated list
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"})
a.logger.Error("Failed to get updated global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}
// updateGlobalRelay updates a global relay
func (a *API) updateGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
relayID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"})
return
}
var relay models.Relay
if err := c.ShouldBindJSON(&relay); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay data"})
return
}
// Set the ID and owner pubkey
relay.ID = relayID
relay.OwnerPubkey = pubkey
if err := a.globalRelayService.UpdateGlobalRelay(&relay); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update global relay"})
a.logger.Error("Failed to update global relay", zap.Error(err))
return
}
// Get the updated list
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve updated relays"})
a.logger.Error("Failed to get updated global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}
// deleteGlobalRelay deletes a global relay
func (a *API) deleteGlobalRelay(c *gin.Context) {
pubkey := c.GetString("pubkey")
relayID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid relay ID"})
return
}
if err := a.globalRelayService.DeleteGlobalRelay(relayID, pubkey); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete global relay"})
a.logger.Error("Failed to delete global relay", zap.Error(err))
return
}
c.JSON(http.StatusOK, gin.H{"message": "Global relay deleted"})
}
func (a *API) listGlobalRelays(c *gin.Context) {
pubkey := c.GetString("pubkey")
// Use your existing service method to get global relays
relays, err := a.globalRelayService.GetUserGlobalRelays(pubkey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get global relays"})
a.logger.Error("Failed to get global relays", zap.Error(err))
return
}
c.JSON(http.StatusOK, relays)
}